From b009f77ad62f51aea795566f6870119dd63fc7ed Mon Sep 17 00:00:00 2001
From: Nik
Date: Thu, 31 Oct 2024 13:18:22 +0000
Subject: [PATCH] Modify registration flow (#80)
---
apps/web-ui/vite.config.ts | 2 +-
.../admin-api-fixtures/src/lib/adminApiDb.ts | 30 +-
.../src/lib/adminApiMockHandlers.ts | 85 +++-
libs/features/explainers/src/index.ts | 1 +
.../explainers/src/lib/ServiceDeployment.tsx | 4 +-
.../explainers/src/lib/ServiceType.tsx | 47 +++
.../RegisterDeployment/AdditionalHeaders.tsx | 11 +-
.../lib/RegisterDeployment/AssumeARNRole.tsx | 3 +-
.../src/lib/RegisterDeployment/Context.tsx | 342 ++++++++++++++++
.../src/lib/RegisterDeployment/Dialog.tsx | 107 +++--
.../src/lib/RegisterDeployment/Form.tsx | 366 +++++++++---------
.../src/lib/RegisterDeployment/Results.tsx | 121 ++++--
.../src/lib/RegisterDeployment/UseHTTP11.tsx | 38 +-
libs/features/overview-route/src/lib/types.ts | 7 +
libs/ui/button/src/lib/Button.tsx | 1 +
libs/ui/button/src/lib/SubmitButton.tsx | 14 +-
libs/ui/dialog/src/lib/DialogContent.tsx | 2 +-
libs/ui/error/src/lib/ErrorBanner.tsx | 4 +-
.../form-field/src/lib/FormFieldCheckbox.tsx | 27 +-
libs/ui/form-field/src/lib/FormFieldError.tsx | 2 +-
libs/ui/form-field/src/lib/FormFieldInput.tsx | 82 ++--
libs/ui/icons/src/lib/Icons.tsx | 15 +-
.../icons/src/lib/custom-icons/Function.tsx | 21 +
.../icons/src/lib/custom-icons/Question.tsx | 21 +
libs/ui/radio-group/src/lib/RadioGroup.tsx | 3 +
libs/ui/tooltip/src/lib/InlineTooltip.tsx | 92 +++--
libs/ui/tooltip/src/lib/TooltipContent.tsx | 35 +-
libs/ui/tooltip/src/lib/TooltipTrigger.tsx | 9 +-
28 files changed, 1097 insertions(+), 395 deletions(-)
create mode 100644 libs/features/explainers/src/lib/ServiceType.tsx
create mode 100644 libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx
create mode 100644 libs/ui/icons/src/lib/custom-icons/Function.tsx
create mode 100644 libs/ui/icons/src/lib/custom-icons/Question.tsx
diff --git a/apps/web-ui/vite.config.ts b/apps/web-ui/vite.config.ts
index 50a04a62..1dfb14a4 100644
--- a/apps/web-ui/vite.config.ts
+++ b/apps/web-ui/vite.config.ts
@@ -2,7 +2,7 @@ import { vitePlugin as remix } from '@remix-run/dev';
import { defineConfig, loadEnv } from 'vite';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
-const BASE_URL = '/ui/';
+const BASE_URL = '/ui';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
diff --git a/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts b/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts
index 15008a73..0ae9b7f1 100644
--- a/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts
+++ b/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts
@@ -2,10 +2,16 @@ import { factory, manyOf, oneOf, primaryKey } from '@mswjs/data';
import { faker } from '@faker-js/faker';
faker.seed(Date.now());
+const names = faker.helpers.uniqueArray(faker.word.noun, 1000);
+
+let index = 0;
+export function getName() {
+ return names[index++];
+}
export const adminApiDb = factory({
handler: {
- name: primaryKey(() => `${faker.hacker.noun()}`),
+ name: primaryKey(() => `${getName()}`),
ty: () =>
faker.helpers.arrayElement(['Exclusive', 'Shared', 'Workflow'] as const),
input_description: () =>
@@ -13,7 +19,7 @@ export const adminApiDb = factory({
output_description: () => "value of content-type 'application/json'",
},
service: {
- name: primaryKey(() => `${faker.hacker.noun()}Service`),
+ name: primaryKey(() => `${getName()}Service`),
handlers: manyOf('handler'),
deployment: oneOf('deployment'),
ty: () =>
@@ -30,23 +36,21 @@ export const adminApiDb = factory({
deployment: {
id: primaryKey(() => `dp_${faker.string.nanoid(27)}`),
services: manyOf('service'),
+ dryRun: Boolean,
+ endpoint: () => faker.internet.url(),
},
});
const isE2E = process.env['SCENARIO'] === 'E2E';
if (!isE2E) {
- const services = Array(3)
- .fill(null)
- .map(() => adminApiDb.service.create());
Array(30)
.fill(null)
- .map(() =>
- adminApiDb.deployment.create({
- services: services.slice(
- 0,
- Math.floor(Math.random() * services.length + 1)
- ),
- })
- );
+ .map(() => {
+ const deployment = adminApiDb.deployment.create();
+ Array(Math.floor(Math.random() * 3 + 1))
+ .fill(null)
+ .map(() => adminApiDb.service.create({ deployment }));
+ return deployment;
+ });
}
diff --git a/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts b/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts
index 9086f0ad..8a6c27fe 100644
--- a/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts
+++ b/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts
@@ -1,7 +1,6 @@
import * as adminApi from '@restate/data-access/admin-api/spec';
import { http, HttpResponse } from 'msw';
-import { adminApiDb } from './adminApiDb';
-import { faker } from '@faker-js/faker';
+import { adminApiDb, getName } from './adminApiDb';
type FormatParameterWithColon =
S extends `${infer A}{${infer P}}${infer B}` ? `${A}:${P}${B}` : S;
@@ -15,12 +14,26 @@ const listDeploymentsHandler = http.get<
adminApi.operations['list_deployments']['responses']['200']['content']['application/json'],
GetPath<'/deployments'>
>('/deployments', async () => {
- const deployments = adminApiDb.deployment.getAll();
+ const deployments = adminApiDb.deployment
+ .getAll()
+ .filter(({ dryRun }) => !dryRun);
return HttpResponse.json({
deployments: deployments.map((deployment) => ({
id: deployment.id,
- services: deployment.services,
- uri: faker.internet.url(),
+ services: adminApiDb.service
+ .findMany({
+ where: { deployment: { id: { equals: deployment.id } } },
+ })
+ .map((service) => ({
+ name: service.name,
+ deployment_id: deployment.id,
+ public: service.public,
+ revision: service.revision,
+ ty: service.ty,
+ idempotency_retention: service.idempotency_retention,
+ workflow_completion_retention: service.idempotency_retention,
+ })),
+ uri: deployment.endpoint,
protocol_type: 'RequestResponse',
created_at: new Date().toISOString(),
http_version: 'HTTP/2.0',
@@ -37,10 +50,68 @@ const registerDeploymentHandler = http.post<
GetPath<'/deployments'>
>('/deployments', async ({ request }) => {
const requestBody = await request.json();
- const newDeployment = adminApiDb.deployment.create({});
+ const requestEndpoint =
+ 'uri' in requestBody ? requestBody.uri : requestBody.arn;
+ const existingDeployment = adminApiDb.deployment.findFirst({
+ where: {
+ endpoint: {
+ equals: requestEndpoint,
+ },
+ dryRun: {
+ equals: true,
+ },
+ },
+ });
+
+ if (existingDeployment) {
+ adminApiDb.deployment.update({
+ where: {
+ id: {
+ equals: existingDeployment.id,
+ },
+ },
+ data: { dryRun: false },
+ });
+
+ return HttpResponse.json({
+ id: existingDeployment.id,
+ services: adminApiDb.service
+ .findMany({
+ where: { deployment: { id: { equals: existingDeployment.id } } },
+ })
+ .map((service) => ({
+ name: service.name,
+ deployment_id: service.deployment!.id,
+ public: service.public,
+ revision: service.revision,
+ ty: service.ty,
+ idempotency_retention: service.idempotency_retention,
+ workflow_completion_retention: service.idempotency_retention,
+ handlers: service.handlers.map((handler) => ({
+ name: handler.name,
+ ty: handler.ty,
+ input_description: handler.input_description,
+ output_description: handler.output_description,
+ })),
+ })),
+ });
+ }
+
+ const newDeployment = adminApiDb.deployment.create({
+ dryRun: requestBody.dry_run,
+ endpoint: requestEndpoint,
+ });
const services = Array(3)
.fill(null)
- .map(() => adminApiDb.service.create({ deployment: newDeployment }));
+ .map(() =>
+ adminApiDb.service.create({
+ deployment: newDeployment,
+ name: `${getName()}Service`,
+ handlers: Array(Math.floor(Math.random() * 6))
+ .fill(null)
+ .map(() => adminApiDb.handler.create({ name: getName() })),
+ })
+ );
return HttpResponse.json({
id: newDeployment.id,
diff --git a/libs/features/explainers/src/index.ts b/libs/features/explainers/src/index.ts
index 513a74ef..bf436f77 100644
--- a/libs/features/explainers/src/index.ts
+++ b/libs/features/explainers/src/index.ts
@@ -1,2 +1,3 @@
export * from './lib/ServiceDeployment';
export * from './lib/Service';
+export * from './lib/ServiceType';
diff --git a/libs/features/explainers/src/lib/ServiceDeployment.tsx b/libs/features/explainers/src/lib/ServiceDeployment.tsx
index 4da0439b..ef446fc3 100644
--- a/libs/features/explainers/src/lib/ServiceDeployment.tsx
+++ b/libs/features/explainers/src/lib/ServiceDeployment.tsx
@@ -3,9 +3,11 @@ import { PropsWithChildren } from 'react';
export function ServiceDeploymentExplainer({
children,
-}: PropsWithChildren) {
+ className,
+}: PropsWithChildren<{ className?: string }>) {
return (
diff --git a/libs/features/explainers/src/lib/ServiceType.tsx b/libs/features/explainers/src/lib/ServiceType.tsx
new file mode 100644
index 00000000..fc0391c8
--- /dev/null
+++ b/libs/features/explainers/src/lib/ServiceType.tsx
@@ -0,0 +1,47 @@
+import { InlineTooltip } from '@restate/ui/tooltip';
+import { ComponentProps, PropsWithChildren } from 'react';
+import * as adminApi from '@restate/data-access/admin-api/spec';
+type ServiceType = adminApi.components['schemas']['ServiceMetadata']['ty'];
+
+const TITLES: Record = {
+ Service: 'Service',
+ VirtualObject: 'Virtual object',
+ Workflow: 'Workflow',
+};
+
+const DESCRIPTIONS: Record = {
+ Service:
+ 'Services expose a collection of durably executed handlers. They do not have any concurrency limits nor K/V store.',
+ VirtualObject:
+ 'Virtual objects expose a set of durably executed handlers with access to K/V state stored in Restate. To ensure consistent writes to the state, Restate provides concurrency guarantees for Virtual Objects.',
+ Workflow:
+ 'A workflow is a special type of Virtual Object that can be used to implement a set of steps that need to be executed durably. Workflows have additional capabilities such as signaling, querying, additional invocation options',
+};
+
+const LEARN_MORE: Record = {
+ Service: 'https://docs.restate.dev/concepts/services/#services-1',
+ VirtualObject: 'https://docs.restate.dev/concepts/services/#virtual-objects',
+ Workflow: 'https://docs.restate.dev/concepts/services/#workflows',
+};
+export function ServiceTypeExplainer({
+ children,
+ className,
+ variant,
+ type,
+}: PropsWithChildren<{
+ className?: string;
+ variant?: ComponentProps['variant'];
+ type: ServiceType;
+}>) {
+ return (
+ {DESCRIPTIONS[type]}
}
+ learnMoreHref={LEARN_MORE[type]}
+ >
+ {children}
+
+ );
+}
diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx
index 3421e84f..ae4305cc 100644
--- a/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx
+++ b/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx
@@ -5,13 +5,10 @@ import {
FormFieldInput,
} from '@restate/ui/form-field';
import { IconName, Icon } from '@restate/ui/icons';
-import { useListData } from 'react-stately';
+import { useRegisterDeploymentContext } from './Context';
export function AdditionalHeaders() {
- const list = useListData<{ key: string; value: string; index: number }>({
- initialItems: [{ key: '', value: '', index: 0 }],
- getKey: (item) => item.index,
- });
+ const { additionalHeaders: list } = useRegisterDeploymentContext();
return (
@@ -23,7 +20,7 @@ export function AdditionalHeaders() {
Headers added to the discover/invoke requests to the deployment.
- {list.items.map((item) => (
+ {list?.items.map((item) => (
- list.append({
+ list?.append({
key: '',
value: '',
index: Math.floor(Math.random() * 1000000),
diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx
index 3b307642..357f62c1 100644
--- a/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx
+++ b/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx
@@ -10,8 +10,7 @@ export function AssumeARNRole() {
<>
Assume role ARN
- Optional ARN of a role to assume when invoking the addressed Lambda,
- to support role chaining
+ ARN of a role to use when invoking the Lambda
>
}
diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx
new file mode 100644
index 00000000..7c63e456
--- /dev/null
+++ b/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx
@@ -0,0 +1,342 @@
+import { Form } from '@remix-run/react';
+import {
+ createContext,
+ FormEvent,
+ PropsWithChildren,
+ useCallback,
+ useContext,
+ useId,
+ useReducer,
+ useRef,
+} from 'react';
+import { ListData, useListData } from 'react-stately';
+import * as adminApi from '@restate/data-access/admin-api/spec';
+import {
+ useListDeployments,
+ useRegisterDeployment,
+} from '@restate/data-access/admin-api';
+import { useDialog } from '@restate/ui/dialog';
+import { getEndpoint } from '../types';
+
+type NavigateToAdvancedAction = {
+ type: 'NavigateToAdvancedAction';
+};
+
+type NavigateToConfirmAction = {
+ type: 'NavigateToConfirmAction';
+};
+
+type NavigateToEndpointAction = {
+ type: 'NavigateToEndpointAction';
+};
+type UpdateEndpointAction = {
+ type: 'UpdateEndpointAction';
+ payload: Pick<
+ DeploymentRegistrationContextInterface,
+ 'endpoint' | 'isLambda' | 'isDuplicate'
+ >;
+};
+type UpdateRoleArnAction = {
+ type: 'UpdateRoleArnAction';
+ payload: Pick;
+};
+type UpdateUseHttp11Action = {
+ type: 'UpdateUseHttp11Action';
+ payload: Pick;
+};
+type UpdateServicesActions = {
+ type: 'UpdateServicesActions';
+ payload: Pick;
+};
+type UpdateShouldForce = {
+ type: 'UpdateShouldForce';
+ payload: Pick;
+};
+
+type Action =
+ | NavigateToAdvancedAction
+ | NavigateToConfirmAction
+ | NavigateToEndpointAction
+ | UpdateEndpointAction
+ | UpdateRoleArnAction
+ | UpdateUseHttp11Action
+ | UpdateServicesActions
+ | UpdateShouldForce;
+
+interface DeploymentRegistrationContextInterface {
+ endpoint?: string;
+ stage: 'endpoint' | 'advanced' | 'confirm';
+ assumeRoleArn?: string;
+ useHttp11?: boolean;
+ isLambda: boolean;
+ formId?: string;
+ additionalHeaders?: ListData<{
+ key: string;
+ value: string;
+ index: number;
+ }>;
+ isPending?: boolean;
+ isDuplicate?: boolean;
+ shouldForce?: boolean;
+ services?: adminApi.components['schemas']['ServiceMetadata'][];
+ goToEndpoint?: VoidFunction;
+ goToAdvanced?: VoidFunction;
+ goToConfirm?: VoidFunction;
+ updateEndpoint?: (value: UpdateEndpointAction['payload']) => void;
+ register?: (isDryRun: boolean) => void;
+ updateAssumeRoleArn?: (value: string) => void;
+ updateUseHttp11Arn?: (value: boolean) => void;
+ updateShouldForce?: (value: boolean) => void;
+ error:
+ | {
+ message: string;
+ restate_code?: string | null;
+ }
+ | null
+ | undefined;
+}
+
+type State = Pick<
+ DeploymentRegistrationContextInterface,
+ | 'endpoint'
+ | 'isLambda'
+ | 'stage'
+ | 'services'
+ | 'assumeRoleArn'
+ | 'useHttp11'
+ | 'shouldForce'
+ | 'isDuplicate'
+>;
+
+function reducer(state: State, action: Action): State {
+ switch (action.type) {
+ case 'NavigateToAdvancedAction':
+ return { ...state, stage: 'advanced' };
+ case 'NavigateToConfirmAction':
+ return { ...state, stage: 'confirm' };
+ case 'NavigateToEndpointAction':
+ return { ...state, stage: 'endpoint' };
+ case 'UpdateEndpointAction':
+ return { ...state, ...action.payload };
+ case 'UpdateRoleArnAction':
+ return { ...state, assumeRoleArn: action.payload.assumeRoleArn };
+ case 'UpdateUseHttp11Action':
+ return { ...state, useHttp11: action.payload.useHttp11 };
+ case 'UpdateServicesActions':
+ return { ...state, services: action.payload.services };
+ case 'UpdateShouldForce':
+ return { ...state, shouldForce: action.payload.shouldForce };
+
+ default:
+ return state;
+ }
+}
+
+const initialState: DeploymentRegistrationContextInterface = {
+ stage: 'endpoint',
+ isLambda: false,
+ error: null,
+};
+const DeploymentRegistrationContext =
+ createContext(initialState);
+
+function withoutTrailingSlash(url?: string) {
+ return url?.endsWith('/') ? url.slice(0, -1) : url;
+}
+
+export function DeploymentRegistrationState(props: PropsWithChildren) {
+ const id = useId();
+ const formRef = useRef(null);
+ const [state, dispatch] = useReducer(reducer, initialState);
+ const additionalHeaders = useListData<{
+ key: string;
+ value: string;
+ index: number;
+ }>({
+ initialItems: [{ key: '', value: '', index: 0 }],
+ getKey: (item) => item.index,
+ });
+ const { close } = useDialog();
+ const goToAdvanced = useCallback(() => {
+ dispatch({ type: 'NavigateToAdvancedAction' });
+ }, []);
+ const goToEndpoint = useCallback(() => {
+ dispatch({ type: 'NavigateToEndpointAction' });
+ }, []);
+ const goToConfirm = useCallback(() => {
+ dispatch({ type: 'NavigateToConfirmAction' });
+ }, []);
+ const updateAssumeRoleArn = useCallback((assumeRoleArn: string) => {
+ dispatch({ type: 'UpdateRoleArnAction', payload: { assumeRoleArn } });
+ }, []);
+ const updateUseHttp11Arn = useCallback((useHttp11: boolean) => {
+ dispatch({ type: 'UpdateUseHttp11Action', payload: { useHttp11 } });
+ }, []);
+ const updateServices = useCallback(
+ (services: DeploymentRegistrationContextInterface['services']) => {
+ dispatch({ type: 'UpdateServicesActions', payload: { services } });
+ },
+ []
+ );
+ const updateShouldForce = useCallback(
+ (shouldForce: DeploymentRegistrationContextInterface['shouldForce']) => {
+ dispatch({ type: 'UpdateShouldForce', payload: { shouldForce } });
+ },
+ []
+ );
+ const { refetch, data: listDeployments } = useListDeployments();
+ const updateEndpoint = useCallback(
+ (value: UpdateEndpointAction['payload']) => {
+ if (state.isLambda !== value.isLambda) {
+ formRef.current?.reset();
+ }
+ const isDuplicate = listDeployments?.deployments.some(
+ (deployment) =>
+ withoutTrailingSlash(getEndpoint(deployment)) ===
+ withoutTrailingSlash(value.endpoint)
+ );
+ dispatch({
+ type: 'UpdateEndpointAction',
+ payload: {
+ isLambda: value.isLambda,
+ endpoint: value.endpoint,
+ isDuplicate,
+ },
+ });
+ },
+ [listDeployments?.deployments, state.isLambda]
+ );
+ const { mutate, isPending, error } = useRegisterDeployment({
+ onSuccess(data) {
+ updateServices(data?.services);
+
+ if (state.stage === 'confirm') {
+ refetch();
+ close();
+ } else {
+ goToConfirm();
+ }
+ },
+ });
+
+ const submitHandler = (event: FormEvent) => {
+ event.preventDefault();
+ const submitter = (event.nativeEvent as SubmitEvent)
+ .submitter as HTMLButtonElement;
+ const action = submitter.value;
+ const {
+ endpoint = '',
+ isLambda,
+ assumeRoleArn,
+ useHttp11,
+ shouldForce,
+ } = state;
+
+ if (action === 'advanced') {
+ goToAdvanced();
+ return;
+ }
+
+ const additional_headers: Record =
+ additionalHeaders.items.reduce((result, { key, value }) => {
+ if (typeof key === 'string' && typeof value === 'string' && key) {
+ return { ...result, [key]: value };
+ }
+ return result;
+ }, {});
+
+ mutate({
+ body: {
+ ...(isLambda
+ ? { arn: endpoint, assume_role_arn: assumeRoleArn }
+ : {
+ uri: endpoint,
+ use_http_11: Boolean(useHttp11),
+ }),
+ force: Boolean(shouldForce),
+ dry_run: action === 'dryRun',
+ additional_headers,
+ },
+ });
+ };
+
+ return (
+
+
+
+ );
+}
+
+export function useRegisterDeploymentContext() {
+ const {
+ stage,
+ endpoint,
+ isLambda,
+ goToAdvanced,
+ goToConfirm,
+ goToEndpoint,
+ updateEndpoint,
+ formId,
+ additionalHeaders,
+ services,
+ updateAssumeRoleArn,
+ updateUseHttp11Arn,
+ updateShouldForce,
+ shouldForce,
+ useHttp11,
+ assumeRoleArn,
+ error,
+ isPending,
+ isDuplicate,
+ } = useContext(DeploymentRegistrationContext);
+ const isEndpoint = stage === 'endpoint';
+ const isAdvanced = stage === 'advanced';
+ const isConfirm = stage === 'confirm';
+
+ return {
+ isAdvanced,
+ isEndpoint,
+ isConfirm,
+ isLambda,
+ isDuplicate,
+ endpoint,
+ goToAdvanced,
+ goToConfirm,
+ goToEndpoint,
+ updateEndpoint,
+ isPending,
+ formId,
+ additionalHeaders,
+ services,
+ updateAssumeRoleArn,
+ updateUseHttp11Arn,
+ updateShouldForce,
+ shouldForce,
+ useHttp11,
+ assumeRoleArn,
+ error,
+ };
+}
diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx
index bf3d2406..180241ac 100644
--- a/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx
+++ b/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx
@@ -12,53 +12,81 @@ import { ErrorBanner } from '@restate/ui/error';
import { RegistrationForm } from './Form';
import { REGISTER_DEPLOYMENT_QUERY } from './constant';
import { Link } from '@restate/ui/link';
+import {
+ DeploymentRegistrationState,
+ useRegisterDeploymentContext,
+} from './Context';
-function RegisterDeploymentFooter({
- isDryRun,
- setIsDryRun,
- error,
- isPending,
- formId,
-}: {
- isDryRun: boolean;
- formId: string;
- isPending: boolean;
- setIsDryRun: (value: boolean) => void;
- error?: {
- message: string;
- restate_code?: string | null;
- } | null;
-}) {
+function RegisterDeploymentFooter() {
+ const {
+ isAdvanced,
+ isEndpoint,
+ isConfirm,
+ isPending,
+ goToEndpoint,
+ error,
+ formId,
+ } = useRegisterDeploymentContext();
return (
{error &&
}
- {isDryRun ? (
-
+
+
+ Cancel
+
+
+
+ {(isEndpoint || isAdvanced) && (
+
+ Next
+
+
+ )}
+ {isConfirm && (
+
+ Confirm
+
+ )}
+ {isEndpoint && (
+
+ Advanced
+
+
+ )}
+ {isAdvanced && (
- Cancel
+
+ Back
-
- ) : (
- {
- setIsDryRun(true);
- }}
- >
- Back
-
- )}
-
- {isDryRun ? 'Next' : 'Confirm'}
-
+ )}
+
@@ -80,8 +108,11 @@ export function TriggerRegisterDeploymentDialog({
{children}
-
- {RegisterDeploymentFooter}
+
+
+
+
+
);
diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx
index 51ba79b9..5953f0e0 100644
--- a/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx
+++ b/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx
@@ -1,26 +1,15 @@
-import { Form } from '@remix-run/react';
-import {
- useListDeployments,
- useRegisterDeployment,
-} from '@restate/data-access/admin-api';
-import { useDialog } from '@restate/ui/dialog';
import { FormFieldCheckbox, FormFieldInput } from '@restate/ui/form-field';
import { Icon, IconName } from '@restate/ui/icons';
-import {
- FormEvent,
- PropsWithChildren,
- ReactNode,
- useEffect,
- useId,
- useState,
-} from 'react';
+import { PropsWithChildren, ReactNode } from 'react';
import { Radio } from 'react-aria-components';
import { RadioGroup } from '@restate/ui/radio-group';
import { RegisterDeploymentResults } from './Results';
import { AdditionalHeaders } from '../RegisterDeployment/AdditionalHeaders';
-import { DeploymentType } from '../types';
import { UseHTTP11 } from '../RegisterDeployment/UseHTTP11';
import { AssumeARNRole } from '../RegisterDeployment/AssumeARNRole';
+import { useRegisterDeploymentContext } from './Context';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@restate/ui/tooltip';
+import { ServiceDeploymentExplainer } from '@restate/features/explainers';
function CustomRadio({
value,
@@ -33,7 +22,12 @@ function CustomRadio({
return (
`${className}
+ className={({
+ isFocusVisible,
+ isSelected,
+ isPressed,
+ isDisabled,
+ }) => `${className}
group relative flex cursor-default rounded-lg shadow-none outline-none bg-clip-padding border
${
isFocusVisible
@@ -49,210 +43,198 @@ function CustomRadio({
}
${isPressed && !isSelected ? 'bg-gray-100' : ''}
${!isSelected && !isPressed ? 'bg-white/50' : ''}
+ ${isDisabled ? 'opacity-50' : ''}
`}
>
{children}
);
}
-// TODO: change type on paste
-// fix autofocus
-function RegistrationFormFields({
+
+function Container({
+ title,
+ description,
children,
- className = '',
-}: PropsWithChildren<{ className?: string }>) {
- const [type, setType] = useState('uri');
- const isURI = type === 'uri';
- const isLambda = type === 'arn';
+}: PropsWithChildren<{
+ title: ReactNode;
+ description?: ReactNode;
+}>) {
+ return (
+
+
{title}
+ {description ? (
+
{description}
+ ) : (
+
+ )}
+
{children}
+
+ );
+}
+
+export function RegistrationForm() {
+ const { isEndpoint, isAdvanced, isConfirm } = useRegisterDeploymentContext();
return (
<>
-
-
- Register deployment
-
-
- Point Restate to your deployed services so Restate can discover and
- register your services and handlers
-
-
-
-
- Please specify the HTTP endpoint or Lambda identifier:
-
- }
- />
-
- Please specify the HTTP endpoint or Lambda identifier:
-
- }
- />
-
-
setType(value as 'uri' | 'arn')}
- >
+ {isEndpoint && (
+
+ Register{' '}
+
+ service deployment
+
+ >
+ }
+ description="Please provide the HTTP endpoint or Lambda ARN where your service is running:"
+ >
+
+
+ )}
+ {isAdvanced && (
+
+
+
+ )}
+ {isConfirm && (
+
+
+
+ )}
+ >
+ );
+}
+
+function EndpointForm() {
+ const {
+ isLambda,
+ updateEndpoint,
+ endpoint,
+ isPending,
+ isDuplicate,
+ shouldForce,
+ updateShouldForce,
+ } = useRegisterDeploymentContext();
+ return (
+ <>
+ {
+ updateEndpoint?.({
+ isLambda: value.startsWith('arn')
+ ? true
+ : value.startsWith('http')
+ ? false
+ : isLambda,
+ endpoint: value,
+ });
+ }}
+ >
+
+
+ updateEndpoint?.({
+ isLambda: value === 'true',
+ endpoint: '',
+ })
+ }
+ disabled={isPending}
+ >
+
+
+
+
+ HTTP endpoint
+
+
+
+
-
-
+
+
+ AWS Lambda
+
+
+
+
+
+ {isDuplicate && (
+
+
+
+ Override existing deployments
-
-
- Override existing deployments
-
+ An existing deployment with the same {isLambda ? 'ARN' : 'URL'}{' '}
+ already exists. Would you like to override it?
-
- If selected, it will override any existing deployment with the
- same URI/identifier, potentially causing unrecoverable errors in
- active invocations.
-
-
- {isURI && }
- {isLambda && }
-
-
-
- {children}
+ Please note that this may cause{' '}
+
unrecoverable errors in active invocations.
+
+
+ )}
>
);
}
-export function RegistrationForm({
- children,
-}: {
- children: (props: {
- isDryRun: boolean;
- isPending: boolean;
- formId: string;
- setIsDryRun: (value: boolean) => void;
- error?: {
- message: string;
- restate_code?: string | null;
- } | null;
- }) => ReactNode;
-}) {
- const formId = useId();
- const { close } = useDialog();
- const { refetch } = useListDeployments();
- const [isDryRun, setIsDryRun] = useState(true);
- const { mutate, isPending, error, data, reset } = useRegisterDeployment({
- onSuccess: (data, variables) => {
- setIsDryRun(false);
- if (variables.body?.dry_run === false) {
- refetch();
- close();
- }
- },
- });
-
- useEffect(() => {
- return () => {
- reset();
- };
- }, [reset]);
-
- function handleSubmit(event: FormEvent
) {
- event.preventDefault();
- const formData = new FormData(event.currentTarget);
- const uri = String(formData.get('uri'));
- const arn = String(formData.get('arn'));
- const type = String(formData.get('type'));
- const force = formData.get('force') === 'true';
- const use_http_11 = formData.get('use_http_11') === 'true';
- const assume_role_arn =
- formData.get('assume_role_arn')?.toString() || undefined;
- const keys = formData.getAll('key');
- const values = formData.getAll('value');
- const additional_headers: Record = keys.reduce(
- (result, key, index) => {
- const value = values.at(index);
- if (typeof key === 'string' && typeof value === 'string' && key) {
- return { ...result, [key]: value };
- }
- return result;
- },
- {}
- );
-
- mutate({
- body: {
- ...(type === 'uri' ? { uri, use_http_11 } : { arn, assume_role_arn }),
- force,
- dry_run: isDryRun,
- additional_headers,
- },
- });
- }
+function AdvancedForm() {
+ const { isLambda } = useRegisterDeploymentContext();
return (
-
+ <>
+ {isLambda ? : }
+
+ >
);
}
diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx
index 2ffe28a9..e4ed8db1 100644
--- a/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx
+++ b/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx
@@ -1,11 +1,16 @@
import * as adminApi from '@restate/data-access/admin-api/spec';
import { Icon, IconName } from '@restate/ui/icons';
+import { useRegisterDeploymentContext } from './Context';
+import {
+ UNSTABLE_Disclosure as Disclosure,
+ UNSTABLE_DisclosurePanel as DisclosurePanel,
+} from 'react-aria-components';
+import { Button } from '@restate/ui/button';
+import { InlineTooltip } from '@restate/ui/tooltip';
+import { ServiceTypeExplainer } from '@restate/features/explainers';
-export function RegisterDeploymentResults({
- services,
-}: {
- services: adminApi.components['schemas']['ServiceMetadata'][];
-}) {
+export function RegisterDeploymentResults() {
+ const { services = [] } = useRegisterDeploymentContext();
if (services.length === 0) {
return (
@@ -19,7 +24,11 @@ export function RegisterDeploymentResults({
return (
{services.map((service) => (
-
+
))}
);
@@ -27,33 +36,71 @@ export function RegisterDeploymentResults({
function Service({
service,
+ defaultExpanded,
}: {
service: adminApi.components['schemas']['ServiceMetadata'];
+ defaultExpanded?: boolean;
}) {
return (
-
-
-
-
-
+
+
+ isExpanded
+ ? '[&_.disclosure-icon]:rotate-180 [&>button]:shadow-lg [&>button]:shadow-zinc-800/5'
+ : ''
+ }
+ >
+
+
-
-
{service.name}
-
- rev. {service.revision}
-
-
- {service.ty}
-
-
-
-
- Handlers
-
- {service.handlers.map((handler) => (
-
- ))}
-
+
+
+
{service.name}
+
+
+ {service.ty}
+
+
+
+
+
+ rev. {service.revision}
+
+
+
+
+
+ {service.handlers.length > 0 && (
+
+
+ Handlers
+
+ {service.handlers.map((handler) => (
+
+ ))}
+
+ )}
+
+
);
}
@@ -64,14 +111,20 @@ function ServiceHandler({
handler: adminApi.components['schemas']['ServiceMetadata']['handlers'][number];
}) {
return (
-
-
-
-
+
+
-
{handler.name}
-
+
+
{handler.name}
+
{handler.ty}
diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx
index 712d697e..a3832d64 100644
--- a/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx
+++ b/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx
@@ -1,26 +1,28 @@
import { FormFieldCheckbox } from '@restate/ui/form-field';
+import { useRegisterDeploymentContext } from './Context';
export function UseHTTP11() {
+ const { updateUseHttp11Arn, useHttp11 } = useRegisterDeploymentContext();
+
return (
-
-
+
+
Use HTTP1.1
-
-
-
+
- If selected, discovery will use a client defaulting to{' '}
- HTTP1.1
. HTTP2
may be used for{' '}
- TLS
servers advertising HTTP2
support via
- ALPN. HTTP1.1 will work only in request-response mode.
-
-
+
+ HTTP1.1
will be used for service registration.
+
+
+
);
}
diff --git a/libs/features/overview-route/src/lib/types.ts b/libs/features/overview-route/src/lib/types.ts
index dfb0293b..3aa2a4f1 100644
--- a/libs/features/overview-route/src/lib/types.ts
+++ b/libs/features/overview-route/src/lib/types.ts
@@ -13,3 +13,10 @@ export function isLambdaDeployment(
): deployment is LambdaDeployment {
return 'arn' in deployment;
}
+export function getEndpoint(deployment: Deployment) {
+ if (isHttpDeployment(deployment)) {
+ return deployment.uri;
+ } else {
+ return deployment.arn;
+ }
+}
diff --git a/libs/ui/button/src/lib/Button.tsx b/libs/ui/button/src/lib/Button.tsx
index 6e1c6e72..028f7de7 100644
--- a/libs/ui/button/src/lib/Button.tsx
+++ b/libs/ui/button/src/lib/Button.tsx
@@ -20,6 +20,7 @@ export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'destructive' | 'icon';
className?: string;
form?: string;
+ slot?: string;
}
const styles = tv({
diff --git a/libs/ui/button/src/lib/SubmitButton.tsx b/libs/ui/button/src/lib/SubmitButton.tsx
index d657f65b..4e75e731 100644
--- a/libs/ui/button/src/lib/SubmitButton.tsx
+++ b/libs/ui/button/src/lib/SubmitButton.tsx
@@ -4,7 +4,7 @@ import {
useDeferredValue,
ComponentProps,
} from 'react';
-import { useFetchers } from '@remix-run/react';
+import { useFetchers, useHref } from '@remix-run/react';
import { Button } from './Button';
import { tv } from 'tailwind-variants';
import { useIsMutating } from '@tanstack/react-query';
@@ -17,6 +17,8 @@ export interface SubmitButtonProps {
value?: string;
variant?: 'primary' | 'secondary' | 'destructive' | 'icon';
className?: string;
+ autoFocus?: boolean;
+ hideSpinner?: boolean;
}
const spinnerStyles = tv({
@@ -57,22 +59,23 @@ const styles = tv({
});
function useIsSubmitting(action?: string) {
+ const basename = useHref('/');
let actionUrl: URL | null = null;
try {
actionUrl = new URL(String(action));
} catch {
actionUrl = null;
}
-
+ const formActionPathname = actionUrl?.pathname.split(basename).at(-1);
const fetchers = useFetchers();
const submitFetcher = fetchers.find(
- (fetcher) => fetcher.formAction === actionUrl?.pathname
+ (fetcher) => fetcher.formAction === formActionPathname
);
const isMutating = useIsMutating({
predicate: (mutation) => {
const [pathName] = mutation.options.mutationKey ?? [];
- return actionUrl?.pathname === pathName;
+ return formActionPathname === pathName;
},
});
@@ -82,6 +85,7 @@ function useIsSubmitting(action?: string) {
export function SubmitButton({
disabled,
children,
+ hideSpinner = false,
...props
}: PropsWithChildren
) {
const ref = useRef(null);
@@ -96,7 +100,7 @@ export function SubmitButton({
ref={ref}
disabled={deferredIsSubmitting || disabled}
>
- {deferredIsSubmitting ? (
+ {deferredIsSubmitting && !hideSpinner ? (
{children}
diff --git a/libs/ui/dialog/src/lib/DialogContent.tsx b/libs/ui/dialog/src/lib/DialogContent.tsx
index 9f8a336f..6daa76b7 100644
--- a/libs/ui/dialog/src/lib/DialogContent.tsx
+++ b/libs/ui/dialog/src/lib/DialogContent.tsx
@@ -48,7 +48,7 @@ export function DialogContent({
modalStyles({ ...renderProps, className })
)}
>
-
+
{children}
diff --git a/libs/ui/error/src/lib/ErrorBanner.tsx b/libs/ui/error/src/lib/ErrorBanner.tsx
index da60110b..78c7cd0f 100644
--- a/libs/ui/error/src/lib/ErrorBanner.tsx
+++ b/libs/ui/error/src/lib/ErrorBanner.tsx
@@ -44,7 +44,7 @@ function SingleError({
name={IconName.CircleX}
/>
-
+
{typeof error === 'string' ? error : error.message}
{children && {children}
}
@@ -81,7 +81,7 @@ export function ErrorBanner({
There were {errors.length} errors:
-
+
{errors.map((error) => (
diff --git a/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx b/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx
index 3d7aa2ad..5996f967 100644
--- a/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx
+++ b/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx
@@ -19,6 +19,7 @@ interface FormFieldCheckboxProps
errorMessage?: ComponentProps['children'];
slot?: string;
checked?: boolean;
+ onChange?: (checked: boolean) => void;
direction?: 'left' | 'right';
}
@@ -28,6 +29,7 @@ const styles = tv({
container: 'grid gap-x-2 items-center',
input:
'disabled:text-gray-100 hover:disabled:text-gray-100 focus:disabled:text-gray-100 disabled:bg-gray-100 disabled:border-gray-100 disabled:shadow-none invalid:bg-red-100 invalid:border-red-600 text-blue-600 checked:focus:text-blue-800 bg-gray-100 row-start-1 min-w-0 rounded-md w-5 h-5 border-gray-200 focus:bg-gray-300 hover:bg-gray-300 shadow-[inset_0_0.5px_0.5px_0px_rgba(0,0,0,0.08)]',
+ error: 'error row-start-2 px-0',
},
variants: {
direction: {
@@ -35,11 +37,13 @@ const styles = tv({
container: 'grid-cols-[1.25rem_1fr]',
input: 'col-start-1',
label: 'col-start-2',
+ error: 'col-start-2',
},
right: {
container: 'grid-cols-[1fr_1.25rem]',
- input: 'col-start-2',
+ input: 'self-baseline col-start-2',
label: 'col-start-1',
+ error: 'col-start-1',
},
},
},
@@ -49,23 +53,28 @@ export const FormFieldCheckbox = forwardRef<
PropsWithChildren
>(
(
- { className, errorMessage, children, direction = 'left', ...props },
+ {
+ onChange,
+ className,
+ errorMessage,
+ children,
+ direction = 'left',
+ ...props
+ },
ref
) => {
- const { input, container, label } = styles({ direction });
+ const { input, container, label, error } = styles({ direction });
return (
-
+
onChange?.(event.currentTarget.checked)}
/>
{children}
-
+
);
}
diff --git a/libs/ui/form-field/src/lib/FormFieldError.tsx b/libs/ui/form-field/src/lib/FormFieldError.tsx
index 430834d8..05761cd1 100644
--- a/libs/ui/form-field/src/lib/FormFieldError.tsx
+++ b/libs/ui/form-field/src/lib/FormFieldError.tsx
@@ -9,7 +9,7 @@ interface FormFieldErrorProps extends Pick {
}
const styles = tv({
- base: 'text-xs px-1 pt-0.5 text-red-600 forced-colors:text-[Mark]',
+ base: 'error text-xs px-1 pt-0.5 text-red-600 forced-colors:text-[Mark]',
});
export function FormFieldError({ className, ...props }: FormFieldErrorProps) {
return ;
diff --git a/libs/ui/form-field/src/lib/FormFieldInput.tsx b/libs/ui/form-field/src/lib/FormFieldInput.tsx
index 9e0e2b33..96c931d7 100644
--- a/libs/ui/form-field/src/lib/FormFieldInput.tsx
+++ b/libs/ui/form-field/src/lib/FormFieldInput.tsx
@@ -6,7 +6,12 @@ import {
} from 'react-aria-components';
import { tv } from 'tailwind-variants';
import { FormFieldError } from './FormFieldError';
-import { ComponentProps, ReactNode } from 'react';
+import {
+ ComponentProps,
+ forwardRef,
+ PropsWithChildren,
+ ReactNode,
+} from 'react';
import { FormFieldLabel } from './FormFieldLabel';
const inputStyles = tv({
@@ -38,34 +43,47 @@ interface InputProps
label?: ReactNode;
errorMessage?: ComponentProps['children'];
}
-export function FormFieldInput({
- className,
- required,
- disabled,
- autoComplete = 'off',
- placeholder,
- errorMessage,
- label,
- readonly,
- ...props
-}: InputProps) {
- return (
-
- {!label && {placeholder} }
- {label && {label} }
-
-
-
- );
-}
+export const FormFieldInput = forwardRef<
+ HTMLInputElement,
+ PropsWithChildren
+>(
+ (
+ {
+ className,
+ required,
+ disabled,
+ autoComplete = 'off',
+ placeholder,
+ errorMessage,
+ label,
+ readonly,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ return (
+
+ {!label && {placeholder} }
+ {label && {label} }
+
+
+
+ );
+ }
+);
diff --git a/libs/ui/icons/src/lib/Icons.tsx b/libs/ui/icons/src/lib/Icons.tsx
index d245de63..3a92852d 100644
--- a/libs/ui/icons/src/lib/Icons.tsx
+++ b/libs/ui/icons/src/lib/Icons.tsx
@@ -26,6 +26,9 @@ import {
Box,
SquareFunction,
Info,
+ ArrowRight,
+ ArrowLeft,
+ ChevronLeft,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { tv } from 'tailwind-variants';
@@ -38,6 +41,8 @@ import { Github } from './custom-icons/Github';
import { Discord } from './custom-icons/Discord';
import { SupportTicket } from './custom-icons/SupportTicket';
import { Help } from './custom-icons/Help';
+import { Question } from './custom-icons/Question';
+import { Function } from './custom-icons/Function';
export const enum IconName {
ChevronDown = 'ChevronDown',
@@ -76,6 +81,10 @@ export const enum IconName {
Box = 'Box',
Function = 'SquareFunction',
Info = 'Info',
+ ArrowRight = 'ArrowRight',
+ ArrowLeft = 'ArrowLeft',
+ ChevronLeft = 'ChevronLeft',
+ Question = 'Question',
}
export interface IconsProps {
name: IconName;
@@ -118,8 +127,12 @@ const ICONS: Record = {
[IconName.Help]: Help,
[IconName.Lambda]: Lambda,
[IconName.Box]: Box,
- [IconName.Function]: SquareFunction,
+ [IconName.Function]: Function,
[IconName.Info]: Info,
+ [IconName.ArrowLeft]: ArrowLeft,
+ [IconName.ArrowRight]: ArrowRight,
+ [IconName.ChevronLeft]: ChevronLeft,
+ [IconName.Question]: Question,
};
const styles = tv({
diff --git a/libs/ui/icons/src/lib/custom-icons/Function.tsx b/libs/ui/icons/src/lib/custom-icons/Function.tsx
new file mode 100644
index 00000000..69aa526b
--- /dev/null
+++ b/libs/ui/icons/src/lib/custom-icons/Function.tsx
@@ -0,0 +1,21 @@
+import { LucideProps } from 'lucide-react';
+import { forwardRef } from 'react';
+
+export const Function = forwardRef((props, ref) => {
+ return (
+
+
+
+
+ );
+});
diff --git a/libs/ui/icons/src/lib/custom-icons/Question.tsx b/libs/ui/icons/src/lib/custom-icons/Question.tsx
new file mode 100644
index 00000000..1d17d1bd
--- /dev/null
+++ b/libs/ui/icons/src/lib/custom-icons/Question.tsx
@@ -0,0 +1,21 @@
+import { LucideProps } from 'lucide-react';
+import { forwardRef } from 'react';
+
+export const Question = forwardRef((props, ref) => {
+ return (
+
+
+
+
+ );
+});
diff --git a/libs/ui/radio-group/src/lib/RadioGroup.tsx b/libs/ui/radio-group/src/lib/RadioGroup.tsx
index 20fd54f8..cb19de9e 100644
--- a/libs/ui/radio-group/src/lib/RadioGroup.tsx
+++ b/libs/ui/radio-group/src/lib/RadioGroup.tsx
@@ -12,6 +12,7 @@ interface RadioGroupProps {
className?: string;
defaultValue?: string;
value?: string;
+ disabled?: boolean;
onChange?: AriaRadioGroupProps['onChange'];
}
@@ -22,6 +23,7 @@ export function RadioGroup({
children,
required,
className,
+ disabled,
...props
}: PropsWithChildren) {
return (
@@ -29,6 +31,7 @@ export function RadioGroup({
{...props}
isRequired={required}
className={styles({ className })}
+ isDisabled={disabled}
>
{children}
diff --git a/libs/ui/tooltip/src/lib/InlineTooltip.tsx b/libs/ui/tooltip/src/lib/InlineTooltip.tsx
index 7c3fad04..a79e6b7a 100644
--- a/libs/ui/tooltip/src/lib/InlineTooltip.tsx
+++ b/libs/ui/tooltip/src/lib/InlineTooltip.tsx
@@ -5,11 +5,14 @@ import { Icon, IconName } from '@restate/ui/icons';
import { Button } from '@restate/ui/button';
import { TooltipTrigger as AriaTooltip } from 'react-aria-components';
import { useFocusable, useObjectRef } from 'react-aria';
+import { tv } from 'tailwind-variants';
interface InlineTooltipProps {
title: ReactNode;
description: ReactNode;
learnMoreHref?: string;
+ className?: string;
+ variant?: 'inline-help' | 'indicator-button';
}
export function InlineTooltip({
@@ -17,12 +20,18 @@ export function InlineTooltip({
title,
description,
learnMoreHref,
+ className,
+ variant = 'inline-help',
}: PropsWithChildren) {
const triggerRef = useRef(null);
+ const Trigger =
+ variant === 'inline-help' ? HelpTooltipTrigger : InfoTooltipTrigger;
return (
- {children}
+
+ {children}
+
{title}
@@ -45,29 +54,62 @@ export function InlineTooltip({
);
}
-const TooltipTrigger = forwardRef
>(
- ({ children }, ref) => {
- const refObject = useObjectRef(ref);
- const { focusableProps } = useFocusable({}, refObject);
+const helpStyles = tv({
+ base: 'cursor-help group underline-offset-4 decoration-from-font decoration-dashed underline inline-flex items-center',
+});
- return (
-
-
- {children}{' '}
-
-
-
-
-
-
+const HelpTooltipTrigger = forwardRef<
+ HTMLElement,
+ PropsWithChildren<{ className?: string }>
+>(({ children, className }, ref) => {
+ const refObject = useObjectRef(ref);
+ const { focusableProps } = useFocusable({}, refObject);
+
+ return (
+
+
+ {children}{' '}
- );
- }
-);
+
+
+
+
+
+
+ );
+});
+
+const infoStyles = tv({
+ base: 'group inline-flex items-center gap-1',
+});
+
+const InfoTooltipTrigger = forwardRef<
+ HTMLElement,
+ PropsWithChildren<{ className?: string }>
+>(({ children, className }, ref) => {
+ const refObject = useObjectRef(ref);
+ const { focusableProps } = useFocusable({}, refObject);
+
+ return (
+
+ {children}
+
+
+
+
+ );
+});
diff --git a/libs/ui/tooltip/src/lib/TooltipContent.tsx b/libs/ui/tooltip/src/lib/TooltipContent.tsx
index e22da44d..3505fe2b 100644
--- a/libs/ui/tooltip/src/lib/TooltipContent.tsx
+++ b/libs/ui/tooltip/src/lib/TooltipContent.tsx
@@ -2,16 +2,19 @@ import { ComponentProps, PropsWithChildren } from 'react';
import {
Tooltip as AriaTooltip,
composeRenderProps,
+ OverlayArrow,
Tooltip,
} from 'react-aria-components';
import { tv } from 'tailwind-variants';
interface TooltipContentProps {
className?: string;
+ small?: boolean;
+ offset?: number;
}
const styles = tv({
- base: 'max-w-sm p-4 group bg-zinc-800/90 backdrop-blur-xl border border-zinc-900/80 shadow-[inset_0_1px_0_0_theme(colors.gray.500)] text-gray-300 text-sm rounded-xl drop-shadow-xl will-change-transform',
+ base: 'max-w-sm group border border-zinc-900/80 text-gray-300 drop-shadow-xl will-change-transform',
variants: {
isEntering: {
true: 'animate-in fade-in placement-bottom:slide-in-from-top-0.5 placement-top:slide-in-from-bottom-0.5 placement-left:slide-in-from-right-0.5 placement-right:slide-in-from-left-0.5 ease-out duration-200',
@@ -19,21 +22,45 @@ const styles = tv({
isExiting: {
true: 'animate-out fade-out placement-bottom:slide-out-to-top-0.5 placement-top:slide-out-to-bottom-0.5 placement-left:slide-out-to-right-0.5 placement-right:slide-out-to-left-0.5 ease-in duration-150',
},
+ small: {
+ true: 'text-xs px-2 py-1 rounded-md shadow-[inset_0_0.5px_0_0_theme(colors.gray.500)] bg-zinc-800',
+ false:
+ 'text-sm p-4 rounded-xl shadow-[inset_0_1px_0_0_theme(colors.gray.500)] bg-zinc-800/90 backdrop-blur-xl',
+ },
+ },
+ defaultVariants: {
+ small: false,
},
});
export function InternalTooltipContent({
children,
+ small,
+ offset = 10,
...props
-}: PropsWithChildren>) {
+}: PropsWithChildren<
+ ComponentProps & Pick
+>) {
return (
- styles({ ...renderProps, className })
+ styles({ ...renderProps, className, small })
)}
>
+ {small && (
+
+
+
+
+
+ )}
{children}
);
diff --git a/libs/ui/tooltip/src/lib/TooltipTrigger.tsx b/libs/ui/tooltip/src/lib/TooltipTrigger.tsx
index 864fd7ac..fb7090c4 100644
--- a/libs/ui/tooltip/src/lib/TooltipTrigger.tsx
+++ b/libs/ui/tooltip/src/lib/TooltipTrigger.tsx
@@ -1,5 +1,5 @@
import { ComponentProps, useContext, type PropsWithChildren } from 'react';
-import { Pressable, PressResponder } from '@react-aria/interactions';
+import { Pressable, PressResponder, useHover } from '@react-aria/interactions';
import { TooltipTriggerStateContext } from 'react-aria-components';
type TooltipTriggerProps = Pick, 'children'>;
@@ -7,12 +7,17 @@ export function TooltipTrigger({
children,
}: PropsWithChildren) {
const state = useContext(TooltipTriggerStateContext);
-
+ const { hoverProps } = useHover({
+ onHoverChange(isHovering) {
+ isHovering ? state.open(true) : state.close();
+ },
+ });
return (
{
state.open();
}}
+ {...hoverProps}
>
{children}