@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.
- package/CHANGELOG.md +24 -0
- package/package.json +12 -5
- package/src/adapters/CannotSortError.ts +19 -0
- package/src/adapters/EverystackAdapter.ts +28 -16
- 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 +12 -0
- package/src/providers/AdminConfigProvider.tsx +7 -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 +34 -5
- package/src/screens/ResourceEdit.tsx +34 -5
- package/src/screens/ResourceList.tsx +118 -17
- package/src/screens/ResourceListView.tsx +7 -3
- package/src/screens/ResourceShow.tsx +8 -4
- package/src/types.ts +89 -14
- 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 +23 -10
- 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 +2 -1
|
@@ -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,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
|
-
|
|
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
|
-
|
|
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
|
);
|