diff --git a/apps/web-ui/app/root.tsx b/apps/web-ui/app/root.tsx index 4cb1e2d1..6b0b3cfb 100644 --- a/apps/web-ui/app/root.tsx +++ b/apps/web-ui/app/root.tsx @@ -5,6 +5,7 @@ import { Path, Scripts, ScrollRestoration, + useHref, useNavigate, } from 'react-router'; import styles from './tailwind.css?url'; @@ -90,7 +91,7 @@ export function Layout({ children }: { children: React.ReactNode }) { - + {children} diff --git a/apps/web-ui/app/routes/invocations.tsx b/apps/web-ui/app/routes/invocations.tsx index 47a0a187..5e3034a8 100644 --- a/apps/web-ui/app/routes/invocations.tsx +++ b/apps/web-ui/app/routes/invocations.tsx @@ -1,3 +1,4 @@ import { invocations } from '@restate/features/invocations-route'; export default invocations.Component; +export const clientLoader = invocations.clientLoader; diff --git a/apps/web-ui/app/tailwind.css b/apps/web-ui/app/tailwind.css index 9214d091..db6e421b 100644 --- a/apps/web-ui/app/tailwind.css +++ b/apps/web-ui/app/tailwind.css @@ -7,3 +7,7 @@ font-family: 'Inter var', 'Helvetica', system-ui, sans-serif; } } + +*[data-testid='underlay'] { + pointer-events: none; +} diff --git a/libs/data-access/admin-api/src/index.ts b/libs/data-access/admin-api/src/index.ts index 06ae0b3c..1a959e4e 100644 --- a/libs/data-access/admin-api/src/index.ts +++ b/libs/data-access/admin-api/src/index.ts @@ -1,4 +1,4 @@ export * from './lib/api/client'; export * from './lib/api/hooks'; -export type * from './lib/api/type'; +export * from './lib/api/type'; export * from './lib/AdminBaseUrlProvider'; diff --git a/libs/data-access/admin-api/src/lib/api/hooks.ts b/libs/data-access/admin-api/src/lib/api/hooks.ts index 04dcd375..6c720fd7 100644 --- a/libs/data-access/admin-api/src/lib/api/hooks.ts +++ b/libs/data-access/admin-api/src/lib/api/hooks.ts @@ -1,5 +1,5 @@ import type { paths, components } from './index'; // generated by openapi-typescript -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQueries, useQuery } from '@tanstack/react-query'; import { adminApi, MutationOptions, @@ -15,6 +15,7 @@ import type { ServiceName, Deployment, FilterItem, + Service, } from './type'; type HookQueryOptions< @@ -104,12 +105,18 @@ export function useListDeployments( options?: HookQueryOptions<'/deployments', 'get'> ) { const baseUrl = useAdminBaseUrl(); + const queryOptions = adminApi('query', '/deployments', 'get', { baseUrl }); - return useQuery({ - ...adminApi('query', '/deployments', 'get', { baseUrl }), + const results = useQuery({ + ...queryOptions, ...options, select: listDeploymentsSelector, }); + + return { + ...results, + queryKey: queryOptions.queryKey, + }; } export function useHealth(options?: HookQueryOptions<'/health', 'get'>) { @@ -169,6 +176,38 @@ export function useServiceDetails( return { ...results, queryKey: queryOptions.queryKey }; } +export function useListServices( + services: string[] = [], + options?: HookQueryOptions<'/services/{service}', 'get'> +) { + const baseUrl = useAdminBaseUrl(); + + const results = useQueries({ + queries: services.map((service) => ({ + ...adminApi('query', '/services/{service}', 'get', { + baseUrl, + parameters: { path: { service } }, + }), + staleTime: 0, + ...options, + })), + combine: (results) => { + return { + data: results.reduce((result, service) => { + if (service.data) { + result.set(service.data?.name, service.data); + } + return result; + }, new Map()), + isPending: results.some((result) => result.isPending), + promise: Promise.all(results.map(({ promise }) => promise)), + }; + }, + }); + + return results; +} + export function useDeploymentDetails( deployment: string, options?: HookQueryOptions<'/deployments/{deployment}', 'get'> diff --git a/libs/data-access/admin-api/src/lib/api/index.d.ts b/libs/data-access/admin-api/src/lib/api/index.d.ts index e2561daf..ca571ca3 100644 --- a/libs/data-access/admin-api/src/lib/api/index.d.ts +++ b/libs/data-access/admin-api/src/lib/api/index.d.ts @@ -849,14 +849,20 @@ export interface components { /** @enum {string} */ type: 'NUMBER'; /** @enum {string} */ - operation: 'EQUALS' | 'NOT_EQUALS' | 'GREATER_THAN' | 'LESS_THAN'; + operation: + | 'EQUALS' + | 'NOT_EQUALS' + | 'GREATER_THAN' + | 'LESS_THAN' + | 'GREATER_THAN_OR_EQUAL' + | 'LESS_THAN_OR_EQUAL'; value?: number; }; FilterStringItem: { /** @enum {string} */ type: 'STRING'; /** @enum {string} */ - operation: 'EQUALS' | 'NOT_EQUALS' | 'CONTAINS'; + operation: 'EQUALS' | 'NOT_EQUALS' | 'CONTAINS' | 'NOT_CONTAINS'; value?: string; }; FilterStringListItem: { @@ -1130,7 +1136,8 @@ export interface components { /** Format: date-time */ next_retry_at?: string; id: string; - invoked_by: string; + /** @enum {string} */ + invoked_by: 'ingress' | 'service'; /** @enum {string} */ status: | 'succeeded' @@ -1145,8 +1152,7 @@ export interface components { | 'ready'; target: string; target_handler_name: string; - /** @enum {string} */ - target_service_key?: 'ingress' | 'service'; + target_service_key?: string; target_service_name: string; /** @enum {string} */ target_service_ty: 'service' | 'virtual_object' | 'workflow'; @@ -1190,7 +1196,8 @@ export interface components { /** Format: date-time */ next_retry_at?: string; id: string; - invoked_by: string; + /** @enum {string} */ + invoked_by: 'ingress' | 'service'; /** @enum {string} */ status: | 'pending' @@ -1202,8 +1209,7 @@ export interface components { | 'completed'; target: string; target_handler_name: string; - /** @enum {string} */ - target_service_key?: 'ingress' | 'service'; + target_service_key?: string; target_service_name: string; /** @enum {string} */ target_service_ty: 'service' | 'virtual_object' | 'workflow'; diff --git a/libs/data-access/admin-api/src/lib/api/output.json b/libs/data-access/admin-api/src/lib/api/output.json index 79651779..6761bb9c 100644 --- a/libs/data-access/admin-api/src/lib/api/output.json +++ b/libs/data-access/admin-api/src/lib/api/output.json @@ -2792,7 +2792,14 @@ }, "operation": { "type": "string", - "enum": ["EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN"] + "enum": [ + "EQUALS", + "NOT_EQUALS", + "GREATER_THAN", + "LESS_THAN", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL" + ] }, "value": { "type": "number" @@ -2809,7 +2816,7 @@ }, "operation": { "type": "string", - "enum": ["EQUALS", "NOT_EQUALS", "CONTAINS"] + "enum": ["EQUALS", "NOT_EQUALS", "CONTAINS", "NOT_CONTAINS"] }, "value": { "type": "string" @@ -3693,7 +3700,8 @@ "type": "string" }, "invoked_by": { - "type": "string" + "type": "string", + "enum": ["ingress", "service"] }, "status": { "type": "string", @@ -3717,8 +3725,7 @@ "type": "string" }, "target_service_key": { - "type": "string", - "enum": ["ingress", "service"] + "type": "string" }, "target_service_name": { "type": "string" @@ -3835,7 +3842,8 @@ "type": "string" }, "invoked_by": { - "type": "string" + "type": "string", + "enum": ["ingress", "service"] }, "status": { "type": "string", @@ -3856,8 +3864,7 @@ "type": "string" }, "target_service_key": { - "type": "string", - "enum": ["ingress", "service"] + "type": "string" }, "target_service_name": { "type": "string" diff --git a/libs/data-access/admin-api/src/lib/api/query.json b/libs/data-access/admin-api/src/lib/api/query.json index 1a3b355b..9bfa2c62 100644 --- a/libs/data-access/admin-api/src/lib/api/query.json +++ b/libs/data-access/admin-api/src/lib/api/query.json @@ -845,7 +845,14 @@ }, "operation": { "type": "string", - "enum": ["EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN"] + "enum": [ + "EQUALS", + "NOT_EQUALS", + "GREATER_THAN", + "LESS_THAN", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL" + ] }, "value": { "type": "number" @@ -862,7 +869,7 @@ }, "operation": { "type": "string", - "enum": ["EQUALS", "NOT_EQUALS", "CONTAINS"] + "enum": ["EQUALS", "NOT_EQUALS", "CONTAINS", "NOT_CONTAINS"] }, "value": { "type": "string" @@ -1617,7 +1624,8 @@ "type": "string" }, "invoked_by": { - "type": "string" + "type": "string", + "enum": ["ingress", "service"] }, "status": { "type": "string", @@ -1641,8 +1649,7 @@ "type": "string" }, "target_service_key": { - "type": "string", - "enum": ["ingress", "service"] + "type": "string" }, "target_service_name": { "type": "string" @@ -1759,7 +1766,8 @@ "type": "string" }, "invoked_by": { - "type": "string" + "type": "string", + "enum": ["ingress", "service"] }, "status": { "type": "string", @@ -1780,8 +1788,7 @@ "type": "string" }, "target_service_key": { - "type": "string", - "enum": ["ingress", "service"] + "type": "string" }, "target_service_name": { "type": "string" diff --git a/libs/data-access/admin-api/src/lib/api/type.ts b/libs/data-access/admin-api/src/lib/api/type.ts index 08dfc2f8..e5e7f46a 100644 --- a/libs/data-access/admin-api/src/lib/api/type.ts +++ b/libs/data-access/admin-api/src/lib/api/type.ts @@ -75,3 +75,27 @@ export type FilterNumberItem = components['schemas']['FilterNumberItem']; export type FilterStringListItem = components['schemas']['FilterStringListItem']; export type FilterStringItem = components['schemas']['FilterStringItem']; + +export type HTTPDeployment = Exclude; +export type LambdaDeployment = Exclude; +export type DeploymentType = 'uri' | 'arn'; +export function isHttpDeployment( + deployment: Deployment +): deployment is HTTPDeployment { + return 'uri' in deployment; +} +export function isLambdaDeployment( + deployment: Deployment +): deployment is LambdaDeployment { + return 'arn' in deployment; +} +export function getEndpoint(deployment?: Deployment) { + if (!deployment) { + return undefined; + } + if (isHttpDeployment(deployment)) { + return deployment.uri; + } else { + return deployment.arn; + } +} diff --git a/libs/data-access/query/src/lib/convertFilters.ts b/libs/data-access/query/src/lib/convertFilters.ts index 350a3371..f8d3108d 100644 --- a/libs/data-access/query/src/lib/convertFilters.ts +++ b/libs/data-access/query/src/lib/convertFilters.ts @@ -18,6 +18,10 @@ function convertFilterNumberToSqlClause( return `${filter.field} > ${filter.value}`; case 'LESS_THAN': return `${filter.field} < ${filter.value}`; + case 'GREATER_THAN_OR_EQUAL': + return `${filter.field} >= ${filter.value}`; + case 'LESS_THAN_OR_EQUAL': + return `${filter.field} <= ${filter.value}`; } } @@ -31,6 +35,8 @@ function convertFilterStringToSqlClause( return `${filter.field} != '${filter.value}'`; case 'CONTAINS': return `${filter.field} LIKE '%${filter.value}%'`; + case 'NOT_CONTAINS': + return `${filter.field} NOT LIKE '%${filter.value}%'`; } } @@ -60,6 +66,35 @@ function convertFilterStringListToSqlClause( } } +function negateOperation(op: FilterItem['operation']): FilterItem['operation'] { + switch (op) { + case 'AFTER': + return 'BEFORE'; + case 'BEFORE': + return 'AFTER'; + case 'CONTAINS': + return 'NOT_CONTAINS'; + case 'EQUALS': + return 'NOT_EQUALS'; + case 'GREATER_THAN': + return 'LESS_THAN_OR_EQUAL'; + case 'GREATER_THAN_OR_EQUAL': + return 'LESS_THAN'; + case 'IN': + return 'NOT_IN'; + case 'LESS_THAN': + return 'GREATER_THAN_OR_EQUAL'; + case 'LESS_THAN_OR_EQUAL': + return 'GREATER_THAN'; + case 'NOT_CONTAINS': + return 'CONTAINS'; + case 'NOT_EQUALS': + return 'EQUALS'; + case 'NOT_IN': + return 'IN'; + } +} + function convertFilterToSqlClause(filter: FilterItem) { switch (filter.type) { case 'DATE': @@ -191,7 +226,10 @@ export function convertFilters(filters: FilterItem[]) { .filter(Boolean) .join(' AND ') ); - } else if (statusFilter.type === 'STRING_LIST') { + } else if ( + statusFilter.type === 'STRING_LIST' && + statusFilter.operation === 'IN' + ) { mappedFilters.push( `(${statusFilter.value .map((value) => @@ -203,6 +241,28 @@ export function convertFilters(filters: FilterItem[]) { .map((clause) => `(${clause})`) .join(' OR ')})` ); + } else if ( + statusFilter.type === 'STRING_LIST' && + statusFilter.operation === 'NOT_IN' + ) { + mappedFilters.push( + `(${statusFilter.value + .map((value) => + getStatusFilterString(value) + .map( + (filter) => + ({ + ...filter, + operation: negateOperation(filter.operation), + } as FilterItem) + ) + .map(convertFilterToSqlClause) + .filter(Boolean) + .join(' OR ') + ) + .map((clause) => `(${clause})`) + .join(' AND ')})` + ); } } diff --git a/libs/data-access/query/src/lib/query.ts b/libs/data-access/query/src/lib/query.ts index 4aa95d45..659ff78b 100644 --- a/libs/data-access/query/src/lib/query.ts +++ b/libs/data-access/query/src/lib/query.ts @@ -94,6 +94,7 @@ async function getInvocation( ); } +// TODO: add limit async function getInvocationJournal( invocationId: string, baseUrl: string, @@ -121,7 +122,7 @@ async function getInbox( ) { const [head, size, position] = await Promise.all([ queryFetcher( - `SELECT * FROM sys_invocation WHERE target_service_key = '${key}' AND target_service_name = '${service}' AND status NOT IN ('completed', 'pending', 'scheduled')`, + `SELECT id FROM sys_invocation WHERE target_service_key = '${key}' AND target_service_name = '${service}' AND status NOT IN ('completed', 'pending', 'scheduled')`, { baseUrl, headers, @@ -188,6 +189,7 @@ async function getState( }); } +// TODO: add limit async function getStateInterface( service: string, baseUrl: string, diff --git a/libs/features/invocation-route/src/lib/Journal.tsx b/libs/features/invocation-route/src/lib/Journal.tsx index 321c265c..0d5ca863 100644 --- a/libs/features/invocation-route/src/lib/Journal.tsx +++ b/libs/features/invocation-route/src/lib/Journal.tsx @@ -292,7 +292,7 @@ export function JournalSection({ return (
- Execution Logs + Journal diff --git a/libs/features/invocations-route/src/lib/Filters.tsx b/libs/features/invocations-route/src/lib/Filters.tsx new file mode 100644 index 00000000..9092feb6 --- /dev/null +++ b/libs/features/invocations-route/src/lib/Filters.tsx @@ -0,0 +1,296 @@ +import { Button } from '@restate/ui/button'; +import { + DropdownMenu, + DropdownItem, + DropdownSection, + Dropdown, + DropdownTrigger, + DropdownPopover, +} from '@restate/ui/dropdown'; +import { + FormFieldNumberInput, + FormFieldInput, + FormFieldDateTimeInput, +} from '@restate/ui/form-field'; +import { Icon, IconName } from '@restate/ui/icons'; +import { + QueryClause, + QueryClauseOperationId, + QueryClauseType, + useNewQueryId, +} from '@restate/ui/query-builder'; +import { PropsWithChildren, useEffect, useMemo, useState } from 'react'; + +export function ClauseChip({ + item, + onRemove, + onUpdate, +}: { + item: QueryClause; + onRemove?: VoidFunction; + onUpdate?: (item: QueryClause) => void; +}) { + const isNew = useNewQueryId() === item.id; + + return ( + + + + ); +} + +function EditQueryTrigger({ + children, + onRemove, + onUpdate, + clause, + isNew, +}: PropsWithChildren<{ + clause: QueryClause; + onRemove?: VoidFunction; + onUpdate?: (item: QueryClause) => void; + isNew?: boolean; +}>) { + const selectedOperations = useMemo( + () => (clause.value.operation ? [clause.value.operation] : []), + [clause.value.operation] + ); + + const [isOpen, setIsOpen] = useState(false); + useEffect(() => { + if (isNew) { + setIsOpen(true); + } + }, [isNew]); + + if (!clause) { + return null; + } + const canChangeOperation = clause.operations.length > 1; + const title = canChangeOperation + ? clause.schema.label + : `${clause.schema.label} ${clause.operationLabel}`; + + return ( + + {children} + + + {canChangeOperation && ( + { + const newClause = new QueryClause(clause.schema, { + ...clause.value, + operation: Array.from(operations).at( + -1 + ) as QueryClauseOperationId, + }); + onUpdate?.(newClause); + }} + > + {clause.operations.map((op) => ( + + {op.label} + {op.description} + + ))} + + )} + + + + + {clause.id === 'status' && ( +

+ Completed invocations (succeeded, failed, cancelled, killed) are + retained only for workflows and those with idempotency keys, and + only for the service's specified retention period. +

+ )} + + Remove + +
+
+ ); +} + +function ValueSelector({ + clause, + onUpdate, +}: { + clause: QueryClause; + onUpdate?: (item: QueryClause) => void; +}) { + if (clause.type === 'STRING_LIST') { + // TODO: redo passing options + if (clause.options) { + return ( + { + const newClause = new QueryClause( + { ...clause.schema, options: clause.options }, + { + ...clause.value, + value: Array.from(values as Set), + } + ); + onUpdate?.(newClause); + }} + className="max-h-96" + > + {clause.options?.map((opt) => ( + +
+ {opt.label} +
{opt.description}
+
+
+ ))} +
+ ); + } + } + + if (clause.type === 'STRING') { + if (clause.options) { + return ( + { + const newClause = new QueryClause(clause.schema, { + ...clause.value, + value, + }); + onUpdate?.(newClause); + }} + > + {clause.options?.map((opt) => ( + +
+ {opt.label} +
{opt.description}
+
+
+ ))} +
+ ); + } + return ( + { + const newClause = new QueryClause(clause.schema, { + ...clause.value, + value, + }); + onUpdate?.(newClause); + }} + className="m-1 [&_label]:hidden" + /> + ); + } + + if (clause.type === 'NUMBER') { + return ( + { + const newClause = new QueryClause(clause.schema, { + ...clause.value, + value, + }); + onUpdate?.(newClause); + }} + className="m-1 [&_label]:hidden" + /> + ); + } + + if (clause.type === 'DATE') { + return ( + <> + { + const newClause = new QueryClause(clause.schema, { + ...clause.value, + value: value ? new Date(value) : undefined, + }); + onUpdate?.(newClause); + }} + className="m-1" + /> + { + const multiplier: Record = { + '1m': 1, + '1h': 60, + '1D': 60 * 24, + }; + const newClause = new QueryClause(clause.schema, { + ...clause.value, + value: value + ? new Date(Date.now() - 60 * 1000 * (multiplier[value] ?? 1)) + : undefined, + }); + onUpdate?.(newClause); + }} + > + 1min ago + 1h ago + 1day ago + + + ); + } + return null; +} + +export function FiltersTrigger() { + return ( + + / + + ); +} diff --git a/libs/features/invocations-route/src/lib/cells.tsx b/libs/features/invocations-route/src/lib/cells.tsx index 55ca8d1d..f0bc91f7 100644 --- a/libs/features/invocations-route/src/lib/cells.tsx +++ b/libs/features/invocations-route/src/lib/cells.tsx @@ -159,7 +159,7 @@ function JournalCell({ invocation }: CellProps) { - + @@ -193,6 +193,7 @@ const CELLS: Record> = { deployment: withCell(InvocationDeployment), target_service_key: withCell(withField({ field: 'target_service_key' })), target_service_name: withCell(withField({ field: 'target_service_name' })), + target_handler_name: withCell(withField({ field: 'target_handler_name' })), }; export function InvocationCell({ diff --git a/libs/features/invocations-route/src/lib/columns.tsx b/libs/features/invocations-route/src/lib/columns.tsx index ccb64b42..630a4108 100644 --- a/libs/features/invocations-route/src/lib/columns.tsx +++ b/libs/features/invocations-route/src/lib/columns.tsx @@ -18,6 +18,7 @@ const COLUMNS_KEYS = [ 'target_service_ty', 'target_service_name', 'target_service_key', + 'target_handler_name', ] as const; export type ColumnKey = (typeof COLUMNS_KEYS)[number]; @@ -27,7 +28,7 @@ export const COLUMN_NAMES: Record = { target: 'Target', status: 'Status', invoked_by: 'Invoked by', - journal_size: 'Execution logs', + journal_size: 'Journal', deployment: 'Deployment', retry_count: 'Attempt count', modified_at: 'Modified at', @@ -37,6 +38,7 @@ export const COLUMN_NAMES: Record = { target_service_ty: 'Service type', target_service_name: 'Service name', target_service_key: 'Service key', + target_handler_name: 'Handler', }; const SORT_ORDER: Record = Object.entries( diff --git a/libs/features/invocations-route/src/lib/invocations.route.tsx b/libs/features/invocations-route/src/lib/invocations.route.tsx index 6af7a96b..93e7dd69 100644 --- a/libs/features/invocations-route/src/lib/invocations.route.tsx +++ b/libs/features/invocations-route/src/lib/invocations.route.tsx @@ -1,5 +1,11 @@ -import { Invocation, useListInvocations } from '@restate/data-access/admin-api'; -import { Button } from '@restate/ui/button'; +import { + FilterItem, + getEndpoint, + useListDeployments, + useListInvocations, + useListServices, +} from '@restate/data-access/admin-api'; +import { Button, SubmitButton } from '@restate/ui/button'; import { Cell, Column, @@ -9,7 +15,7 @@ import { TableHeader, } from '@restate/ui/table'; import { useCollator } from 'react-aria'; -import { useAsyncList } from 'react-stately'; +import { SortDescriptor } from 'react-stately'; import { Dropdown, DropdownItem, @@ -21,15 +27,30 @@ import { import { Icon, IconName } from '@restate/ui/icons'; import { COLUMN_NAMES, ColumnKey, useColumns } from './columns'; import { InvocationCell } from './cells'; -import { useQueryClient } from '@tanstack/react-query'; import { SnapshotTimeProvider, useDurationSinceLastSnapshot, } from '@restate/util/snapshot-time'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { formatDurations } from '@restate/util/intl'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@restate/ui/tooltip'; import { Actions } from '@restate/features/invocation-route'; +import { LayoutOutlet, LayoutZone } from '@restate/ui/layout'; +import { + AddQueryTrigger, + QueryBuilder, + QueryClause, + QueryClauseSchema, + QueryClauseType, + useQueryBuilder, +} from '@restate/ui/query-builder'; +import { ClauseChip, FiltersTrigger } from './Filters'; +import { + ClientLoaderFunctionArgs, + Form, + redirect, + useSearchParams, +} from 'react-router'; +import { useQueryClient } from '@tanstack/react-query'; const COLUMN_WIDTH: Partial> = { id: 80, @@ -40,111 +61,288 @@ const COLUMN_WIDTH: Partial> = { }; function Component() { + const { promise: listDeploymentPromise, data: listDeploymentsData } = + useListDeployments(); + const { promise: listServicesPromise } = useListServices( + listDeploymentsData?.sortedServiceNames + ); + const [searchParams, setSearchParams] = useSearchParams(); + const schema = useMemo(() => { + const serviceNamesPromise = listDeploymentPromise.then((results) => + [...(results?.sortedServiceNames ?? [])].sort() + ); + return [ + { + id: 'id', + label: 'Invocation Id', + operations: [{ value: 'EQUALS', label: 'is' }], + type: 'STRING', + }, + { + id: 'status', + label: 'Status', + operations: [ + { value: 'IN', label: 'is' }, + { value: 'NOT_IN', label: 'is not' }, + ], + type: 'STRING_LIST', + options: [ + { value: 'scheduled', label: 'Scheduled' }, + { value: 'pending', label: 'Pending' }, + { value: 'ready', label: 'Ready' }, + { value: 'running', label: 'Running' }, + { value: 'suspended', label: 'Suspended' }, + { value: 'retrying', label: 'Retrying' }, + { value: 'killed', label: 'Killed' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'succeeded', label: 'Succeeded' }, + { value: 'failed', label: 'Failed' }, + ], + }, + { + id: 'target_service_name', + label: 'Service', + operations: [ + { value: 'IN', label: 'is' }, + { value: 'NOT_IN', label: 'is not' }, + ], + type: 'STRING_LIST', + loadOptions: async () => + serviceNamesPromise.then((results) => { + return ( + results.map((name) => ({ + label: name, + value: name, + })) ?? [] + ); + }), + }, + { + id: 'target_service_key', + label: 'Service key', + operations: [{ value: 'EQUALS', label: 'is' }], + type: 'STRING', + }, + { + id: 'target_handler_name', + label: 'Handler', + operations: [ + { value: 'IN', label: 'is' }, + { value: 'NOT_IN', label: 'is not' }, + ], + type: 'STRING_LIST', + loadOptions: async () => { + return listServicesPromise.then( + (services) => + Array.from( + new Set( + services + .filter(Boolean) + .map((service) => + service!.handlers.map((handler) => handler.name) + ) + .flat() + ).values() + ) + .sort() + .map((name) => ({ + label: name, + value: name, + })) ?? [] + ); + }, + }, + { + id: 'target_service_ty', + label: 'Service type', + operations: [ + { value: 'IN', label: 'is' }, + { value: 'NOT_IN', label: 'is not' }, + ], + type: 'STRING_LIST', + options: [ + { value: 'service', label: 'Service' }, + { value: 'virtual_object', label: 'Virtual Object' }, + { value: 'workflow', label: 'Workflow' }, + ], + }, + { + id: 'last_attempt_deployment_id', + label: 'Deployment', + operations: [ + { value: 'IN', label: 'is' }, + { value: 'NOT_IN', label: 'is not' }, + ], + type: 'STRING_LIST', + loadOptions: async () => + listDeploymentPromise.then((results) => + Array.from(results?.deployments.values() ?? []).map( + (deployment) => ({ + label: String(getEndpoint(deployment)), + value: deployment.id, + description: deployment.id, + }) + ) + ), + }, + { + id: 'invoked_by', + label: 'Invoked by', + operations: [{ value: 'EQUALS', label: 'is' }], + type: 'STRING', + options: [ + { value: 'service', label: 'Service' }, + { value: 'ingress', label: 'Ingress' }, + ], + }, + { + id: 'invoked_by_service_name', + label: 'Invoked by service', + operations: [ + { value: 'IN', label: 'is' }, + { value: 'NOT_IN', label: 'is not' }, + ], + type: 'STRING_LIST', + loadOptions: async () => + serviceNamesPromise.then((results) => + results.map((name) => ({ + label: name, + value: name, + })) + ), + }, + { + id: 'invoked_by_id', + label: 'Invoked by id', + operations: [{ value: 'EQUALS', label: 'is' }], + type: 'STRING', + }, + { + id: 'idempotency_key', + label: 'Idempotency key', + operations: [{ value: 'EQUALS', label: 'is' }], + type: 'STRING', + }, + + { + id: 'retry_count', + label: 'Attempt count', + operations: [{ value: 'GREATER_THAN', label: '>' }], + type: 'NUMBER', + }, + { + id: 'created_at', + label: 'Created', + operations: [ + { value: 'BEFORE', label: 'before' }, + { value: 'AFTER', label: 'after' }, + ], + type: 'DATE', + }, + { + id: 'scheduled_at', + label: 'Scheduled', + operations: [ + { value: 'BEFORE', label: 'before' }, + { value: 'AFTER', label: 'after' }, + ], + type: 'DATE', + }, + { + id: 'modified_at', + label: 'Modified', + operations: [ + { value: 'BEFORE', label: 'before' }, + { value: 'AFTER', label: 'after' }, + ], + type: 'DATE', + }, + ] satisfies QueryClauseSchema[]; + }, [listDeploymentPromise, listServicesPromise]); + const { selectedColumns, setSelectedColumns, sortedColumnsList } = useColumns(); - const { refetch, queryKey, dataUpdatedAt, error } = useListInvocations([], { - refetchOnMount: false, - refetchOnReconnect: false, - initialData: { rows: [], total_count: 0 }, - staleTime: Infinity, - }); const queryCLient = useQueryClient(); + const [queryFilters, setQueryFilters] = useState(() => + schema + .filter((schemaClause) => searchParams.get(`filter_${schemaClause.id}`)) + .map((schemaClause) => { + return QueryClause.fromJSON( + schemaClause, + searchParams.get(`filter_${schemaClause.id}`)! + ); + }) + .filter((clause) => clause.isValid) + .map((clause) => { + return { + field: clause.id, + operation: clause.value.operation!, + type: clause.type, + value: clause.value.value, + } as FilterItem; + }) + ); + const { dataUpdatedAt, error, data, isFetching, isPending, queryKey } = + useListInvocations(queryFilters, { + refetchOnMount: true, + refetchOnReconnect: false, + staleTime: 0, + }); + const [sortDescriptor, setSortDescriptor] = useState(); const collator = useCollator(); - const invocations = useAsyncList({ - async load() { - await queryCLient.invalidateQueries({ queryKey }); - const results = await refetch(); - return { items: results.data?.rows ?? [] }; - }, - async sort({ items, sortDescriptor }) { - // TODO - return { - items: items.sort((a, b) => { - let cmp = 0; - if (sortDescriptor.column === 'deployment') { - cmp = collator.compare( - ( - a.last_attempt_deployment_id ?? a.pinned_deployment_id - )?.toString() ?? '', - ( - b.last_attempt_deployment_id ?? b.pinned_deployment_id - )?.toString() ?? '' - ); - } else { - cmp = collator.compare( - a[ - sortDescriptor.column as Exclude - ]?.toString() ?? '', - b[ - sortDescriptor.column as Exclude - ]?.toString() ?? '' - ); - } - // Flip the direction if descending order is specified. - if (sortDescriptor.direction === 'descending') { - cmp *= -1; - } + const sortedItems = useMemo(() => { + return ( + data?.rows.sort((a, b) => { + let cmp = 0; + if (sortDescriptor?.column === 'deployment') { + cmp = collator.compare( + ( + a.last_attempt_deployment_id ?? a.pinned_deployment_id + )?.toString() ?? '', + ( + b.last_attempt_deployment_id ?? b.pinned_deployment_id + )?.toString() ?? '' + ); + } else { + cmp = collator.compare( + a[ + sortDescriptor?.column as Exclude + ]?.toString() ?? '', + b[ + sortDescriptor?.column as Exclude + ]?.toString() ?? '' + ); + } - return cmp; - }), - }; - }, - }); + // Flip the direction if descending order is specified. + if (sortDescriptor?.direction === 'descending') { + cmp *= -1; + } + + return cmp; + }) ?? [] + ); + }, [collator, data?.rows, sortDescriptor?.column, sortDescriptor?.direction]); + + const query = useQueryBuilder( + schema + .filter((schemaClause) => searchParams.get(`filter_${schemaClause.id}`)) + .map((schemaClause) => { + return QueryClause.fromJSON( + schemaClause, + searchParams.get(`filter_${schemaClause.id}`)! + ); + }) + ); return (
-
- - - - - - - - - - - - - - - - - {Object.entries(COLUMN_NAMES).map(([key, name]) => ( - - {name} - - ))} - - - - -
{sortedColumnsList @@ -165,14 +363,40 @@ function Component() { ))} + + + + + + + + {Object.entries(COLUMN_NAMES).map(([key, name]) => ( + + {name} + + ))} + + + + Actions @@ -200,21 +424,74 @@ function Component() { )}
- +
+ +
{ + event.preventDefault(); + setSearchParams((old) => { + const newSearchParams = new URLSearchParams(old); + Array.from(newSearchParams.keys()) + .filter((key) => key.startsWith('filter_')) + .forEach((key) => newSearchParams.delete(key)); + query.items + .filter((clause) => clause.isValid) + .forEach((item) => { + newSearchParams.set(`filter_${item.id}`, String(item)); + }); + return newSearchParams; + }); + setQueryFilters( + query.items + .filter((clause) => clause.isValid) + .map( + (clause) => + ({ + field: clause.id, + operation: clause.value.operation!, + type: clause.type, + value: clause.value.value, + } as FilterItem) + ) + ); + await queryCLient.invalidateQueries({ queryKey }); + }} + > + + + {ClauseChip} + + + + Query + +
+
); } -function Footnote() { +function Footnote({ + data, + isFetching, +}: { + isFetching: boolean; + data?: ReturnType['data']; +}) { const [now, setNow] = useState(() => Date.now()); const durationSinceLastSnapshot = useDurationSinceLastSnapshot(); - const { data, isFetching } = useListInvocations([], { - refetchOnMount: false, - refetchOnReconnect: false, - initialData: { rows: [], total_count: 0 }, - staleTime: Infinity, - }); useEffect(() => { let interval: ReturnType | null = null; @@ -255,20 +532,21 @@ function Footnote() { ); } -function RefreshContentTooltip() { - const [now] = useState(() => Date.now()); - - const durationSinceLastSnapshot = useDurationSinceLastSnapshot(); - const { isPast, ...parts } = durationSinceLastSnapshot(now); - const duration = formatDurations(parts); - return ( -
-
Refresh
-
- Last updated {duration} ago -
-
+export const clientLoader = ({ request }: ClientLoaderFunctionArgs) => { + const url = new URL(request.url); + const hasFilters = Array.from(url.searchParams.keys()).some((key) => + key.startsWith('filter_') ); -} + if (!hasFilters) { + url.searchParams.append( + 'filter_status', + JSON.stringify({ + operation: 'NOT_IN', + value: ['succeeded', 'cancelled', 'killed'], + }) + ); + return redirect(url.search); + } +}; -export const invocations = { Component }; +export const invocations = { Component, clientLoader }; diff --git a/libs/features/overview-route/src/lib/DeleteDeployment.tsx b/libs/features/overview-route/src/lib/DeleteDeployment.tsx index 16b30113..3e743e35 100644 --- a/libs/features/overview-route/src/lib/DeleteDeployment.tsx +++ b/libs/features/overview-route/src/lib/DeleteDeployment.tsx @@ -16,8 +16,8 @@ import { FormEvent, useId, useState } from 'react'; import { useDeleteDeployment, useListDeployments, + getEndpoint, } from '@restate/data-access/admin-api'; -import { getEndpoint } from './types'; import { showSuccessNotification } from '@restate/ui/notification'; export function DeleteDeployment() { diff --git a/libs/features/overview-route/src/lib/Deployment.tsx b/libs/features/overview-route/src/lib/Deployment.tsx index 81f2fbb4..2a36efce 100644 --- a/libs/features/overview-route/src/lib/Deployment.tsx +++ b/libs/features/overview-route/src/lib/Deployment.tsx @@ -1,11 +1,12 @@ import { Icon, IconName } from '@restate/ui/icons'; import { tv } from 'tailwind-variants'; -import { getEndpoint, isHttpDeployment } from './types'; import { TruncateWithTooltip } from '@restate/ui/tooltip'; import { DeploymentId, Revision as ServiceRevision, useListDeployments, + getEndpoint, + isHttpDeployment, } from '@restate/data-access/admin-api'; import { Revision } from './Revision'; import { DEPLOYMENT_QUERY_PARAM } from './constants'; @@ -17,7 +18,7 @@ const styles = tv({ base: 'flex flex-row items-center gap-2 relative border -m-1 p-1 transition-all ease-in-out text-code', variants: { isSelected: { - true: 'bg-white/70 shadow-sm rounded-lg border -mx-5 px-[1.25rem] z-10 font-medium', + true: 'bg-white shadow-sm shadow-zinc-800/[0.03] rounded-lg border -mx-[0.25rem] px-[0.25rem] z-10 font-medium', false: 'border-transparent', }, }, diff --git a/libs/features/overview-route/src/lib/Details/Deployment.tsx b/libs/features/overview-route/src/lib/Details/Deployment.tsx index 6069eb34..07432005 100644 --- a/libs/features/overview-route/src/lib/Details/Deployment.tsx +++ b/libs/features/overview-route/src/lib/Details/Deployment.tsx @@ -13,8 +13,10 @@ import { Icon, IconName } from '@restate/ui/icons'; import { Deployment, useDeploymentDetails, + getEndpoint, + isHttpDeployment, + isLambdaDeployment, } from '@restate/data-access/admin-api'; -import { getEndpoint, isHttpDeployment, isLambdaDeployment } from '../types'; import { InlineTooltip, TruncateWithTooltip } from '@restate/ui/tooltip'; import { MiniService } from '../MiniService'; import { diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx index dc516d3f..6e6da968 100644 --- a/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx @@ -12,11 +12,11 @@ import { import { ListData, useListData } from 'react-stately'; import * as adminApi from '@restate/data-access/admin-api/spec'; import { + getEndpoint, useListDeployments, useRegisterDeployment, } from '@restate/data-access/admin-api'; import { useDialog } from '@restate/ui/dialog'; -import { getEndpoint } from '../types'; import { showSuccessNotification } from '@restate/ui/notification'; import { RestateError } from '@restate/util/errors'; diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx index 59400e31..797cc1d1 100644 --- a/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx @@ -7,7 +7,7 @@ import { QueryDialog, } from '@restate/ui/dialog'; import { Icon, IconName } from '@restate/ui/icons'; -import { PropsWithChildren } from 'react'; +import { ComponentProps, PropsWithChildren } from 'react'; import { ErrorBanner } from '@restate/ui/error'; import { RegistrationForm } from './Form'; import { REGISTER_DEPLOYMENT_QUERY } from './constant'; @@ -16,6 +16,7 @@ import { DeploymentRegistrationState, useRegisterDeploymentContext, } from './Context'; +import { tv } from 'tailwind-variants'; function RegisterDeploymentFooter() { const { @@ -94,18 +95,24 @@ function RegisterDeploymentFooter() { ); } +const triggerStyles = tv({ base: 'flex gap-2 items-center px-3' }); export function TriggerRegisterDeploymentDialog({ children = 'Register deployment', -}: PropsWithChildren>) { + variant = 'secondary-button', + className, +}: PropsWithChildren<{ + variant?: ComponentProps['variant']; + className?: string; +}>) { return ( - + {children} diff --git a/libs/features/overview-route/src/lib/Service.tsx b/libs/features/overview-route/src/lib/Service.tsx index 5839c455..67373db3 100644 --- a/libs/features/overview-route/src/lib/Service.tsx +++ b/libs/features/overview-route/src/lib/Service.tsx @@ -1,4 +1,10 @@ -import { useListDeployments } from '@restate/data-access/admin-api'; +import { + Deployment as DeploymentType, + getEndpoint, + Handler as HandlerType, + useListDeployments, + useServiceDetails, +} from '@restate/data-access/admin-api'; import { Icon, IconName } from '@restate/ui/icons'; import { tv } from 'tailwind-variants'; import { Deployment } from './Deployment'; @@ -7,27 +13,79 @@ import { Link } from '@restate/ui/link'; import { SERVICE_QUERY_PARAM } from './constants'; import { useRef } from 'react'; import { useActiveSidebarParam } from '@restate/ui/layout'; +import { Handler } from './Handler'; const styles = tv({ - base: 'w-full rounded-2xl p2-0.5 pt2-1 border shadow-zinc-800/[0.03] transform transition', + base: 'w-full rounded-2xl border bg-gray-200/50 shadow-[inset_0_1px_0px_0px_rgba(0,0,0,0.03)] transform transition', + variants: { + isMatching: { + true: '', + false: 'opacity-70', + }, + isSelected: { + true: '', + false: '', + }, + }, + compoundVariants: [ + { isMatching: false, isSelected: true, className: 'opacity-100' }, + ], + defaultVariants: { + isMatching: true, + }, +}); + +const serviceLinkStyles = tv({ + base: "outline-offset-0 rounded-full before:absolute before:inset-0 before:content-[''] hover:before:bg-black/[0.03] pressed:before:bg-black/5", + variants: { + isMatching: { + true: 'before:rounded-t-[0.9rem]', + false: 'before:rounded-[0.9rem]', + }, + }, + defaultVariants: { + isMatching: true, + }, +}); + +const serviceStyles = tv({ + base: 'w-full rounded-2xl border shadow-zinc-800/[0.03] transform transition', variants: { isSelected: { - true: 'bg-white shadow-md scale-110', - false: 'bg-gradient-to-b to-gray-50/50 from-gray-50 shadow-sm scale-100', + true: 'bg-white shadow-md scale-105', + false: + 'border-white/50 bg-gradient-to-b to-gray-50/80 from-gray-50 shadow2-[inset_0_2px_0_0_rgba(255,255,255, 0.8)] shadow-sm scale-100', }, }, + defaultVariants: { isSelected: false }, }); -const MAX_NUMBER_OF_DEPLOYMENTS = 5; +const MAX_NUMBER_OF_DEPLOYMENTS = 2; +const MAX_NUMBER_OF_HANDLERS = 2; + +function filterHandler(handler: HandlerType, filterText?: string) { + return ( + !filterText || handler.name.toLowerCase().includes(filterText.toLowerCase()) + ); +} +function filterDeployment(deployment?: DeploymentType, filterText?: string) { + return Boolean( + !filterText || + getEndpoint(deployment)?.toLowerCase().includes(filterText.toLowerCase()) + ); +} export function Service({ className, serviceName, + filterText, }: { serviceName: string; className?: string; + filterText?: string; }) { - const { data: { services } = {} } = useListDeployments(); + const { data: { services, deployments } = {} } = useListDeployments(); + const { data: serviceDetails } = useServiceDetails(serviceName); const service = services?.get(serviceName); const serviceDeployments = service?.deployments; const revisions = service?.sortedRevisions ?? []; @@ -46,57 +104,125 @@ export function Service({ revision: number; }[]; + const isMatchingServiceName = + !filterText || + serviceName.toLowerCase().includes(filterText.toLowerCase()) || + serviceDetails?.ty.toLowerCase().includes(filterText.toLowerCase()); + const isMatchingAnyHandlerName = serviceDetails?.handlers.some((handler) => + filterHandler(handler, filterText) + ); + const isMatchingAnyDeployment = Object.values(service?.deployments ?? {}) + .flat() + .some((deploymentId) => + filterDeployment(deployments?.get(deploymentId), filterText) + ); + + const isMatching = + isMatchingAnyDeployment || + isMatchingAnyHandlerName || + isMatchingServiceName; + + const filteredHandlers = + serviceDetails?.handlers.filter( + (handler) => + isMatchingServiceName || + isMatchingAnyDeployment || + filterHandler(handler, filterText) + ) ?? []; + const filteredDeployments = + deploymentRevisionPairs.filter( + ({ id: deploymentId }) => + isMatchingServiceName || + isMatchingAnyHandlerName || + filterDeployment(deployments?.get(deploymentId), filterText) + ) ?? []; + return ( -
-
-
-
- +
+
+
+
+
+ +
+
+
+ + {serviceName} + + + +
-
- - {serviceName} - - - - -
+ {isMatching && + serviceDetails && + serviceDetails?.handlers.length > 0 && ( +
+
+ {filteredHandlers + .slice(0, MAX_NUMBER_OF_HANDLERS) + .map((handler) => ( + + ))} + {filteredHandlers.length > MAX_NUMBER_OF_HANDLERS && ( + + +{filteredHandlers.length - MAX_NUMBER_OF_HANDLERS} handler + {filteredHandlers.length - MAX_NUMBER_OF_HANDLERS > 1 + ? 's' + : ''} + … + + )} +
+
+ )}
- {revisions.length > 0 && ( -
+ {isMatching && revisions.length > 0 && ( +
Deployments
- {deploymentRevisionPairs + {filteredDeployments .slice(0, MAX_NUMBER_OF_DEPLOYMENTS) .map(({ id, revision }) => ( ))} - {deploymentRevisionPairs.length > MAX_NUMBER_OF_DEPLOYMENTS && ( + {filteredDeployments.length > MAX_NUMBER_OF_DEPLOYMENTS && ( - +{deploymentRevisionPairs.length - MAX_NUMBER_OF_DEPLOYMENTS}{' '} + +{filteredDeployments.length - MAX_NUMBER_OF_DEPLOYMENTS}{' '} deployment - {deploymentRevisionPairs.length - MAX_NUMBER_OF_DEPLOYMENTS > 1 + {filteredDeployments.length - MAX_NUMBER_OF_DEPLOYMENTS > 1 ? 's' : ''} … diff --git a/libs/features/overview-route/src/lib/overview.route.tsx b/libs/features/overview-route/src/lib/overview.route.tsx index 4ea471cc..fdbad210 100644 --- a/libs/features/overview-route/src/lib/overview.route.tsx +++ b/libs/features/overview-route/src/lib/overview.route.tsx @@ -8,16 +8,73 @@ import { } from '@restate/features/explainers'; import { Service } from './Service'; import Masonry, { ResponsiveMasonry } from 'react-responsive-masonry'; -import { useId, useLayoutEffect, useState } from 'react'; +import { + useDeferredValue, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { LayoutOutlet, LayoutZone } from '@restate/ui/layout'; +import { FormFieldInput } from '@restate/ui/form-field'; + +function MultipleDeploymentsPlaceholder({ + filterText, + onFilter, +}: { + filterText: string; + onFilter: (filterText: string) => void; +}) { + const inputRef = useRef(null); + useEffect(() => { + const input = inputRef.current; + const keyHandler = (event: KeyboardEvent) => { + if ( + event.key !== '/' || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.repeat + ) { + return; + } + if ( + event.target instanceof HTMLElement && + /^(?:input|textarea|select|button)$/i.test(event.target?.tagName) + ) + return; + event.preventDefault(); + input?.focus(); + }; + document.addEventListener('keydown', keyHandler); + return () => { + document.removeEventListener('keydown', keyHandler); + }; + }, []); -function MultipleDeploymentsPlaceholder() { return ( -
- - Deployment - +
+
+ + / + + + + Deployment + +
); @@ -107,11 +164,14 @@ function Component() { isPending, isSuccess, } = useListDeployments(); + const size = services ? services.size : 0; - const isEmpty = isSuccess && (!deployments || deployments.size === 0); const [isScrolling, setIsScrolling] = useState(false); const masonryId = useId(); + const [filter, setFilter] = useState(''); + const filterQuery = useDeferredValue(filter); + useLayoutEffect(() => { let isCanceled = false; const resizeObserver = new ResizeObserver(() => { @@ -135,7 +195,8 @@ function Component() { isCanceled = true; resizeObserver.unobserve(document.body); }; - }, [masonryId, sortedServiceNames]); + }, [masonryId, sortedServiceNames, filterQuery]); + const isEmpty = isSuccess && (!deployments || deployments.size === 0); // TODO: Handle isLoading & isError @@ -150,11 +211,12 @@ function Component() { style={{ gap: 'calc(8rem + 150px)' }} className={layoutStyles({ isScrolling, className: masonryId })} > - {sortedServiceNames?.map((serviceName, i) => ( + {sortedServiceNames?.map((serviceName) => ( ))} {size === 1 && } @@ -163,7 +225,12 @@ function Component() { {isEmpty && } - {size > 1 && } + {size > 1 && ( + + )} ); } diff --git a/libs/features/overview-route/src/lib/types.ts b/libs/features/overview-route/src/lib/types.ts deleted file mode 100644 index 4609b65d..00000000 --- a/libs/features/overview-route/src/lib/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Deployment } from '@restate/data-access/admin-api'; - -export type HTTPDeployment = Exclude; -export type LambdaDeployment = Exclude; -export type DeploymentType = 'uri' | 'arn'; -export function isHttpDeployment( - deployment: Deployment -): deployment is HTTPDeployment { - return 'uri' in deployment; -} -export function isLambdaDeployment( - deployment: Deployment -): deployment is LambdaDeployment { - return 'arn' in deployment; -} -export function getEndpoint(deployment?: Deployment) { - if (!deployment) { - return undefined; - } - 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 26afcd97..bcc7188d 100644 --- a/libs/ui/button/src/lib/Button.tsx +++ b/libs/ui/button/src/lib/Button.tsx @@ -21,7 +21,6 @@ export interface ButtonProps { className?: string; form?: string; slot?: string; - onHover?: VoidFunction; } const styles = tv({ @@ -30,7 +29,7 @@ const styles = tv({ variants: { variant: { primary: - 'bg-gradient-to-b from-blue-600/90 to-blue-600 hover:from-blue-700 hover:to-blue-700 pressed:from-blue-800 pressed:to-blue-800 text-white shadow-[inset_0_1px_0_0_rgba(255,255,255,0.2)] drop-shadow-sm hover:shadow-none pressed:shadow-none', + 'bg-gradient-to-b from-blue-600/90 to-blue-600 disabled:bg-gray-400 disabled:shadow-none disabled:drop-shadow-none disabled:text-gray-200 hover:from-blue-700 hover:to-blue-700 pressed:from-blue-800 pressed:to-blue-800 text-white shadow-[inset_0_1px_0_0_rgba(255,255,255,0.2)] drop-shadow-sm hover:shadow-none pressed:shadow-none', secondary: 'bg-white hover:bg-gray-100 pressed:bg-gray-200 text-gray-800', destructive: 'bg-gradient-to-b from-red-700/95 to-red-700 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.15)] drop-shadow-sm hover:from-red-800 hover:to-red-800 pressed:from-red-900 pressed:to-red-900 text-white hover:shadow-none pressed:shadow-none', @@ -48,11 +47,10 @@ const styles = tv({ export const Button = forwardRef< HTMLButtonElement, PropsWithChildren ->(({ variant, onClick, disabled, onHover, ...props }, ref) => { +>(({ variant, onClick, disabled, ...props }, ref) => { return ( void; + isOpen?: boolean; +} -export function Dropdown({ children }: PropsWithChildren) { - return {children}; +export function Dropdown({ + children, + defaultOpen, + onOpenChange, + isOpen, +}: PropsWithChildren) { + return ( + + {children} + + ); } diff --git a/libs/ui/dropdown/src/lib/DropdownPopover.tsx b/libs/ui/dropdown/src/lib/DropdownPopover.tsx index 2c487836..59131ffc 100644 --- a/libs/ui/dropdown/src/lib/DropdownPopover.tsx +++ b/libs/ui/dropdown/src/lib/DropdownPopover.tsx @@ -1,9 +1,11 @@ import { PopoverContent } from '@restate/ui/popover'; import type { PropsWithChildren } from 'react'; +import { Placement } from 'react-aria'; import { tv } from 'tailwind-variants'; interface DropdownPopoverProps { className?: string; + placement?: Placement; } const styles = tv({ @@ -13,9 +15,10 @@ const styles = tv({ export function DropdownPopover({ children, className, + ...props }: PropsWithChildren) { return ( - + {children} ); diff --git a/libs/ui/dropdown/src/lib/DropdownSection.tsx b/libs/ui/dropdown/src/lib/DropdownSection.tsx index 85dea7f5..0ab1ab91 100644 --- a/libs/ui/dropdown/src/lib/DropdownSection.tsx +++ b/libs/ui/dropdown/src/lib/DropdownSection.tsx @@ -11,7 +11,7 @@ const styles = tv({ slots: { container: 'px-1', header: 'text-sm font-semibold text-gray-500 px-4 py-1 pt-2 truncate', - menu: 'bg-white rounded-xl border [&_.dropdown-item]:rounded-lg [&:last-child]:mb-1', + menu: 'bg-white [&:not(:has(*))]:hidden rounded-xl border [&_.dropdown-item]:rounded-lg [&:last-child]:mb-1', }, }); export function DropdownSection({ diff --git a/libs/ui/form-field/src/index.ts b/libs/ui/form-field/src/index.ts index f35ae948..9bcc7710 100644 --- a/libs/ui/form-field/src/index.ts +++ b/libs/ui/form-field/src/index.ts @@ -1,3 +1,4 @@ +export { FormFieldMultiCombobox } from './lib/MultiCombobox/MultiCombobox'; export * from './lib/FormFieldError'; export * from './lib/FormFieldGroup'; export * from './lib/FormFieldLabel'; @@ -7,3 +8,4 @@ export * from './lib/FormFieldCheckbox'; export * from './lib/FormFieldSelect'; export * from './lib/FormFieldNumberInput'; export * from './lib/FormFieldCombobox'; +export * from './lib/FormFieldDateTimeInput'; diff --git a/libs/ui/form-field/src/lib/FormFieldCombobox.tsx b/libs/ui/form-field/src/lib/FormFieldCombobox.tsx index 1bcbcfdd..9d594c0a 100644 --- a/libs/ui/form-field/src/lib/FormFieldCombobox.tsx +++ b/libs/ui/form-field/src/lib/FormFieldCombobox.tsx @@ -89,7 +89,7 @@ export function FormFieldCombobox({
{errorMessage} - + {children} diff --git a/libs/ui/form-field/src/lib/FormFieldDateTimeInput.tsx b/libs/ui/form-field/src/lib/FormFieldDateTimeInput.tsx new file mode 100644 index 00000000..667e559e --- /dev/null +++ b/libs/ui/form-field/src/lib/FormFieldDateTimeInput.tsx @@ -0,0 +1,115 @@ +import { + Label, + DateField as AriaDateField, + DateFieldProps as AriaDateFieldProps, + DateInput as AriaDateInput, + DateSegment, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; +import { FormFieldError } from './FormFieldError'; +import { + ComponentProps, + forwardRef, + PropsWithChildren, + ReactNode, +} from 'react'; +import { FormFieldLabel } from './FormFieldLabel'; +import { parseAbsoluteToLocal, ZonedDateTime } from '@internationalized/date'; +const inputStyles = tv({ + base: 'text-center relative invalid:border-red-600 invalid:bg-red-100/70 focus:outline focus:border-gray-200 disabled:text-gray-500/80 disabled:placeholder:text-gray-300 disabled:border-gray-100 disabled:shadow-none [&[readonly]]:text-gray-500/80 [&[readonly]]:bg-gray-100 read-only:shadow-none focus:shadow-none focus:outline-blue-600 focus:[box-shadow:inset_0_1px_0px_0px_rgba(0,0,0,0.03)] shadow-[inset_0_1px_0px_0px_rgba(0,0,0,0.03)] mt-0 bg-gray-100 rounded-lg border border-gray-200 py-1.5 placeholder:text-gray-500/70 px-10 w-full min-w-0 text-sm text-gray-900', +}); +const containerStyles = tv({ + base: 'group flex flex-col gap-1', +}); + +const segmentStyles = tv({ + base: 'inline p-0.5 type-literal:px-0 rounded outline outline-0 forced-color-adjust-none caret-transparent text-gray-800 dark:text-zinc-200 forced-colors:text-[ButtonText]', + variants: { + isPlaceholder: { + true: 'text-gray-600 dark:text-zinc-400 italic', + }, + isDisabled: { + true: 'text-gray-200 dark:text-zinc-600 forced-colors:text-[GrayText]', + }, + isFocused: { + true: 'bg-blue-600 text-white dark:text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]', + }, + }, +}); + +interface InputProps + extends Pick< + AriaDateFieldProps, + 'name' | 'autoFocus' | 'validate' + > { + className?: string; + required?: boolean; + disabled?: boolean; + readonly?: boolean; + placeholder?: string; + label?: ReactNode; + errorMessage?: ComponentProps['children']; + value?: string; + placeholderValue?: string; + defaultValue?: string; + onChange?: (value?: string) => void; +} + +export const FormFieldDateTimeInput = forwardRef< + HTMLInputElement, + PropsWithChildren +>( + ( + { + className, + required, + disabled, + placeholder, + errorMessage, + label, + readonly, + children, + value, + defaultValue, + onChange, + placeholderValue, + ...props + }, + ref + ) => { + return ( + + {...props} + isRequired={required} + isDisabled={disabled} + isReadOnly={readonly} + className={containerStyles({ className })} + {...(placeholderValue && { + placeholderValue: parseAbsoluteToLocal(placeholderValue), + })} + {...(value && { + value: parseAbsoluteToLocal(value), + })} + {...(defaultValue && { + defaultValue: parseAbsoluteToLocal(defaultValue), + })} + {...(onChange && { + onChange: (value) => { + onChange?.(value?.toAbsoluteString()); + }, + })} + > + {!label && } + {label && {label}} +
+ + {(segment) => ( + + )} + +
+ + + ); + } +); diff --git a/libs/ui/form-field/src/lib/FormFieldSelect.tsx b/libs/ui/form-field/src/lib/FormFieldSelect.tsx index 59af4a22..2496b075 100644 --- a/libs/ui/form-field/src/lib/FormFieldSelect.tsx +++ b/libs/ui/form-field/src/lib/FormFieldSelect.tsx @@ -28,6 +28,7 @@ interface SelectProps placeholder?: string; errorMessage?: ComponentProps['children']; label?: ReactNode; + defaultValue?: string; } export function FormFieldSelect({ className, @@ -39,6 +40,7 @@ export function FormFieldSelect({ children, label, autoFocus, + defaultValue, ...props }: PropsWithChildren) { return ( @@ -49,10 +51,11 @@ export function FormFieldSelect({ isDisabled={disabled} className={containerStyles({ className })} placeholder={placeholder} + defaultSelectedKey={defaultValue} > {!label && } {label && {label}} -
+
- - + + {children} @@ -78,9 +78,15 @@ export function FormFieldSelect({ ); } -export function Option({ children }: { children: string }) { +export function Option({ + children, + value = children, +}: { + children: string; + value?: string; +}) { return ( - + {children} ); diff --git a/libs/ui/form-field/src/lib/MultiCombobox/LabeledGroup.tsx b/libs/ui/form-field/src/lib/MultiCombobox/LabeledGroup.tsx new file mode 100644 index 00000000..b40370e0 --- /dev/null +++ b/libs/ui/form-field/src/lib/MultiCombobox/LabeledGroup.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react'; +import { LabelContext, GroupContext } from 'react-aria-components'; +import { tv } from 'tailwind-variants'; + +const styles = tv({ + base: '', +}); +// https://react-spectrum.adobe.com/react-aria/Group.html#advanced-customization +export function LabeledGroup({ + className, + children, + id, +}: { + className?: string; + children: ReactNode; + id: string; +}) { + return ( + + +
{children}
+
+
+ ); +} diff --git a/libs/ui/form-field/src/lib/MultiCombobox/MultiCombobox.tsx b/libs/ui/form-field/src/lib/MultiCombobox/MultiCombobox.tsx new file mode 100644 index 00000000..c49e86fb --- /dev/null +++ b/libs/ui/form-field/src/lib/MultiCombobox/MultiCombobox.tsx @@ -0,0 +1,376 @@ +import { + useCallback, + useState, + KeyboardEvent, + useId, + ComponentType, + ReactNode, + Fragment, + RefObject, + PropsWithChildren, +} from 'react'; +import { + ComboBox, + ComboBoxProps as RACComboBoxProps, + Key, + Input as AriaInput, + Label, + InputProps, +} from 'react-aria-components'; +import { useListData, ListData } from 'react-stately'; +import { FocusScope, useFilter, useFocusManager } from 'react-aria'; +import { ListBox, ListBoxItem, ListBoxSection } from '@restate/ui/listbox'; +import { LabeledGroup } from './LabeledGroup'; +import { tv } from 'tailwind-variants'; +import { Button } from '@restate/ui/button'; +import { Icon, IconName } from '@restate/ui/icons'; +import { PopoverOverlay } from '@restate/ui/popover'; +import { focusRing } from '@restate/ui/focus'; + +const tagStyles = tv({ + extend: focusRing, + base: 'bg-white/90 border text-zinc-800 shadow-sm flex max-w-fit cursor-default items-center gap-x-1 rounded-md pl-1.5 py-0.5 text-xs font-medium outline-0 transition ', +}); +function DefaultTag< + T extends { + id: Key; + textValue: string; + } +>({ item, onRemove }: { item: T; onRemove?: VoidFunction }) { + return ( +
+ {item.textValue} + +
+ ); +} + +function DefaultMenuTrigger() { + return null; +} + +export interface MultiSelectProps + extends Omit< + RACComboBoxProps, + | 'children' + | 'validate' + | 'allowsEmptyCollection' + | 'inputValue' + | 'selectedKey' + | 'inputValue' + | 'className' + | 'value' + | 'onSelectionChange' + | 'onInputChange' + > { + items: Array; + selectedList: ListData; + className?: string; + onItemAdd?: (key: Key) => void; + onItemRemove?: (key: Key) => void; + onItemUpdated?: (key: Key) => void; + renderEmptyState?: (inputValue: string) => React.ReactNode; + children?: (props: { + item: T; + onRemove?: VoidFunction; + onUpdate?: (newValue: T) => void; + }) => ReactNode; + MenuTrigger?: ComponentType; + label: string; + placeholder?: string; + ref?: RefObject; +} + +const multiSelectStyles = tv({ + base: 'relative flex flex-row flex-wrap items-center rounded-lg border has-[input[data-focused=true]]:border-blue-500 has-[input[data-invalid=true][data-focused=true]]:border-blue-500 has-[input[data-invalid=true]]:border-destructive has-[input[data-focused=true]]:ring-1 has-[input[data-focused=true]]:ring-blue-500', +}); + +const inputStyles = tv({ + base: 'min-h-[2.125rem] py-1.5 pl-0 pr-10 w-full min-w-0 text-sm text-current border-0 focus:border-0 focus:shadow-none focus:ring-0 focus:outline-0 bg-transparent', +}); +export function FormFieldMultiCombobox< + T extends { + id: Key; + textValue: string; + } +>({ + label, + items, + selectedList, + onItemRemove, + onItemAdd, + onItemUpdated, + className, + name, + renderEmptyState, + children = DefaultTag, + MenuTrigger = DefaultMenuTrigger, + placeholder, + ref, + ...props +}: MultiSelectProps) { + const { contains } = useFilter({ sensitivity: 'base' }); + + const selectedKeys = selectedList.items.map((i) => i.id); + + const filter = useCallback( + (item: T, filterText: string) => { + return ( + !selectedKeys.includes(item.id) && contains(item.textValue, filterText) + ); + }, + [contains, selectedKeys] + ); + + const availableList = useListData({ + initialItems: items, + filter, + }); + + const [fieldState, setFieldState] = useState<{ + selectedKey: Key | null; + inputValue: string; + }>({ + selectedKey: null, + inputValue: '', + }); + + const onRemove = useCallback( + (key: Key) => { + selectedList.remove(key); + setFieldState({ + inputValue: '', + selectedKey: null, + }); + onItemRemove?.(key); + }, + [selectedList, onItemRemove] + ); + + const onUpdate = useCallback( + (newValue: T) => { + selectedList.update(newValue.id, newValue); + setFieldState({ + inputValue: '', + selectedKey: null, + }); + onItemUpdated?.(newValue.id); + }, + [onItemUpdated, selectedList] + ); + + const onSelectionChange = (id: Key | null) => { + if (!id) { + return; + } + + const item = availableList.getItem(id); + + if (!item) { + return; + } + + if (!selectedKeys.includes(id)) { + selectedList.append(item); + setFieldState({ + inputValue: '', + selectedKey: id, + }); + onItemAdd?.(id); + } + + availableList.setFilterText(''); + }; + + const onInputChange = (value: string) => { + setFieldState((prevState) => ({ + inputValue: value, + selectedKey: value === '' ? null : prevState.selectedKey, + })); + + availableList.setFilterText(value); + }; + + const deleteLast = useCallback(() => { + if (selectedList.items.length === 0) { + return; + } + + const lastKey = selectedList.items[selectedList.items.length - 1]; + + if (lastKey) { + selectedList.remove(lastKey.id); + onItemRemove?.(lastKey.id); + } + + setFieldState({ + inputValue: '', + selectedKey: null, + }); + }, [selectedList, onItemRemove]); + + const onKeyDownCapture = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Backspace' && fieldState.inputValue === '') { + deleteLast(); + } + }, + [deleteLast, fieldState.inputValue] + ); + + const tagGroupId = useId(); + const labelId = useId(); + + return ( + + + + +
+ {selectedList.items.map((item) => ( + + {children({ + item, + onRemove: onRemove.bind(null, item.id), + onUpdate, + })} + + ))} +
+ + +
+ + { + setFieldState({ + inputValue: '', + selectedKey: null, + }); + availableList.setFilterText(''); + }} + aria-describedby={tagGroupId} + onKeyDownCapture={onKeyDownCapture} + placeholder={placeholder} + type="search" + /> +
+ + {availableList.items.length > 0 && ( + + + + {availableList.items.map((item) => ( + + {item.textValue} + + ))} + + + + )} +
+
+ + {name && ( + <> + {selectedKeys.map((key) => ( + + ))} + + )} +
+ ); +} + +function TagFocusManager({ + children, + onRemove, +}: PropsWithChildren<{ onRemove?: VoidFunction }>) { + const focusManager = useFocusManager(); + const onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Backspace': + onRemove?.(); + break; + case 'ArrowRight': + focusManager?.focusNext({ wrap: true }); + break; + case 'ArrowLeft': + focusManager?.focusPrevious({ wrap: true }); + break; + } + }; + + return ( +
+ {children} +
+ ); +} + +function InputWithFocusManager({ + onKeyDownCapture, + ...props +}: Pick< + InputProps, + 'onBlur' | 'className' | 'onKeyDownCapture' | 'placeholder' | 'type' +> & { ref?: RefObject }) { + const focusManager = useFocusManager(); + + const onKeyDownCaptureInner = useCallback( + (e: KeyboardEvent) => { + onKeyDownCapture?.(e); + + switch (e.key) { + case 'ArrowRight': + if ( + e.currentTarget.selectionStart === e.currentTarget.value.length && + e.currentTarget.selectionEnd === e.currentTarget.value.length + ) { + focusManager?.focusNext({ wrap: true }); + } + break; + case 'ArrowLeft': + if ( + e.currentTarget.selectionStart === 0 && + e.currentTarget.selectionEnd === 0 + ) { + focusManager?.focusPrevious({ wrap: true }); + } + break; + } + }, + [focusManager, onKeyDownCapture] + ); + return ; +} diff --git a/libs/ui/layout/src/lib/Layout.tsx b/libs/ui/layout/src/lib/Layout.tsx index d58dc2af..369ff401 100644 --- a/libs/ui/layout/src/lib/Layout.tsx +++ b/libs/ui/layout/src/lib/Layout.tsx @@ -5,6 +5,7 @@ import { ZONE_IDS, LayoutZone } from './LayoutZone'; import { ComplementaryOutlet } from './ComplementaryOutlet'; import { defaultConfig } from 'tailwind-variants'; import { NotificationRegion } from '@restate/ui/notification'; +import { Toolbar } from './Toolbar'; // TODO: refactor to a separate pacakge defaultConfig.twMergeConfig = { @@ -30,12 +31,8 @@ export function LayoutProvider({ children }: PropsWithChildren) {
{children} -
-
-
+ +
); } diff --git a/libs/ui/layout/src/lib/Toolbar.tsx b/libs/ui/layout/src/lib/Toolbar.tsx new file mode 100644 index 00000000..712355d7 --- /dev/null +++ b/libs/ui/layout/src/lib/Toolbar.tsx @@ -0,0 +1,16 @@ +import { PropsWithChildren } from 'react'; + +interface ToolbarProps { + id?: string; +} + +export function Toolbar(props: PropsWithChildren) { + return ( +
+
+
+ ); +} diff --git a/libs/ui/link/src/lib/Link.tsx b/libs/ui/link/src/lib/Link.tsx index e118a2ac..a95b9d64 100644 --- a/libs/ui/link/src/lib/Link.tsx +++ b/libs/ui/link/src/lib/Link.tsx @@ -31,7 +31,7 @@ const styles = tv({ variants: { variant: { button: - 'no-underline bg-blue-600 hover:bg-blue-700 pressed:bg-blue-800 text-white shadow-sm px-5 py-2 text-sm text-center transition rounded-xl border border-black/10', + 'no-underline bg-gradient-to-b from-blue-600/90 to-blue-600 hover:from-blue-700 hover:to-blue-700 pressed:from-blue-800 pressed:to-blue-800 text-white shadow-[inset_0_1px_0_0_rgba(255,255,255,0.2)] drop-shadow-sm hover:shadow-none pressed:shadow-none px-5 py-2 text-sm text-center transition rounded-xl border border-black/10', 'secondary-button': 'bg-white hover:bg-gray-100 pressed:bg-gray-200 text-gray-800 no-underline shadow-sm px-5 py-2 text-sm text-center transition rounded-xl border', primary: diff --git a/libs/ui/listbox/src/lib/ListBoxSection.tsx b/libs/ui/listbox/src/lib/ListBoxSection.tsx index 31dc8d37..6cbd2005 100644 --- a/libs/ui/listbox/src/lib/ListBoxSection.tsx +++ b/libs/ui/listbox/src/lib/ListBoxSection.tsx @@ -1,5 +1,9 @@ -import type { PropsWithChildren, ReactNode } from 'react'; -import { Header, Section } from 'react-aria-components'; +import { use, type PropsWithChildren, type ReactNode } from 'react'; +import { + Header, + ListBoxContext, + ListBoxSection as Section, +} from 'react-aria-components'; import { tv } from 'tailwind-variants'; export interface ListBoxSectionProps extends PropsWithChildren { @@ -18,6 +22,7 @@ const styles = tv({ }, }); +//TODO: fix descriptions export function ListBoxSection({ children, title, diff --git a/libs/ui/popover/src/lib/Popover.tsx b/libs/ui/popover/src/lib/Popover.tsx index a837abfd..4078a843 100644 --- a/libs/ui/popover/src/lib/Popover.tsx +++ b/libs/ui/popover/src/lib/Popover.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { DialogTrigger } from 'react-aria-components'; -export function Popover(props: { children: ReactNode }) { +export function Popover(props: { children: ReactNode; defaultOpen?: boolean }) { return ; } diff --git a/libs/ui/popover/src/lib/PopoverContent.tsx b/libs/ui/popover/src/lib/PopoverContent.tsx index dd4ce8e4..e617a162 100644 --- a/libs/ui/popover/src/lib/PopoverContent.tsx +++ b/libs/ui/popover/src/lib/PopoverContent.tsx @@ -1,7 +1,8 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, RefObject } from 'react'; import { Dialog as AriaDialog } from 'react-aria-components'; import { PopoverOverlay } from './PopoverOverlay'; import { tv } from 'tailwind-variants'; +import { Placement } from 'react-aria'; const styles = tv({ base: 'rounded-[1rem]', @@ -10,9 +11,13 @@ export function PopoverContent({ children, className, ...props -}: PropsWithChildren<{ className?: string }>) { +}: PropsWithChildren<{ + className?: string; + triggerRef?: RefObject; + placement?: Placement; +}>) { return ( - + {children} diff --git a/libs/ui/popover/src/lib/PopoverOverlay.tsx b/libs/ui/popover/src/lib/PopoverOverlay.tsx index 78173008..68adb6ca 100644 --- a/libs/ui/popover/src/lib/PopoverOverlay.tsx +++ b/libs/ui/popover/src/lib/PopoverOverlay.tsx @@ -1,4 +1,5 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, RefObject } from 'react'; +import { Placement } from 'react-aria'; import { Popover as AriaPopover, composeRenderProps, @@ -21,7 +22,11 @@ export function PopoverOverlay({ children, className, ...props -}: PropsWithChildren<{ className?: string }>) { +}: PropsWithChildren<{ + className?: string; + triggerRef?: RefObject; + placement?: Placement; +}>) { return ( ) { +export function getTriggerElement( + context: ContextValue +) { if ( context && 'triggerRef' in context && @@ -49,7 +51,7 @@ export function PopoverHoverTrigger({ if (popoverId) { const popoverEl = document.getElementById(popoverId); popoverEl?.addEventListener( - 'mouseenter', + 'mouseover', () => { isPopoverHovered = true; timeout && clearTimeout(timeout); @@ -85,12 +87,12 @@ export function PopoverHoverTrigger({ }, 250); }; - elementTriggeringHover?.addEventListener('mouseenter', enterHandler); + elementTriggeringHover?.addEventListener('mouseover', enterHandler); elementTriggeringHover?.addEventListener('mouseleave', leaveHandler); return () => { timeout && clearTimeout(timeout); - elementTriggeringHover?.removeEventListener('mouseenter', enterHandler); + elementTriggeringHover?.removeEventListener('mouseover', enterHandler); elementTriggeringHover?.removeEventListener('mouseleave', leaveHandler); observer.disconnect(); }; diff --git a/libs/ui/query-builder/.babelrc b/libs/ui/query-builder/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/query-builder/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/query-builder/README.md b/libs/ui/query-builder/README.md new file mode 100644 index 00000000..04e17914 --- /dev/null +++ b/libs/ui/query-builder/README.md @@ -0,0 +1,7 @@ +# query-builder + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test query-builder` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/query-builder/eslint.config.cjs b/libs/ui/query-builder/eslint.config.cjs new file mode 100644 index 00000000..2016babe --- /dev/null +++ b/libs/ui/query-builder/eslint.config.cjs @@ -0,0 +1,12 @@ +const nx = require('@nx/eslint-plugin'); +const baseConfig = require('../../../eslint.config.js'); + +module.exports = [ + ...baseConfig, + ...nx.configs['flat/react'], + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/ui/query-builder/project.json b/libs/ui/query-builder/project.json new file mode 100644 index 00000000..0803af23 --- /dev/null +++ b/libs/ui/query-builder/project.json @@ -0,0 +1,9 @@ +{ + "name": "query-builder", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/query-builder/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project query-builder --web", + "targets": {} +} diff --git a/libs/ui/query-builder/src/index.ts b/libs/ui/query-builder/src/index.ts new file mode 100644 index 00000000..8f85fa64 --- /dev/null +++ b/libs/ui/query-builder/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/QueryBuilder'; +export * from './lib/Query'; diff --git a/libs/ui/query-builder/src/lib/Query.ts b/libs/ui/query-builder/src/lib/Query.ts new file mode 100644 index 00000000..be65eb69 --- /dev/null +++ b/libs/ui/query-builder/src/lib/Query.ts @@ -0,0 +1,155 @@ +import { formatDateTime } from '@restate/util/intl'; + +export type QueryClauseType = 'STRING' | 'STRING_LIST' | 'NUMBER' | 'DATE'; +export type QueryClauseOperationId = + | 'EQUALS' + | 'NOT_EQUALS' + | 'IN' + | 'NOT_IN' + | 'BEFORE' + | 'AFTER' + | 'LESS_THAN' + | 'GREATER_THAN'; + +interface Option { + value: T; + label: string; + description?: string; +} + +export type QueryClauseOperation = Option; +export type QueryClauseOption = Option; +export interface QueryClauseSchema { + id: string; + label: string; + operations: QueryClauseOperation[]; + type: T; + loadOptions?: () => Promise; + options?: QueryClauseOption[]; +} +type QueryClauseValue = T extends 'STRING' + ? string + : T extends 'NUMBER' + ? number + : T extends 'STRING_LIST' + ? string[] + : T extends 'DATE' + ? Date + : never; + +export class QueryClause { + get id() { + return this.schema.id; + } + + get name() { + return this.schema.id; + } + + get label() { + return this.schema.label; + } + + get textValue() { + return this.schema.label; + } + + get type() { + return this.schema.type; + } + + get operations() { + return this.schema.operations; + } + + get operationLabel() { + return this.schema.operations.find( + (op) => op.value === this.value.operation + )?.label; + } + + get valueLabel() { + const value = this.value.value; + if (typeof value === 'number' || typeof value === 'string') { + const valueOption = this.options?.find((opt) => opt.value === value); + return valueOption?.label ?? String(value); + } + if (value instanceof Date) { + return formatDateTime(value, 'system'); + } + if (Array.isArray(value)) { + return value + .map((v) => this.options?.find((opt) => opt.value === v)?.label ?? v) + .join(', '); + } + return ''; + } + + private _options?: QueryClauseOption[]; + get options() { + return this._options; + } + + get isValid() { + return !!this.value.value; + } + + constructor( + public readonly schema: QueryClauseSchema, + public readonly value: { + operation?: QueryClauseOperationId; + value?: QueryClauseValue; + } = { operation: schema.operations[0]?.value, value: undefined } + ) { + this._options = schema.options; + this.schema + .loadOptions?.() + ?.then((opts) => { + this._options = opts; + }) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => {}); + } + + toString() { + if (!this.value.value) { + return undefined; + } + return JSON.stringify(this.value); + } + + static fromJSON(schema: QueryClauseSchema, value: string) { + try { + const parsedValue = JSON.parse(value) as { + operation?: QueryClauseOperationId; + value?: number | string | string[]; + }; + return new QueryClause(schema, { + operation: parsedValue.operation, + value: getValue(schema.type, parsedValue.value), + }); + } catch (error) { + return new QueryClause(schema); + } + } +} + +function getValue(type: QueryClauseType, value?: number | string | string[]) { + if (type === 'STRING' && typeof value === 'string') { + return value; + } + if (type === 'NUMBER' && !isNaN(Number(value))) { + return Number(value); + } + if (type === 'DATE' && typeof value === 'string') { + return new Date(value); + } + if ( + type === 'STRING_LIST' && + Array.isArray(value) && + value.every((v) => typeof v === 'string') + ) { + return value; + } + return undefined; +} diff --git a/libs/ui/query-builder/src/lib/QueryBuilder.tsx b/libs/ui/query-builder/src/lib/QueryBuilder.tsx new file mode 100644 index 00000000..65d3b06d --- /dev/null +++ b/libs/ui/query-builder/src/lib/QueryBuilder.tsx @@ -0,0 +1,150 @@ +import { + ComponentType, + createContext, + PropsWithChildren, + ReactNode, + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { QueryClause, QueryClauseSchema, QueryClauseType } from './Query'; +import { FormFieldMultiCombobox } from '@restate/ui/form-field'; +import { Key, ListData, useListData } from 'react-stately'; + +interface QueryBuilderProps { + schema: QueryClauseSchema[]; + query: ListData>; +} + +const QueryBuilderContext = createContext<{ + query?: ListData>; + schema: QueryClauseSchema[]; + newId?: string; + setNewId?: (id?: string) => void; +}>({ + schema: [], +}); + +// TODO: update state if schema changes +export function useQueryBuilder( + initialClauses: QueryClause[] = [] +) { + const selectedClauses = useListData>({ + initialItems: initialClauses, + }); + + return selectedClauses; +} + +export function QueryBuilder({ + schema, + query, + children, +}: PropsWithChildren) { + const [newId, setNewId] = useState(); + + return ( + + {children} + + ); +} + +export function AddQueryTrigger({ + placeholder, + title, + children, + className, + MenuTrigger, +}: { + placeholder: string; + title: string; + children?: (props: { + item: QueryClause; + onRemove?: VoidFunction; + onUpdate?: (item: QueryClause) => void; + }) => ReactNode; + className?: string; + MenuTrigger?: ComponentType; +}) { + const { query, schema, setNewId } = use(QueryBuilderContext); + const items = useMemo(() => { + return schema.map((clauseSchema) => new QueryClause(clauseSchema)); + }, [schema]); + + const inputRef = useRef(null); + useEffect(() => { + const input = inputRef.current; + const keyHandler = (event: KeyboardEvent) => { + if ( + event.key !== '/' || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.repeat + ) { + return; + } + if ( + event.target instanceof HTMLElement && + (/^(?:input|textarea|select|button)$/i.test(event.target?.tagName) || + event.target.closest('[role=listbox]') || + event.target.closest('[role=dialog]')) + ) + return; + event.preventDefault(); + input?.focus(); + }; + document.addEventListener('keydown', keyHandler); + return () => { + document.removeEventListener('keydown', keyHandler); + }; + }, []); + + const onAdd = useCallback( + (key: Key) => { + setNewId?.(String(key)); + }, + [setNewId] + ); + const onRemove = useCallback( + (key: Key) => { + setNewId?.(undefined); + }, + [setNewId] + ); + + if (!query) { + return null; + } + return ( + > + selectedList={query} + label={title} + items={items} + children={children} + placeholder={placeholder} + className={className} + MenuTrigger={MenuTrigger} + ref={inputRef} + onItemAdd={onAdd} + onItemRemove={onRemove} + onItemUpdated={onRemove} + /> + ); +} + +export function useNewQueryId() { + const { newId } = use(QueryBuilderContext); + return newId; +} diff --git a/libs/ui/query-builder/tsconfig.json b/libs/ui/query-builder/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/ui/query-builder/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/query-builder/tsconfig.lib.json b/libs/ui/query-builder/tsconfig.lib.json new file mode 100644 index 00000000..49dd657b --- /dev/null +++ b/libs/ui/query-builder/tsconfig.lib.json @@ -0,0 +1,34 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/query-builder/tsconfig.spec.json b/libs/ui/query-builder/tsconfig.spec.json new file mode 100644 index 00000000..69d6af25 --- /dev/null +++ b/libs/ui/query-builder/tsconfig.spec.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/query-builder/vite.config.ts b/libs/ui/query-builder/vite.config.ts new file mode 100644 index 00000000..568b2a0d --- /dev/null +++ b/libs/ui/query-builder/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/query-builder', + plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/ui/query-builder', + provider: 'v8', + }, + }, +}); diff --git a/libs/util/react-query/src/lib/QueryProvider.tsx b/libs/util/react-query/src/lib/QueryProvider.tsx index 7c720d39..ed5cc0a2 100644 --- a/libs/util/react-query/src/lib/QueryProvider.tsx +++ b/libs/util/react-query/src/lib/QueryProvider.tsx @@ -42,6 +42,7 @@ export function QueryProvider({ staleTime: 5 * 60 * 1000, retry: false, refetchOnMount: false, + experimental_prefetchInRender: true, }, }, }) diff --git a/package.json b/package.json index 13da6705..d5c64964 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@buf/restatedev_service-protocol.bufbuild_es": "2.2.3-20240606160341-127256df3b4d.1", "@bufbuild/protobuf": "2.2.3", "@formatjs/intl-durationformat": "0.6.4", + "@internationalized/date": "^3.6.0", "@react-aria/interactions": "3.22.5", "@react-aria/toast": "3.0.0-beta.18", "@react-router/node": "7.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dacbd75..cd972ef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@formatjs/intl-durationformat': specifier: 0.6.4 version: 0.6.4 + '@internationalized/date': + specifier: ^3.6.0 + version: 3.6.0 '@react-aria/interactions': specifier: 3.22.5 version: 3.22.5(react@19.0.0) diff --git a/tsconfig.base.json b/tsconfig.base.json index 591dd974..3471de05 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -69,6 +69,7 @@ "@restate/ui/nav": ["libs/ui/nav/src/index.ts"], "@restate/ui/notification": ["libs/ui/notification/src/index.ts"], "@restate/ui/popover": ["libs/ui/popover/src/index.ts"], + "@restate/ui/query-builder": ["libs/ui/query-builder/src/index.ts"], "@restate/ui/radio-group": ["libs/ui/radio-group/src/index.ts"], "@restate/ui/section": ["libs/ui/section/src/index.ts"], "@restate/ui/table": ["libs/ui/table/src/index.ts"],