@everystack/admin 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,8 +15,10 @@
15
15
  */
16
16
 
17
17
  import { sql, type SQL } from 'drizzle-orm';
18
- import { toCamelCaseKeys } from '../utils/naming';
19
- import type { ResourceDashboardConfig, DimensionConfig, StatDialConfig } from '../types';
18
+ import { HandlerError } from '@everystack/api/errors';
19
+ import { compileListFilter } from './list-filter';
20
+ import { toCamelCaseKeys, toSnakeCase } from '../utils/naming';
21
+ import type { ResourceDashboardConfig, DimensionConfig, StatDialConfig, WhereValues } from '../types';
20
22
 
21
23
  interface DbExecutor {
22
24
  execute(query: unknown): Promise<{ rows?: unknown[] } | unknown[]>;
@@ -80,6 +82,15 @@ export interface EventListResult {
80
82
 
81
83
  const TABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
82
84
  const COLUMN_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
85
+ const ERROR_COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
86
+
87
+ function refuseOrder(column: unknown): never {
88
+ const safe = typeof column === 'string' && ERROR_COLUMN.exec(column)?.[0] === column;
89
+ throw new HandlerError(400, safe ? `Unknown order column: ${column}` : 'Unknown order column', {
90
+ code: 'invalid_order',
91
+ ...(safe ? { column } : {}),
92
+ });
93
+ }
83
94
 
84
95
  function assertTable(name: string): void {
85
96
  if (!TABLE_PATTERN.test(name)) {
@@ -124,6 +135,7 @@ function buildWhereClause(
124
135
  config: ResourceDashboardConfig,
125
136
  filters: DashboardFilters,
126
137
  hours: number,
138
+ options: { timeBounded?: boolean; conditions?: SQL[] } = {},
127
139
  ): SQL {
128
140
  const conditions: SQL[] = [];
129
141
  const tsCol = config.timestampColumn;
@@ -137,7 +149,7 @@ function buildWhereClause(
137
149
  if (filters.time && filters.time[0] && filters.time[1]) {
138
150
  conditions.push(sql`${sql.raw(tsCol)} >= ${filters.time[0]}::timestamptz`);
139
151
  conditions.push(sql`${sql.raw(tsCol)} <= ${filters.time[1]}::timestamptz`);
140
- } else {
152
+ } else if (!options.timeBounded || filters.time !== undefined) {
141
153
  conditions.push(sql`${sql.raw(tsCol)} >= NOW() - INTERVAL '1 hour' * ${hours}`);
142
154
  }
143
155
 
@@ -160,6 +172,8 @@ function buildWhereClause(
160
172
  conditions.push(sql`${sql.raw(dim.column)} IN (${inList})`);
161
173
  }
162
174
 
175
+ conditions.push(...(options.conditions ?? []));
176
+
163
177
  if (conditions.length === 0) return sql`1=1`;
164
178
  if (conditions.length === 1) return conditions[0];
165
179
 
@@ -360,7 +374,9 @@ function createEventListHandler(
360
374
  config: ResourceDashboardConfig,
361
375
  db: DbExecutor,
362
376
  ): DashboardRpc {
363
- const validSortColumns = new Set(config.listColumns);
377
+ const validSortColumns = new Set([...config.listColumns, config.timestampColumn]);
378
+ // Build the config-only column/alias map once, after factory validation.
379
+ compileListFilter(config, undefined);
364
380
 
365
381
  return async (body: unknown, _user?: unknown, _client?: unknown, tx?: DbExecutor) => {
366
382
  const exec = tx ?? db;
@@ -370,14 +386,20 @@ function createEventListHandler(
370
386
  const search = params.search as string | undefined;
371
387
  // Accept the EverystackAdapter listRpc param names (sort/dir) as aliases for the
372
388
  // EventTable names (sortBy/sortDir), so this handler serves both consumers.
373
- const reqSort = (params.sortBy ?? params.sort) as string;
389
+ const reqSort = params.sortBy ?? params.sort;
374
390
  const reqDir = (params.sortDir ?? params.dir) as string;
375
- const sortBy = validSortColumns.has(reqSort) ? reqSort : config.timestampColumn;
391
+ // The admin list sends the camelCase field name (durationMs) for the column duration_ms.
392
+ const reqColumn = typeof reqSort === 'string' ? toSnakeCase(reqSort) : undefined;
393
+ const sortBy = reqSort == null || reqSort === '' ? config.timestampColumn
394
+ : typeof reqSort === 'string' && validSortColumns.has(reqSort) ? reqSort
395
+ : reqColumn !== undefined && validSortColumns.has(reqColumn) ? reqColumn
396
+ : refuseOrder(reqSort);
376
397
  const sortDir = reqDir === 'asc' ? 'asc' : 'desc';
377
398
  const limit = Math.min(Math.max(1, Number(params.limit ?? 50)), 200);
378
399
  const offset = Math.max(0, Number(params.offset ?? 0));
379
400
 
380
- const where = buildWhereClause(config, filters, hours);
401
+ const listFilter = compileListFilter(config, params.filter);
402
+ const where = buildWhereClause(config, filters, hours, listFilter);
381
403
 
382
404
  // Build search condition
383
405
  let searchCondition: SQL = sql`1=1`;
@@ -398,10 +420,14 @@ function createEventListHandler(
398
420
  ? (Array.isArray(config.primaryKey) ? config.primaryKey : [config.primaryKey])
399
421
  : ['id'];
400
422
  const allCols = [...new Set([...pkCols, ...config.listColumns])];
423
+ // These identifiers are unquoted in SQL, so PostgreSQL folds their case.
424
+ const hasTimestamp = allCols.some(column => column.toLowerCase() === config.timestampColumn.toLowerCase());
425
+ const filteredCols = hasTimestamp ? allCols : [...allCols, config.timestampColumn];
426
+ const resultCols = hasTimestamp ? '*' : allCols.join(', ');
401
427
 
402
428
  const result = await exec.execute(sql`
403
429
  WITH filtered AS (
404
- SELECT ${sql.raw(allCols.join(', '))}
430
+ SELECT ${sql.raw(filteredCols.join(', '))}
405
431
  FROM ${sql.raw(tableName)}
406
432
  WHERE ${where} AND ${searchCondition}
407
433
  )
@@ -409,7 +435,7 @@ function createEventListHandler(
409
435
  (SELECT COUNT(*)::int FROM filtered) AS total,
410
436
  (SELECT COALESCE(json_agg(row_to_json(r)), '[]'::json)
411
437
  FROM (
412
- SELECT * FROM filtered
438
+ SELECT ${sql.raw(resultCols)} FROM filtered
413
439
  ORDER BY ${sql.raw(sortBy)} ${sql.raw(sortDir)}
414
440
  LIMIT ${limit} OFFSET ${offset}
415
441
  ) r
@@ -520,25 +546,48 @@ export function createResourceDashboardRpcs(
520
546
  };
521
547
  }
522
548
 
549
+ /**
550
+ * The dashboard's static where with the resource's `where` ANDed in. A `where` key is a field name
551
+ * as the API spells it, so a camelCase key becomes its snake_case column (a snake_case key is
552
+ * unchanged). The resource `where` wins on the same column, as it does on the list. With no
553
+ * resource `where`, the dashboard config is returned as is.
554
+ */
555
+ function withResourceWhere(config: ResourceDashboardConfig, where: WhereValues | undefined): ResourceDashboardConfig {
556
+ const entries = Object.entries(where ?? {});
557
+ if (entries.length === 0) return config;
558
+ const forced = entries.map(([key, value]) => ({ column: toSnakeCase(key), equals: String(value) }));
559
+ const columns = new Set(forced.map(p => p.column));
560
+ return { ...config, where: [...(config.where ?? []).filter(p => !columns.has(p.column)), ...forced] };
561
+ }
562
+
523
563
  /**
524
564
  * Create dashboard RPCs for all resources that have a dashboard config.
525
565
  *
526
566
  * Returns a flat Record<string, RpcHandler> ready to spread into the handler's rpc config.
527
567
  * RPC names follow the pattern: {resourceName}DashboardData, {resourceName}EventList
528
568
  *
529
- * @param resources - Array of ResourceConfig (with optional .dashboard)
569
+ * The table is `dashboard.tableName`, else the resource `table`, else the resource name. When that
570
+ * is the resource's own table, the resource `where` is ANDed into every dashboard query, the event
571
+ * list and the event detail. When `dashboard.tableName` names another table, it is not: only
572
+ * `dashboard.where` applies.
573
+ *
574
+ * @param resources - Array of ResourceConfig (with optional .dashboard, .table and .where)
530
575
  * @param db - Drizzle db executor
531
576
  */
532
577
  export function createAllDashboardRpcs(
533
- resources: Array<{ name: string; dashboard?: ResourceDashboardConfig }>,
578
+ resources: Array<{ name: string; table?: string; where?: WhereValues; dashboard?: ResourceDashboardConfig }>,
534
579
  db: DbExecutor,
535
580
  ): Record<string, DashboardRpc> {
536
581
  const rpcs: Record<string, DashboardRpc> = {};
537
582
 
538
583
  for (const resource of resources) {
539
584
  if (!resource.dashboard) continue;
540
- const tableName = resource.dashboard.tableName ?? resource.name;
541
- const handlers = createResourceDashboardRpcs(tableName, resource.dashboard, db);
585
+ const ownTable = resource.table ?? resource.name;
586
+ const tableName = resource.dashboard.tableName ?? ownTable;
587
+ // The resource `where` names columns of the resource's own table. A dashboard over another
588
+ // table (a rollup view) may not have them, so there its own `dashboard.where` governs.
589
+ const where = tableName === ownTable ? resource.where : undefined;
590
+ const handlers = createResourceDashboardRpcs(tableName, withResourceWhere(resource.dashboard, where), db);
542
591
  // Convert resource name to camelCase RPC name prefix
543
592
  const prefix = resource.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
544
593
  rpcs[`${prefix}DashboardData`] = handlers.dashboardData;
@@ -11,7 +11,7 @@
11
11
  import React, { useMemo } from 'react';
12
12
  import { useAdminConfig } from '../hooks/useAdminConfig';
13
13
  import { useDataAdapter } from '../hooks/useAdminData';
14
- import { ViewStateContext, normalizeParams, buildQueryString } from '../hooks/useViewState';
14
+ import { ViewStateContext, normalizeParams, normalizeParamValues, buildQueryString, type ViewParamDelta } from '../hooks/useViewState';
15
15
  import { AdminNavigationContext } from '../hooks/useAdminNavigation';
16
16
  import { AdminDashboard } from './AdminDashboard';
17
17
  import { ResourceListView } from './ResourceListView';
@@ -31,7 +31,7 @@ export interface AdminNavigator {
31
31
  * remounts the list and takes the cursor out of the search box. A key set to undefined
32
32
  * is removed.
33
33
  */
34
- setParams?: (params: Record<string, string | undefined>) => void;
34
+ setParams?: (params: ViewParamDelta) => void;
35
35
  }
36
36
 
37
37
  export interface AdminRouterProps {
@@ -41,6 +41,11 @@ export interface AdminRouterProps {
41
41
  pathname: string;
42
42
  /** Current query params, e.g. expo-router's useLocalSearchParams(). */
43
43
  searchParams?: Record<string, string | string[] | undefined>;
44
+ /**
45
+ * Host route params to omit from view state and fallback URL rewrites, e.g.
46
+ * ['rest'] for a [...rest] route. Default: none. Query keys are never guessed.
47
+ */
48
+ ignoreParams?: string[];
44
49
  /** Mount point of the admin. Default '/admin'. */
45
50
  basePath?: string;
46
51
  /** API base for resources with a `dashboard` config (observability screens). */
@@ -53,6 +58,7 @@ export function AdminRouter({
53
58
  router,
54
59
  pathname,
55
60
  searchParams,
61
+ ignoreParams,
56
62
  basePath = '/admin',
57
63
  baseUrl,
58
64
  getToken,
@@ -66,20 +72,27 @@ export function AdminRouter({
66
72
  // View state ⇄ URL query: screens read the current params and push merged
67
73
  // updates, preserving the path. Powers shareable filtered/scrubbed views.
68
74
  const viewState = useMemo(() => {
69
- const params = normalizeParams(searchParams);
75
+ const ignored = new Set(ignoreParams);
76
+ const queryParams = Object.fromEntries(
77
+ Object.entries(searchParams ?? {}).filter(([key]) => !ignored.has(key)),
78
+ );
79
+ const params = normalizeParams(queryParams);
80
+ const paramValues = normalizeParamValues(queryParams);
70
81
  return {
71
82
  params,
72
- setParams: (next: Record<string, string | undefined>) => {
83
+ paramValues,
84
+ setParams: (next: ViewParamDelta) => {
73
85
  if (router.setParams) {
74
86
  router.setParams(next);
75
87
  return;
76
88
  }
77
- const merged: Record<string, string | undefined> = { ...params, ...next };
89
+ const merged: ViewParamDelta = { ...paramValues, ...next };
90
+ for (const key of ignored) delete merged[key];
78
91
  (router.replace ?? router.push)(`${pathname}${buildQueryString(merged)}`);
79
92
  },
80
93
  };
81
94
  // eslint-disable-next-line react-hooks/exhaustive-deps
82
- }, [JSON.stringify(searchParams), pathname, router]);
95
+ }, [JSON.stringify(searchParams), JSON.stringify(ignoreParams), pathname, router]);
83
96
 
84
97
  const navigation = useMemo(
85
98
  () => ({ push: (href: string) => router.push(href), basePath, resources: config.resources.map(r => r.name) }),
@@ -11,10 +11,12 @@ import { useAdminConfig } from '../hooks/useAdminConfig';
11
11
  import { useDataAdapter } from '../hooks/useAdminData';
12
12
  import { useEditors } from '../hooks/useEditors';
13
13
  import { editorFor } from '../utils/render-resolution';
14
+ import { normalizeDateTimeSubmission } from '../utils/datetime-submission';
14
15
  import { toDateInputValue } from '../utils/format-cell-value';
15
16
  import { integrityErrorField } from '../utils/integrity-error';
16
17
  import { missingRequired } from '../utils/required';
17
18
  import { isWallClock, wallClockError } from '../utils/wall-clock';
19
+ import { withoutWhere } from '../utils/resource-query';
18
20
  import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, ErrorState } from '@everystack/ui';
19
21
  import { ImageFieldInput } from '../components/ImageFieldInput';
20
22
  import { AvatarFieldInput } from '../components/AvatarFieldInput';
@@ -42,6 +44,22 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
42
44
 
43
45
  const [formData, setFormData] = useState<Record<string, unknown>>({});
44
46
  const [errors, setErrors] = useState<Record<string, string>>({});
47
+ // A picker can have an invalid draft while formData still holds its last valid instant.
48
+ const invalidFields = useRef(new Map<string, string>());
49
+ const setFieldValidity = useCallback((name: string, valid: boolean, reason?: string) => {
50
+ const previous = invalidFields.current.get(name);
51
+ const error = valid ? undefined : reason ?? 'Choose a valid date and time.';
52
+ if (error === previous) return;
53
+ if (error) invalidFields.current.set(name, error);
54
+ else invalidFields.current.delete(name);
55
+ setErrors(current => {
56
+ if (error) return { ...current, [name]: error };
57
+ if (current[name] !== previous) return current;
58
+ const next = { ...current };
59
+ delete next[name];
60
+ return next;
61
+ });
62
+ }, []);
45
63
  const [submitting, setSubmitting] = useState(false);
46
64
  const [submitError, setSubmitError] = useState<string | null>(null);
47
65
  // Set synchronously so two presses in one tick (before a re-render) submit once.
@@ -51,10 +69,11 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
51
69
  if (!resourceConfig) return [];
52
70
  const fieldNames = resourceConfig.createFields ?? resourceConfig.fields.map(f => f.name);
53
71
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
54
- return fieldNames
72
+ // A `where` column has no input: the adapter sends its `where` value instead.
73
+ return withoutWhere(fieldNames
55
74
  .map(name => fieldMap.get(name))
56
75
  .filter((f): f is FieldDefinition => f != null)
57
- .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
76
+ .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id')), resourceConfig);
58
77
  }, [resourceConfig]);
59
78
 
60
79
  const editors = useEditors();
@@ -76,6 +95,11 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
76
95
  const validate = useCallback((): Record<string, string> => {
77
96
  const newErrors: Record<string, string> = {};
78
97
  for (const field of fields) {
98
+ const draftError = invalidFields.current.get(field.name);
99
+ if (draftError) {
100
+ newErrors[field.name] = draftError;
101
+ continue;
102
+ }
79
103
  const value = formData[field.name];
80
104
  if (field.validation?.required && missingRequired(field, value)) {
81
105
  newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
@@ -101,7 +125,7 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
101
125
  setSubmitError(null);
102
126
 
103
127
  try {
104
- const record = await adapter.create(resource, formData as Record<string, unknown> & { id?: never });
128
+ const record = await adapter.create(resource, normalizeDateTimeSubmission(fields, formData) as Record<string, unknown> & { id?: never });
105
129
  onSuccess?.(record);
106
130
  } catch (err) {
107
131
  const message = err instanceof Error ? err.message : String(err);
@@ -129,6 +153,7 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
129
153
  value={formData[field.name]}
130
154
  error={errors[field.name]}
131
155
  onChange={value => setFieldValue(field.name, value)}
156
+ onValidityChange={setFieldValidity}
132
157
  values={formData}
133
158
  />
134
159
  );
@@ -178,13 +203,17 @@ interface FieldInputProps {
178
203
  value: unknown;
179
204
  error?: string;
180
205
  onChange: (value: unknown) => void;
206
+ onValidityChange: (name: string, valid: boolean, reason?: string) => void;
181
207
  /** The whole form's current values, for a custom input. */
182
208
  values: Record<string, unknown>;
183
209
  }
184
210
 
185
- function FieldInput({ field, value, error, onChange, values }: FieldInputProps): React.ReactElement {
211
+ function FieldInput({ field, value, error, onChange, onValidityChange, values }: FieldInputProps): React.ReactElement {
186
212
  const editors = useEditors();
187
213
  const adapter = useDataAdapter();
214
+ const reportValidity = useCallback((valid: boolean, reason?: string) => {
215
+ onValidityChange(field.name, valid, reason);
216
+ }, [field.name, onValidityChange]);
188
217
 
189
218
  // Resolution: inline input → registered editor → type-based default.
190
219
  if (field.input) {
@@ -235,7 +264,7 @@ function FieldInput({ field, value, error, onChange, values }: FieldInputProps):
235
264
  {isWallClock(field) ? (
236
265
  <DateInput value={toDateInputValue(value)} onValueChange={onChange} timeZone={field.timezone} storage="wall" />
237
266
  ) : (
238
- <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" />
267
+ <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" storage="utc" onValidityChange={reportValidity} />
239
268
  )}
240
269
  </FormField>
241
270
  );
@@ -11,10 +11,12 @@ import { useAdminConfig } from '../hooks/useAdminConfig';
11
11
  import { useDataAdapter } from '../hooks/useAdminData';
12
12
  import { useEditors } from '../hooks/useEditors';
13
13
  import { editorFor } from '../utils/render-resolution';
14
+ import { normalizeDateTimeSubmission } from '../utils/datetime-submission';
14
15
  import { toDateInputValue } from '../utils/format-cell-value';
15
16
  import { integrityErrorField } from '../utils/integrity-error';
16
17
  import { missingRequired } from '../utils/required';
17
18
  import { isWallClock, wallClockError } from '../utils/wall-clock';
19
+ import { withoutWhere } from '../utils/resource-query';
18
20
  import { changedFields } from '../utils/changed-fields';
19
21
  import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, LoadingState, ErrorState } from '@everystack/ui';
20
22
  import { ImageFieldInput } from '../components/ImageFieldInput';
@@ -46,6 +48,22 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
46
48
  const [record, setRecord] = useState<DataRecord | null>(null);
47
49
  const [formData, setFormData] = useState<Record<string, unknown>>({});
48
50
  const [errors, setErrors] = useState<Record<string, string>>({});
51
+ // A picker can have an invalid draft while formData still holds its last valid instant.
52
+ const invalidFields = useRef(new Map<string, string>());
53
+ const setFieldValidity = useCallback((name: string, valid: boolean, reason?: string) => {
54
+ const previous = invalidFields.current.get(name);
55
+ const error = valid ? undefined : reason ?? 'Choose a valid date and time.';
56
+ if (error === previous) return;
57
+ if (error) invalidFields.current.set(name, error);
58
+ else invalidFields.current.delete(name);
59
+ setErrors(current => {
60
+ if (error) return { ...current, [name]: error };
61
+ if (current[name] !== previous) return current;
62
+ const next = { ...current };
63
+ delete next[name];
64
+ return next;
65
+ });
66
+ }, []);
49
67
  const [loading, setLoading] = useState(true);
50
68
  const [submitting, setSubmitting] = useState(false);
51
69
  const [fetchError, setFetchError] = useState<Error | null>(null);
@@ -59,10 +77,11 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
59
77
  if (!resourceConfig) return [];
60
78
  const fieldNames = resourceConfig.editFields ?? resourceConfig.fields.map(f => f.name);
61
79
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
62
- return fieldNames
80
+ // A `where` column has no input: the adapter sends its `where` value instead.
81
+ return withoutWhere(fieldNames
63
82
  .map(name => fieldMap.get(name))
64
83
  .filter((f): f is FieldDefinition => f != null)
65
- .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
84
+ .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id')), resourceConfig);
66
85
  }, [resourceConfig]);
67
86
 
68
87
  const editors = useEditors();
@@ -113,6 +132,11 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
113
132
  // did not touch must not block saving another field.
114
133
  const changes = changedFields(fields, initialValues.current, formData);
115
134
  for (const field of fields) {
135
+ const draftError = invalidFields.current.get(field.name);
136
+ if (draftError) {
137
+ newErrors[field.name] = draftError;
138
+ continue;
139
+ }
116
140
  const value = formData[field.name];
117
141
  if (field.validation?.required && missingRequired(field, value)) {
118
142
  newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
@@ -146,7 +170,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
146
170
  setSubmitError(null);
147
171
 
148
172
  try {
149
- const updated = await adapter.update(resource, id, changes);
173
+ const updated = await adapter.update(resource, id, normalizeDateTimeSubmission(fields, changes));
150
174
  // What was saved is now what is stored: the next save diffs against it.
151
175
  initialValues.current = { ...formData };
152
176
  setRecord(prev => ({ ...prev, ...updated }));
@@ -189,6 +213,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
189
213
  value={formData[field.name]}
190
214
  error={errors[field.name]}
191
215
  onChange={value => setFieldValue(field.name, value)}
216
+ onValidityChange={setFieldValidity}
192
217
  values={formData}
193
218
  record={record}
194
219
  />
@@ -241,14 +266,18 @@ interface EditFieldInputProps {
241
266
  value: unknown;
242
267
  error?: string;
243
268
  onChange: (value: unknown) => void;
269
+ onValidityChange: (name: string, valid: boolean, reason?: string) => void;
244
270
  record?: DataRecord;
245
271
  /** The whole form's current values, for a custom input. */
246
272
  values: Record<string, unknown>;
247
273
  }
248
274
 
249
- function EditFieldInput({ field, value, error, onChange, record, values }: EditFieldInputProps): React.ReactElement {
275
+ function EditFieldInput({ field, value, error, onChange, onValidityChange, record, values }: EditFieldInputProps): React.ReactElement {
250
276
  const editors = useEditors();
251
277
  const adapter = useDataAdapter();
278
+ const reportValidity = useCallback((valid: boolean, reason?: string) => {
279
+ onValidityChange(field.name, valid, reason);
280
+ }, [field.name, onValidityChange]);
252
281
 
253
282
  // Resolution: inline input → registered editor → type-based default.
254
283
  if (field.input) {
@@ -296,7 +325,7 @@ function EditFieldInput({ field, value, error, onChange, record, values }: EditF
296
325
  {isWallClock(field) ? (
297
326
  <DateInput value={toDateInputValue(value)} onValueChange={onChange} timeZone={field.timezone} storage="wall" />
298
327
  ) : (
299
- <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" />
328
+ <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" storage="utc" onValidityChange={reportValidity} />
300
329
  )}
301
330
  </FormField>
302
331
  );