diff --git a/src/examples/auth-zero/src/frontend/types.generated.ts b/src/examples/auth-zero/src/frontend/types.generated.ts index cee0d4d3f7..47609a60d4 100644 --- a/src/examples/auth-zero/src/frontend/types.generated.ts +++ b/src/examples/auth-zero/src/frontend/types.generated.ts @@ -71,12 +71,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/auth-zero/src/types.generated.ts b/src/examples/auth-zero/src/types.generated.ts index cee0d4d3f7..47609a60d4 100644 --- a/src/examples/auth-zero/src/types.generated.ts +++ b/src/examples/auth-zero/src/types.generated.ts @@ -71,12 +71,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/aws-cognito/src/frontend/types.generated.ts b/src/examples/aws-cognito/src/frontend/types.generated.ts index c86e95446c..f2a4abbfef 100644 --- a/src/examples/aws-cognito/src/frontend/types.generated.ts +++ b/src/examples/aws-cognito/src/frontend/types.generated.ts @@ -69,12 +69,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/aws-cognito/src/types.generated.ts b/src/examples/aws-cognito/src/types.generated.ts index c86e95446c..f2a4abbfef 100644 --- a/src/examples/aws-cognito/src/types.generated.ts +++ b/src/examples/aws-cognito/src/types.generated.ts @@ -69,12 +69,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/federation/src/frontend/types.generated.ts b/src/examples/federation/src/frontend/types.generated.ts index 466ef9cd28..b6a7cf5761 100644 --- a/src/examples/federation/src/frontend/types.generated.ts +++ b/src/examples/federation/src/frontend/types.generated.ts @@ -72,12 +72,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/federation/src/types.generated.ts b/src/examples/federation/src/types.generated.ts index 466ef9cd28..b6a7cf5761 100644 --- a/src/examples/federation/src/types.generated.ts +++ b/src/examples/federation/src/types.generated.ts @@ -72,12 +72,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/microsoft-entra/src/frontend/types.generated.ts b/src/examples/microsoft-entra/src/frontend/types.generated.ts index cee0d4d3f7..47609a60d4 100644 --- a/src/examples/microsoft-entra/src/frontend/types.generated.ts +++ b/src/examples/microsoft-entra/src/frontend/types.generated.ts @@ -71,12 +71,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/microsoft-entra/src/types.generated.ts b/src/examples/microsoft-entra/src/types.generated.ts index cee0d4d3f7..47609a60d4 100644 --- a/src/examples/microsoft-entra/src/types.generated.ts +++ b/src/examples/microsoft-entra/src/types.generated.ts @@ -71,12 +71,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/okta/src/frontend/types.generated.ts b/src/examples/okta/src/frontend/types.generated.ts index cee0d4d3f7..47609a60d4 100644 --- a/src/examples/okta/src/frontend/types.generated.ts +++ b/src/examples/okta/src/frontend/types.generated.ts @@ -71,12 +71,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/okta/src/types.generated.ts b/src/examples/okta/src/types.generated.ts index cee0d4d3f7..47609a60d4 100644 --- a/src/examples/okta/src/types.generated.ts +++ b/src/examples/okta/src/types.generated.ts @@ -71,12 +71,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/rest-with-auth/src/frontend/types.generated.ts b/src/examples/rest-with-auth/src/frontend/types.generated.ts index 988b3fb5dc..348d6d6f0f 100644 --- a/src/examples/rest-with-auth/src/frontend/types.generated.ts +++ b/src/examples/rest-with-auth/src/frontend/types.generated.ts @@ -73,12 +73,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/rest-with-auth/src/types.generated.ts b/src/examples/rest-with-auth/src/types.generated.ts index 988b3fb5dc..348d6d6f0f 100644 --- a/src/examples/rest-with-auth/src/types.generated.ts +++ b/src/examples/rest-with-auth/src/types.generated.ts @@ -73,12 +73,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/rest-with-auth/types.generated.ts b/src/examples/rest-with-auth/types.generated.ts index 988b3fb5dc..348d6d6f0f 100644 --- a/src/examples/rest-with-auth/types.generated.ts +++ b/src/examples/rest-with-auth/types.generated.ts @@ -73,12 +73,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/rest/src/frontend/types.generated.ts b/src/examples/rest/src/frontend/types.generated.ts index 6a6af2d600..00288b79cf 100644 --- a/src/examples/rest/src/frontend/types.generated.ts +++ b/src/examples/rest/src/frontend/types.generated.ts @@ -69,12 +69,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/rest/src/types.generated.ts b/src/examples/rest/src/types.generated.ts index 6a6af2d600..00288b79cf 100644 --- a/src/examples/rest/src/types.generated.ts +++ b/src/examples/rest/src/types.generated.ts @@ -69,12 +69,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/sqlite/databases/trace.sqlite b/src/examples/sqlite/databases/trace.sqlite index 3db720097c..dbb641886e 100644 Binary files a/src/examples/sqlite/databases/trace.sqlite and b/src/examples/sqlite/databases/trace.sqlite differ diff --git a/src/examples/sqlite/src/backend/schema/employee.ts b/src/examples/sqlite/src/backend/schema/employee.ts index d17e5e2821..2c3147a79c 100644 --- a/src/examples/sqlite/src/backend/schema/employee.ts +++ b/src/examples/sqlite/src/backend/schema/employee.ts @@ -27,10 +27,20 @@ export class Employee { }) employee?: Employee; - @Field(() => ISODateStringScalar, { nullable: true }) + @Field(() => ISODateStringScalar, { + nullable: true, + adminUIOptions: { + format: { type: 'date', timezone: 'Australia/Sydney', format: 'DATE_MED' }, + }, + }) birthDate?: Date; - @Field(() => ISODateStringScalar, { nullable: true }) + @Field(() => ISODateStringScalar, { + nullable: true, + adminUIOptions: { + format: { type: 'date', timezone: 'Australia/Sydney', format: 'DATE_MED_WITH_WEEKDAY' }, + }, + }) hireDate?: Date; @Field(() => String, { nullable: true }) diff --git a/src/examples/sqlite/src/backend/schema/invoice-line.ts b/src/examples/sqlite/src/backend/schema/invoice-line.ts index 3d30b7c4ab..7cef545259 100644 --- a/src/examples/sqlite/src/backend/schema/invoice-line.ts +++ b/src/examples/sqlite/src/backend/schema/invoice-line.ts @@ -20,7 +20,11 @@ export class InvoiceLine { @RelationshipField(() => Track, { id: (entity) => entity.track?.trackId }) track!: Track; - @Field(() => String) + @Field(() => String, { + adminUIOptions: { + format: { type: 'currency', variant: 'AUD' }, + }, + }) unitPrice!: string; @Field(() => Number) diff --git a/src/examples/sqlite/src/frontend/types.generated.ts b/src/examples/sqlite/src/frontend/types.generated.ts index 3fee212bf8..f163bae5bd 100644 --- a/src/examples/sqlite/src/frontend/types.generated.ts +++ b/src/examples/sqlite/src/frontend/types.generated.ts @@ -75,12 +75,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/examples/sqlite/src/types.generated.ts b/src/examples/sqlite/src/types.generated.ts index 3fee212bf8..f163bae5bd 100644 --- a/src/examples/sqlite/src/types.generated.ts +++ b/src/examples/sqlite/src/types.generated.ts @@ -75,12 +75,21 @@ export type AdminUiFieldExtensionsMetadata = { key?: Maybe; }; +export type AdminUiFieldFormatMetadata = { + __typename?: 'AdminUiFieldFormatMetadata'; + format?: Maybe; + timezone?: Maybe; + type: Scalars['String']['output']; + variant?: Maybe; +}; + export type AdminUiFieldMetadata = { __typename?: 'AdminUiFieldMetadata'; attributes?: Maybe; detailPanelInputComponent?: Maybe; extensions?: Maybe; filter?: Maybe; + format?: Maybe; hideInDetailForm?: Maybe; hideInFilterBar?: Maybe; hideInTable?: Maybe; diff --git a/src/packages/admin-ui-components/src/entity-list/columns.tsx b/src/packages/admin-ui-components/src/entity-list/columns.tsx index 2ffb9be644..cc4e2af15b 100644 --- a/src/packages/admin-ui-components/src/entity-list/columns.tsx +++ b/src/packages/admin-ui-components/src/entity-list/columns.tsx @@ -6,14 +6,59 @@ import { DetailPanelInputComponentOption, Entity, EntityField, routeFor } from ' import { cells } from '../table/cells'; import { Checkbox } from '../checkbox'; import { getExtensions } from '../detail-panel/fields/rich-text-field/utils'; +import { DateTime } from 'luxon'; -const columnHelper = createColumnHelper(); const richTextExtensions = getExtensions({}); -const cellForType = (field: EntityField, value: any, entityByType: (type: string) => Entity) => { +// Constants +const CHECKBOX_COLUMN_WIDTH = 48; + +const formatValue = (field: EntityField, value: unknown): string | number | null => { + if (!field.format) { + return value as string | number | null; + } else if (field.format?.type === 'date') { + if (!value) return null; + + try { + let date = DateTime.fromISO(value as string); + if (!date.isValid) { + console.warn(`Invalid date value: ${value} for field: ${field.name}`); + return value as string; + } + + if (field.format?.timezone) { + date = date.setZone(field.format.timezone ?? 'UTC'); + } + if (field.format?.format && DateTime[field.format.format as keyof typeof DateTime]) { + return date.toLocaleString(DateTime[field.format.format as keyof typeof DateTime] as any); + } + return date.toLocaleString(DateTime.DATETIME_FULL); + } catch (error) { + console.error('Date formatting error:', error, { value, fieldName: field.name }); + return value as string; + } + } else if (field.format?.type === 'currency') { + return typeof value === 'string' + ? parseFloat(value).toLocaleString('en-AU', { + style: 'currency', + currency: field.format.variant, + }) + : (value as number).toLocaleString('en-AU', { + style: 'currency', + currency: field.format.variant, + }); + } + return value as string | number | null; +}; + +const cellForType = ( + field: EntityField, + value: unknown, + entityByType: (type: string) => Entity +): React.ReactNode => { // Is there a specific definition for the cell type? if (cells[field.type as keyof typeof cells]) { - return cells[field.type as keyof typeof cells](value); + return cells[field.type as keyof typeof cells](value as any); } // If not, is it a relationship? @@ -44,25 +89,25 @@ const cellForType = (field: EntityField, value: any, entityByType: (type: string // Is it an array? if (Array.isArray(value)) { - return value.join(', '); + return value.map((item) => formatValue(field, item)).join(', '); } if (field.detailPanelInputComponent?.name === DetailPanelInputComponentOption.RICH_TEXT) { if (!value) return null; try { - const json = generateJSON(value, richTextExtensions); + const json = generateJSON(value as string, richTextExtensions); return
{generateText(json, richTextExtensions)}
; - } catch (e) { - console.error(e); - return
{value}
; + } catch (error) { + console.error('Rich text rendering error:', error, { value, fieldName: field.name }); + return
{String(value)}
; } } // Ok, all we're left with is a simple value - return value; + return formatValue(field, value); }; -const isFieldSortable = (field: EntityField) => { +const isFieldSortable = (field: EntityField): boolean => { if (field.type === 'JSON') { return false; } @@ -82,14 +127,15 @@ const isFieldSortable = (field: EntityField) => { return true; }; -const addRowCheckboxColumn = () => { - return columnHelper.accessor('select', { +const addRowCheckboxColumn = >() => { + const columnHelper = createColumnHelper(); + return columnHelper.accessor('select' as any, { id: 'select', enableSorting: false, - size: 48, - minSize: 48, - maxSize: 48, - header: ({ table }) => ( + size: CHECKBOX_COLUMN_WIDTH, + minSize: CHECKBOX_COLUMN_WIDTH, + maxSize: CHECKBOX_COLUMN_WIDTH, + header: ({ table }: { table: any }) => ( { }} /> ), - cell: ({ row }) => ( + cell: ({ row }: { row: any }) => ( { }); }; -export const convertEntityToColumns = (entity: Entity, entityByType: (type: string) => Entity) => { +export const convertEntityToColumns = >( + entity: Entity, + entityByType: (type: string) => Entity +) => { + // Input validation + if (!entity?.fields) { + console.warn('Entity has no fields:', entity); + return []; + } + + if (typeof entityByType !== 'function') { + throw new Error('entityByType must be a function'); + } + + const columnHelper = createColumnHelper(); + const entityColumns = entity.fields .filter((field) => !field.hideInTable) .map((field) => - columnHelper.accessor(field.name, { + columnHelper.accessor(field.name as any, { id: field.name, header: () => field.name, - cell: (info) => cellForType(field, info.getValue(), entityByType), + cell: (info: CellContext) => cellForType(field, info.getValue(), entityByType), enableSorting: isFieldSortable(field), }) ); @@ -139,10 +200,10 @@ export const convertEntityToColumns = (entity: Entity, entityByType: (type: stri // Ok, now we can merge our custom fields in for (const customField of customFieldsToShow) { - const column = columnHelper.accessor(customField.name, { + const column = columnHelper.accessor(customField.name as any, { id: customField.name, header: () => customField.name, - cell: (info: CellContext) => + cell: (info: CellContext) => customField.component?.({ context: 'table', entity: info.row.original }), enableSorting: false, }); @@ -151,7 +212,7 @@ export const convertEntityToColumns = (entity: Entity, entityByType: (type: stri // Add the row selection column if the entity is not read-only if (!entity.attributes.isReadOnly) { - return [addRowCheckboxColumn(), ...entityColumns]; + return [addRowCheckboxColumn(), ...entityColumns]; } return entityColumns; diff --git a/src/packages/admin-ui-components/src/entity-list/component.tsx b/src/packages/admin-ui-components/src/entity-list/component.tsx index da9cfd947a..5031096d02 100644 --- a/src/packages/admin-ui-components/src/entity-list/component.tsx +++ b/src/packages/admin-ui-components/src/entity-list/component.tsx @@ -27,7 +27,11 @@ import { QueryResponse, queryForEntityPage } from './graphql'; import { ExportModal } from '../export-modal'; import styles from './styles.module.css'; -export const EntityList = ({ children }: { children: React.ReactNode }) => { +export const EntityList = >({ + children, +}: { + children: React.ReactNode; +}) => { const { entity: entityName, id } = useParams(); if (!entityName) throw new Error('There should always be an entity at this point.'); @@ -50,7 +54,7 @@ export const EntityList = ({ children }: { children: React supportsPseudoCursorPagination, } = entity; const columns = useMemo( - () => convertEntityToColumns(entity, entityByType), + () => convertEntityToColumns(entity, entityByType), [fields, entityByType] ); @@ -85,7 +89,7 @@ export const EntityList = ({ children }: { children: React return ; } - const handleRowClick = (row: Row) => { + const handleRowClick = >(row: Row) => { setLocation( routeFor({ entity, diff --git a/src/packages/admin-ui-components/src/utils/graphql.ts b/src/packages/admin-ui-components/src/utils/graphql.ts index f71e97e6b0..d513cd4fd7 100644 --- a/src/packages/admin-ui-components/src/utils/graphql.ts +++ b/src/packages/admin-ui-components/src/utils/graphql.ts @@ -28,6 +28,12 @@ export const SCHEMA_QUERY = gql` type options } + format { + type + timezone + format + variant + } attributes { isReadOnly isRequired diff --git a/src/packages/admin-ui-components/src/utils/use-schema.ts b/src/packages/admin-ui-components/src/utils/use-schema.ts index 3aa3b76080..ee08e2aa06 100644 --- a/src/packages/admin-ui-components/src/utils/use-schema.ts +++ b/src/packages/admin-ui-components/src/utils/use-schema.ts @@ -90,6 +90,41 @@ export interface DetailPanelInputComponent { options?: Record; } +export type DateTimeFormat = + | 'DATETIME_FULL' + | 'DATETIME_FULL_WITH_SECONDS' + | 'DATETIME_HUGE' + | 'DATETIME_HUGE_WITH_SECONDS' + | 'DATETIME_MED' + | 'DATETIME_MED_WITH_SECONDS' + | 'DATETIME_MED_WITH_WEEKDAY' + | 'DATETIME_SHORT' + | 'DATETIME_SHORT_WITH_SECONDS' + | 'DATE_FULL' + | 'DATE_HUGE' + | 'DATE_MED' + | 'DATE_MED_WITH_WEEKDAY' + | 'DATE_SHORT' + | 'TIME_24_SIMPLE' + | 'TIME_24_WITH_LONG_OFFSET' + | 'TIME_24_WITH_SHORT_OFFSET' + | 'TIME_24_WITH_SECONDS' + | 'TIME_WITH_LONG_OFFSET' + | 'TIME_WITH_SHORT_OFFSET' + | 'TIME_SIMPLE' + | 'TIME_WITH_SECONDS'; + +export type CellFormatOptions = + | { + type: 'date'; + timezone?: 'UTC' | 'local' | string; + format?: DateTimeFormat; + } + | { + type: 'currency'; + variant: 'AUD' | 'GBP' | 'USD' | 'JPY' | 'EUR' | 'CHF' | 'THB' | 'IDR' | string; + }; + export interface EntityField { name: string; type: EntityFieldType; @@ -101,6 +136,7 @@ export interface EntityField { }; attributes?: EntityFieldAttributes; initialValue?: string | number | boolean; + format?: CellFormatOptions; extensions?: { key: string; }; diff --git a/src/packages/auth/src/authentication/apollo/plugin.ts b/src/packages/auth/src/authentication/apollo/plugin.ts index ba46617942..749f9e6fb7 100644 --- a/src/packages/auth/src/authentication/apollo/plugin.ts +++ b/src/packages/auth/src/authentication/apollo/plugin.ts @@ -78,6 +78,7 @@ export const applyDefaultMetadataACL = () => { 'AdminUiFieldMetadata', 'AdminUiFilterMetadata', 'DetailPanelInputComponent', + 'AdminUiFieldFormatMetadata', ]; for (const entity of metadataEntities) { diff --git a/src/packages/core/package.json b/src/packages/core/package.json index 1076a14b64..f86072e9d3 100644 --- a/src/packages/core/package.json +++ b/src/packages/core/package.json @@ -31,6 +31,7 @@ "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-node": "0.57.2", "@opentelemetry/sdk-trace-base": "1.30.1", + "@types/luxon": "3.6.2", "async-mutex": "0.5.0", "class-validator": "0.14.1", "dataloader": "2.2.3", diff --git a/src/packages/core/src/decorators/field.ts b/src/packages/core/src/decorators/field.ts index 04a5ba9a66..e249956bf9 100644 --- a/src/packages/core/src/decorators/field.ts +++ b/src/packages/core/src/decorators/field.ts @@ -86,6 +86,41 @@ export interface DetailPanelInputComponent { }; } +export type DateTimeFormat = + | 'DATETIME_FULL' + | 'DATETIME_FULL_WITH_SECONDS' + | 'DATETIME_HUGE' + | 'DATETIME_HUGE_WITH_SECONDS' + | 'DATETIME_MED' + | 'DATETIME_MED_WITH_SECONDS' + | 'DATETIME_MED_WITH_WEEKDAY' + | 'DATETIME_SHORT' + | 'DATETIME_SHORT_WITH_SECONDS' + | 'DATE_FULL' + | 'DATE_HUGE' + | 'DATE_MED' + | 'DATE_MED_WITH_WEEKDAY' + | 'DATE_SHORT' + | 'TIME_24_SIMPLE' + | 'TIME_24_WITH_LONG_OFFSET' + | 'TIME_24_WITH_SHORT_OFFSET' + | 'TIME_24_WITH_SECONDS' + | 'TIME_WITH_LONG_OFFSET' + | 'TIME_WITH_SHORT_OFFSET' + | 'TIME_SIMPLE' + | 'TIME_WITH_SECONDS'; + +export type CellFormatOptions = + | { + type: 'date'; + timezone?: 'UTC' | 'local' | string; + format?: DateTimeFormat; + } + | { + type: 'currency'; + variant: 'AUD' | 'GBP' | 'USD' | 'JPY' | 'EUR' | 'CHF' | 'THB' | 'IDR' | string; + }; + export interface FieldOptions { description?: string; deprecationReason?: string; @@ -107,6 +142,7 @@ export interface FieldOptions { readonly?: boolean; summaryField?: boolean; fieldForDetailPanelNavigationId?: boolean; + format?: CellFormatOptions; /** * Specifies a component to be utilized as input in the detail panel for this field. diff --git a/src/packages/core/src/metadata-service/field-format.ts b/src/packages/core/src/metadata-service/field-format.ts new file mode 100644 index 0000000000..20d3eb4221 --- /dev/null +++ b/src/packages/core/src/metadata-service/field-format.ts @@ -0,0 +1,18 @@ +import { DateTimeFormat, Entity, Field } from '../decorators'; + +@Entity('AdminUiFieldFormatMetadata', { + apiOptions: { excludeFromBuiltInOperations: true, excludeFromFederation: true }, +}) +export class AdminUiFieldFormatMetadata { + @Field(() => String, { nullable: false }) + type!: 'date' | 'currency'; + + @Field(() => String, { nullable: true }) + timezone?: string; + + @Field(() => String, { nullable: true }) + format?: DateTimeFormat; + + @Field(() => String, { nullable: true }) + variant?: string; +} diff --git a/src/packages/core/src/metadata-service/field.ts b/src/packages/core/src/metadata-service/field.ts index 7129fcf3d3..1b7333a99d 100644 --- a/src/packages/core/src/metadata-service/field.ts +++ b/src/packages/core/src/metadata-service/field.ts @@ -1,9 +1,10 @@ +import { GraphQLJSON } from '@exogee/graphweaver-scalars'; import { DetailPanelInputComponentOption, Entity, Field } from '../decorators'; -import { AdminUiFilterMetadata } from './filter'; +import { graphweaverMetadata } from '../metadata'; import { AdminUiFieldAttributeMetadata } from './field-attribute'; import { AdminUiFieldExtensionsMetadata } from './field-extensions'; -import { graphweaverMetadata } from '../metadata'; -import { GraphQLJSON } from '@exogee/graphweaver-scalars'; +import { AdminUiFieldFormatMetadata } from './field-format'; +import { AdminUiFilterMetadata } from './filter'; graphweaverMetadata.collectEnumInformation({ name: 'DetailPanelInputComponentOption', @@ -40,6 +41,9 @@ export class AdminUiFieldMetadata { @Field(() => AdminUiFilterMetadata, { nullable: true }) filter?: AdminUiFilterMetadata; + @Field(() => AdminUiFieldFormatMetadata, { nullable: true }) + format?: AdminUiFieldFormatMetadata; + @Field(() => AdminUiFieldAttributeMetadata, { nullable: true }) attributes?: AdminUiFieldAttributeMetadata; diff --git a/src/packages/core/src/metadata-service/resolver.ts b/src/packages/core/src/metadata-service/resolver.ts index 3e66344650..2e13b1b937 100644 --- a/src/packages/core/src/metadata-service/resolver.ts +++ b/src/packages/core/src/metadata-service/resolver.ts @@ -116,6 +116,7 @@ export const resolveAdminUiMetadata = (hooks?: Hooks) => { isReadOnly, isRequired, }, + format: field.adminUIOptions?.format, hideInTable: field.adminUIOptions?.hideInTable, hideInFilterBar: field.adminUIOptions?.hideInFilterBar, hideInDetailForm: field.adminUIOptions?.hideInDetailForm, diff --git a/src/packages/core/src/types.ts b/src/packages/core/src/types.ts index b8116780ec..9c8e678f1f 100644 --- a/src/packages/core/src/types.ts +++ b/src/packages/core/src/types.ts @@ -4,7 +4,11 @@ import { GraphQLID, GraphQLResolveInfo, GraphQLScalarType, Source } from 'graphq import { ResolveTree } from 'graphql-parse-resolve-info'; import { ComplexityEstimator } from 'graphql-query-complexity'; import { EntityMetadata } from './metadata'; -import { DetailPanelInputComponent, DetailPanelInputComponentOption } from './decorators'; +import { + CellFormatOptions, + DetailPanelInputComponent, + DetailPanelInputComponentOption, +} from './decorators'; export type { Instrumentation } from '@opentelemetry/instrumentation'; export type { GraphQLResolveInfo } from 'graphql'; @@ -255,6 +259,7 @@ export interface FieldMetadata { filterType?: AdminUIFilterType; filterOptions?: Record; detailPanelInputComponent?: DetailPanelInputComponentOption | DetailPanelInputComponent; + format?: CellFormatOptions; }; apiOptions?: { excludeFromBuiltInWriteOperations?: boolean; diff --git a/src/packages/end-to-end/src/__tests__/api/sqlite/query/adminUISettings.test.ts b/src/packages/end-to-end/src/__tests__/api/sqlite/query/adminUISettings.test.ts index 5652c0c70f..0c0585fd9f 100644 --- a/src/packages/end-to-end/src/__tests__/api/sqlite/query/adminUISettings.test.ts +++ b/src/packages/end-to-end/src/__tests__/api/sqlite/query/adminUISettings.test.ts @@ -173,7 +173,7 @@ test('Test the decorator adminUISettings', async () => { }); assert(response.body.kind === 'single'); const result = response.body.singleResult.data?.result; - expect(result?.entities).toHaveLength(13); + expect(result?.entities).toHaveLength(14); const albumEntity = result?.entities.find((entity) => entity.name === 'Album'); expect(albumEntity).not.toBeNull(); diff --git a/src/packages/end-to-end/src/__tests__/api/sqlite/query/export-page-size-decorator.test.ts b/src/packages/end-to-end/src/__tests__/api/sqlite/query/export-page-size-decorator.test.ts index 47e5f7c11c..c40ae583c8 100644 --- a/src/packages/end-to-end/src/__tests__/api/sqlite/query/export-page-size-decorator.test.ts +++ b/src/packages/end-to-end/src/__tests__/api/sqlite/query/export-page-size-decorator.test.ts @@ -117,7 +117,7 @@ test('Should return exportPageSize attribute for each entity in getAdminUiMetada }); assert(response.body.kind === 'single'); const result = response.body.singleResult.data?.result; - expect(result?.entities).toHaveLength(12); + expect(result?.entities).toHaveLength(13); const albumEntity = result?.entities.find((entity) => entity.name === 'Album'); expect(albumEntity).not.toBeNull(); diff --git a/src/packages/end-to-end/src/__tests__/api/sqlite/query/field-isArray.test.ts b/src/packages/end-to-end/src/__tests__/api/sqlite/query/field-isArray.test.ts index 722740acc9..8031d66cfc 100644 --- a/src/packages/end-to-end/src/__tests__/api/sqlite/query/field-isArray.test.ts +++ b/src/packages/end-to-end/src/__tests__/api/sqlite/query/field-isArray.test.ts @@ -130,7 +130,7 @@ test('Should return isArray = true if field property is defined as array', async }); assert(response.body.kind === 'single'); const result = response.body.singleResult.data?.result; - expect(result?.entities).toHaveLength(12); + expect(result?.entities).toHaveLength(13); const artistEntity = result?.entities.find((entity) => entity.name === 'Artist'); expect(artistEntity).not.toBeNull(); diff --git a/src/packages/end-to-end/src/__tests__/api/sqlite/query/read-only-entity.test.ts b/src/packages/end-to-end/src/__tests__/api/sqlite/query/read-only-entity.test.ts index 6d363d58cf..5d8fadffa3 100644 --- a/src/packages/end-to-end/src/__tests__/api/sqlite/query/read-only-entity.test.ts +++ b/src/packages/end-to-end/src/__tests__/api/sqlite/query/read-only-entity.test.ts @@ -113,7 +113,7 @@ test('Should return isReadOnly attribute for each entity in getAdminUiMetadata', }); assert(response.body.kind === 'single'); const result = response.body.singleResult.data?.result; - expect(result?.entities).toHaveLength(12); + expect(result?.entities).toHaveLength(13); const albumEntity = result?.entities.find((entity) => entity.name === 'Album'); expect(albumEntity).not.toBeNull(); diff --git a/src/packages/end-to-end/src/__tests__/api/sqlite/query/read-only-field.test.ts b/src/packages/end-to-end/src/__tests__/api/sqlite/query/read-only-field.test.ts index e7b4e84e0a..9b74b10f92 100644 --- a/src/packages/end-to-end/src/__tests__/api/sqlite/query/read-only-field.test.ts +++ b/src/packages/end-to-end/src/__tests__/api/sqlite/query/read-only-field.test.ts @@ -151,7 +151,7 @@ test('Should return isReadOnly attribute for each field in getAdminUiMetadata', }); assert(response.body.kind === 'single'); const result = response.body.singleResult.data?.result; - expect(result?.entities).toHaveLength(11); + expect(result?.entities).toHaveLength(12); const customerEntity = result?.entities.find((entity) => entity.name === 'Customer'); expect(customerEntity).not.toBeNull(); diff --git a/src/pnpm-lock.yaml b/src/pnpm-lock.yaml index 1e6867e46c..c3cd34fac6 100644 --- a/src/pnpm-lock.yaml +++ b/src/pnpm-lock.yaml @@ -1329,6 +1329,9 @@ importers: '@opentelemetry/sdk-trace-base': specifier: 1.30.1 version: 1.30.1(@opentelemetry/api@1.9.0) + '@types/luxon': + specifier: 3.6.2 + version: 3.6.2 async-mutex: specifier: 0.5.0 version: 0.5.0