@everystack/admin 0.4.5 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +22 -5
  3. package/src/adapters/CannotSortError.ts +19 -0
  4. package/src/adapters/EverystackAdapter.ts +33 -20
  5. package/src/adapters/EverystackApiError.ts +18 -0
  6. package/src/adapters/types.ts +13 -4
  7. package/src/components/DateConditionInput.tsx +148 -0
  8. package/src/components/DateFilterBar.tsx +85 -0
  9. package/src/components/DateTimeValue.tsx +37 -0
  10. package/src/components/DeletePreview.tsx +15 -6
  11. package/src/hooks/useAdminData.ts +44 -3
  12. package/src/hooks/useDateRangeFilters.ts +47 -0
  13. package/src/hooks/useViewState.ts +41 -8
  14. package/src/index.ts +16 -0
  15. package/src/providers/AdminConfigProvider.tsx +11 -2
  16. package/src/providers/DataAdapterProvider.tsx +5 -3
  17. package/src/rpc/list-filter.ts +210 -0
  18. package/src/rpc/resource-dashboard-rpc.ts +62 -13
  19. package/src/screens/AdminRouter.tsx +19 -6
  20. package/src/screens/ResourceCreate.tsx +51 -6
  21. package/src/screens/ResourceEdit.tsx +71 -8
  22. package/src/screens/ResourceList.tsx +118 -17
  23. package/src/screens/ResourceListView.tsx +7 -3
  24. package/src/screens/ResourceShow.tsx +14 -9
  25. package/src/types.ts +114 -11
  26. package/src/utils/changed-fields.ts +54 -0
  27. package/src/utils/date-filter-fields.ts +32 -0
  28. package/src/utils/date-range.ts +146 -0
  29. package/src/utils/datetime-submission.ts +25 -0
  30. package/src/utils/field-sortable.ts +19 -0
  31. package/src/utils/format-cell-value.ts +28 -5
  32. package/src/utils/integrity-error.ts +16 -0
  33. package/src/utils/list-query.ts +3 -3
  34. package/src/utils/resource-query.ts +303 -0
  35. package/src/utils/validate-config.ts +85 -0
  36. package/src/utils/view-filters.ts +9 -3
  37. package/src/utils/wall-clock.ts +72 -0
@@ -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,8 +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';
16
+ import { integrityErrorField } from '../utils/integrity-error';
15
17
  import { missingRequired } from '../utils/required';
18
+ import { isWallClock, wallClockError } from '../utils/wall-clock';
19
+ import { withoutWhere } from '../utils/resource-query';
16
20
  import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, ErrorState } from '@everystack/ui';
17
21
  import { ImageFieldInput } from '../components/ImageFieldInput';
18
22
  import { AvatarFieldInput } from '../components/AvatarFieldInput';
@@ -40,6 +44,22 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
40
44
 
41
45
  const [formData, setFormData] = useState<Record<string, unknown>>({});
42
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
+ }, []);
43
63
  const [submitting, setSubmitting] = useState(false);
44
64
  const [submitError, setSubmitError] = useState<string | null>(null);
45
65
  // Set synchronously so two presses in one tick (before a re-render) submit once.
@@ -49,10 +69,11 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
49
69
  if (!resourceConfig) return [];
50
70
  const fieldNames = resourceConfig.createFields ?? resourceConfig.fields.map(f => f.name);
51
71
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
52
- return fieldNames
72
+ // A `where` column has no input: the adapter sends its `where` value instead.
73
+ return withoutWhere(fieldNames
53
74
  .map(name => fieldMap.get(name))
54
75
  .filter((f): f is FieldDefinition => f != null)
55
- .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
76
+ .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id')), resourceConfig);
56
77
  }, [resourceConfig]);
57
78
 
58
79
  const editors = useEditors();
@@ -74,10 +95,18 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
74
95
  const validate = useCallback((): Record<string, string> => {
75
96
  const newErrors: Record<string, string> = {};
76
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
+ }
77
103
  const value = formData[field.name];
78
104
  if (field.validation?.required && missingRequired(field, value)) {
79
105
  newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
106
+ continue;
80
107
  }
108
+ const skipped = wallClockError(field, value);
109
+ if (skipped) newErrors[field.name] = skipped;
81
110
  }
82
111
  setErrors(newErrors);
83
112
  return newErrors;
@@ -96,10 +125,17 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
96
125
  setSubmitError(null);
97
126
 
98
127
  try {
99
- 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 });
100
129
  onSuccess?.(record);
