@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.
- package/CHANGELOG.md +38 -0
- package/package.json +22 -5
- package/src/adapters/CannotSortError.ts +19 -0
- package/src/adapters/EverystackAdapter.ts +33 -20
- package/src/adapters/EverystackApiError.ts +18 -0
- package/src/adapters/types.ts +13 -4
- package/src/components/DateConditionInput.tsx +148 -0
- package/src/components/DateFilterBar.tsx +85 -0
- package/src/components/DateTimeValue.tsx +37 -0
- package/src/components/DeletePreview.tsx +15 -6
- package/src/hooks/useAdminData.ts +44 -3
- package/src/hooks/useDateRangeFilters.ts +47 -0
- package/src/hooks/useViewState.ts +41 -8
- package/src/index.ts +16 -0
- package/src/providers/AdminConfigProvider.tsx +11 -2
- package/src/providers/DataAdapterProvider.tsx +5 -3
- package/src/rpc/list-filter.ts +210 -0
- package/src/rpc/resource-dashboard-rpc.ts +62 -13
- package/src/screens/AdminRouter.tsx +19 -6
- package/src/screens/ResourceCreate.tsx +51 -6
- package/src/screens/ResourceEdit.tsx +71 -8
- package/src/screens/ResourceList.tsx +118 -17
- package/src/screens/ResourceListView.tsx +7 -3
- package/src/screens/ResourceShow.tsx +14 -9
- package/src/types.ts +114 -11
- package/src/utils/changed-fields.ts +54 -0
- package/src/utils/date-filter-fields.ts +32 -0
- package/src/utils/date-range.ts +146 -0
- package/src/utils/datetime-submission.ts +25 -0
- package/src/utils/field-sortable.ts +19 -0
- package/src/utils/format-cell-value.ts +28 -5
- package/src/utils/integrity-error.ts +16 -0
- package/src/utils/list-query.ts +3 -3
- package/src/utils/resource-query.ts +303 -0
- package/src/utils/validate-config.ts +85 -0
- package/src/utils/view-filters.ts +9 -3
- 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 {
|
|
19
|
-
import
|
|
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 =
|
|
389
|
+
const reqSort = params.sortBy ?? params.sort;
|
|
374
390
|
const reqDir = (params.sortDir ?? params.dir) as string;
|
|
375
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
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
|
|
541
|
-
const
|
|
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:
|
|
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
|
|
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
|
-
|
|
83
|
+
paramValues,
|
|
84
|
+
setParams: (next: ViewParamDelta) => {
|
|
73
85
|
if (router.setParams) {
|
|
74
86
|
router.setParams(next);
|
|
75
87
|
return;
|
|
76
88
|
}
|
|
77
|
-
const merged:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|