101
130
  } catch (err) {
102
- setSubmitError(err instanceof Error ? err.message : String(err));
131
+ const message = err instanceof Error ? err.message : String(err);
132
+ const field = integrityErrorField(err, fields);
133
+ if (field) {
134
+ setErrors({ [field.name]: message });
135
+ requestScrollToFirstError(fields);
136
+ } else {
137
+ setSubmitError(message);
138
+ }
103
139
  } finally {
104
140
  inFlight.current = false;
105
141
  setSubmitting(false);
@@ -117,6 +153,7 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
117
153
  value={formData[field.name]}
118
154
  error={errors[field.name]}
119
155
  onChange={value => setFieldValue(field.name, value)}
156
+ onValidityChange={setFieldValidity}
120
157
  values={formData}
121
158
  />
122
159
  );
@@ -166,13 +203,17 @@ interface FieldInputProps {
166
203
  value: unknown;
167
204
  error?: string;
168
205
  onChange: (value: unknown) => void;
206
+ onValidityChange: (name: string, valid: boolean, reason?: string) => void;
169
207
  /** The whole form's current values, for a custom input. */
170
208
  values: Record<string, unknown>;
171
209
  }
172
210
 
173
- function FieldInput({ field, value, error, onChange, values }: FieldInputProps): React.ReactElement {
211
+ function FieldInput({ field, value, error, onChange, onValidityChange, values }: FieldInputProps): React.ReactElement {
174
212
  const editors = useEditors();
175
213
  const adapter = useDataAdapter();
214
+ const reportValidity = useCallback((valid: boolean, reason?: string) => {
215
+ onValidityChange(field.name, valid, reason);
216
+ }, [field.name, onValidityChange]);
176
217
 
177
218
  // Resolution: inline input → registered editor → type-based default.
178
219
  if (field.input) {
@@ -220,7 +261,11 @@ function FieldInput({ field, value, error, onChange, values }: FieldInputProps):
220
261
  case 'datetime':
221
262
  return (
222
263
  <FormField label={label} error={error} required={required}>
223
- <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" />
264
+ {isWallClock(field) ? (
265
+ <DateInput value={toDateInputValue(value)} onValueChange={onChange} timeZone={field.timezone} storage="wall" />
266
+ ) : (
267
+ <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" storage="utc" onValidityChange={reportValidity} />
268
+ )}
224
269
  </FormField>
225
270
  );
226
271
 
@@ -2,7 +2,7 @@
2
2
  * ResourceEdit
3
3
  *
4
4
  * Edit form for an existing resource record.
5
- * Pre-populates form with existing data, submits via adapter.update().
5
+ * Pre-populates form with existing data, submits the changed fields via adapter.update().
6
6
  */
7
7
 
8
8
  import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
@@ -11,8 +11,13 @@ 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';
16
+ import { integrityErrorField } from '../utils/integrity-error';
15
17
  import { missingRequired } from '../utils/required';
18
+ import { isWallClock, wallClockError } from '../utils/wall-clock';
19
+ import { withoutWhere } from '../utils/resource-query';
20
+ import { changedFields } from '../utils/changed-fields';
16
21
  import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, LoadingState, ErrorState } from '@everystack/ui';
17
22
  import { ImageFieldInput } from '../components/ImageFieldInput';
18
23
  import { AvatarFieldInput } from '../components/AvatarFieldInput';
@@ -43,21 +48,40 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
43
48
  const [record, setRecord] = useState<DataRecord | null>(null);
44
49
  const [formData, setFormData] = useState<Record<string, unknown>>({});
45
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
+ }, []);
46
67
  const [loading, setLoading] = useState(true);
47
68
  const [submitting, setSubmitting] = useState(false);
48
69
  const [fetchError, setFetchError] = useState<Error | null>(null);
49
70
  const [submitError, setSubmitError] = useState<string | null>(null);
50
71
  // Set synchronously so two presses in one tick (before a re-render) submit once.
51
72
  const inFlight = useRef(false);
73
+ // The values as loaded. A save sends only the fields that differ from these.
74
+ const initialValues = useRef<Record<string, unknown>>({});
52
75
 
53
76
  const fields = useMemo(() => {
54
77
  if (!resourceConfig) return [];
55
78
  const fieldNames = resourceConfig.editFields ?? resourceConfig.fields.map(f => f.name);
56
79
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
57
- return fieldNames
80
+ // A `where` column has no input: the adapter sends its `where` value instead.
81
+ return withoutWhere(fieldNames
58
82
  .map(name => fieldMap.get(name))
59
83
  .filter((f): f is FieldDefinition => f != null)
60
- .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
84
+ .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id')), resourceConfig);
61
85
  }, [resourceConfig]);
62
86
 
63
87
  const editors = useEditors();
@@ -80,6 +104,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
80
104
  for (const field of fields) {
81
105
  initial[field.name] = result[field.name];
82
106
  }
107
+ initialValues.current = initial;
83
108
  setFormData(initial);
84
109
  } catch (err) {
85
110
  setFetchError(err instanceof Error ? err : new Error(String(err)));
@@ -103,11 +128,22 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
103
128
 
104
129
  const validate = useCallback((): Record<string, string> => {
105
130
  const newErrors: Record<string, string> = {};
131
+ // Only a value this save would send is checked for a skipped wall time: a stored one the user
132
+ // did not touch must not block saving another field.
133
+ const changes = changedFields(fields, initialValues.current, formData);
106
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
+ }
107
140
  const value = formData[field.name];
108
141
  if (field.validation?.required && missingRequired(field, value)) {
109
142
  newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
143
+ continue;
110
144
  }
145
+ const skipped = field.name in changes ? wallClockError(field, value) : null;
146
+ if (skipped) newErrors[field.name] = skipped;
111
147
  }
112
148
  setErrors(newErrors);
113
149
  return newErrors;
@@ -121,20 +157,38 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
121
157
  return;
122
158
  }
123
159
 
160
+ const changes = changedFields(fields, initialValues.current, formData);
161
+ // The API refuses an empty update, and there is nothing to save.
162
+ if (Object.keys(changes).length === 0) {
163
+ setSubmitError(null);
164
+ if (record) onSuccess?.(record);
165
+ return;
166
+ }
167
+
124
168
  inFlight.current = true;
125
169
  setSubmitting(true);
126
170
  setSubmitError(null);
127
171
 
128
172
  try {
129
- const updated = await adapter.update(resource, id, formData);
173
+ const updated = await adapter.update(resource, id, normalizeDateTimeSubmission(fields, changes));
174
+ // What was saved is now what is stored: the next save diffs against it.
175
+ initialValues.current = { ...formData };
176
+ setRecord(prev => ({ ...prev, ...updated }));
130
177
  onSuccess?.(updated);
131
178
  } catch (err) {
132
- setSubmitError(err instanceof Error ? err.message : String(err));
179
+ const message = err instanceof Error ? err.message : String(err);
180
+ const field = integrityErrorField(err, fields);
181
+ if (field) {
182
+ setErrors({ [field.name]: message });
183
+ requestScrollToFirstError(fields);
184
+ } else {
185
+ setSubmitError(message);
186
+ }
133
187
  } finally {
134
188
  inFlight.current = false;
135
189
  setSubmitting(false);
136
190
  }
137
- }, [adapter, resource, id, formData, validate, onSuccess, fields, requestScrollToFirstError]);
191
+ }, [adapter, resource, id, formData, record, validate, onSuccess, fields, requestScrollToFirstError]);
138
192
 
139
193
  if (!resourceConfig) {
140
194
  return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
@@ -159,6 +213,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
159
213
  value={formData[field.name]}
160
214
  error={errors[field.name]}
161
215
  onChange={value => setFieldValue(field.name, value)}
216
+ onValidityChange={setFieldValidity}
162
217
  values={formData}
163
218
  record={record}
164
219
  />
@@ -211,14 +266,18 @@ interface EditFieldInputProps {
211
266
  value: unknown;
212
267
  error?: string;
213
268
  onChange: (value: unknown) => void;
269
+ onValidityChange: (name: string, valid: boolean, reason?: string) => void;
214
270
  record?: DataRecord;
215
271
  /** The whole form's current values, for a custom input. */
216
272
  values: Record<string, unknown>;
217
273
  }
218
274
 
219
- function EditFieldInput({ field, value, error, onChange, record, values }: EditFieldInputProps): React.ReactElement {
275
+ function EditFieldInput({ field, value, error, onChange, onValidityChange, record, values }: EditFieldInputProps): React.ReactElement {
220
276
  const editors = useEditors();
221
277
  const adapter = useDataAdapter();
278
+ const reportValidity = useCallback((valid: boolean, reason?: string) => {
279
+ onValidityChange(field.name, valid, reason);
280
+ }, [field.name, onValidityChange]);
222
281
 
223
282
  // Resolution: inline input → registered editor → type-based default.
224
283
  if (field.input) {
@@ -263,7 +322,11 @@ function EditFieldInput({ field, value, error, onChange, record, values }: EditF
263
322
  case 'datetime':
264
323
  return (
265
324
  <FormField label={label} error={error} required={required}>
266
- <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" />
325
+ {isWallClock(field) ? (
326
+ <DateInput value={toDateInputValue(value)} onValueChange={onChange} timeZone={field.timezone} storage="wall" />
327
+ ) : (
328
+ <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" storage="utc" onValidityChange={reportValidity} />
329
+ )}
267
330
  </FormField>
268
331
  );
269
332