@everystack/admin 0.4.5 → 0.4.6
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 +14 -0
- package/package.json +14 -4
- package/src/adapters/EverystackAdapter.ts +5 -4
- package/src/adapters/EverystackApiError.ts +18 -0
- package/src/index.ts +4 -0
- package/src/providers/AdminConfigProvider.tsx +5 -1
- package/src/screens/ResourceCreate.tsx +18 -2
- package/src/screens/ResourceEdit.tsx +39 -5
- package/src/screens/ResourceShow.tsx +10 -9
- package/src/types.ts +29 -1
- package/src/utils/changed-fields.ts +54 -0
- package/src/utils/format-cell-value.ts +14 -4
- package/src/utils/integrity-error.ts +16 -0
- package/src/utils/wall-clock.ts +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to `@everystack/admin` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.6] - 2026-10-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- export editors/rich-html-extensions and editors/unsupported-markup
|
|
12
|
+
- the Edit form sends only the fields you changed, so beforeUpdate hooks and PATCH bodies carry less; a cleared field sends null, and a save with no changes sends nothing
|
|
13
|
+
- wall-clock datetime fields in a fixed business zone ({ timezone, storage: 'wall' } on a naive timestamp column); admin's FieldDefinition is now a union type, so an interface that extends it should extend FieldDefinitionBase instead
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- show integrity errors under matching form fields
|
|
18
|
+
- match a column for integrity code 23000 too
|
|
19
|
+
- ResourceShow prints an html or markdown field as text on native
|
|
20
|
+
|
|
7
21
|
## [0.4.5] - 2026-10-15
|
|
8
22
|
|
|
9
23
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/admin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Declarative admin dashboard framework for PostgREST APIs",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -42,6 +42,16 @@
|
|
|
42
42
|
"types": "./src/editors/tiptap.tsx",
|
|
43
43
|
"default": "./src/editors/tiptap.tsx"
|
|
44
44
|
},
|
|
45
|
+
"./editors/rich-html-extensions": {
|
|
46
|
+
"source": "./src/editors/rich-html-extensions.ts",
|
|
47
|
+
"types": "./src/editors/rich-html-extensions.ts",
|
|
48
|
+
"default": "./src/editors/rich-html-extensions.ts"
|
|
49
|
+
},
|
|
50
|
+
"./editors/unsupported-markup": {
|
|
51
|
+
"source": "./src/editors/unsupported-markup.ts",
|
|
52
|
+
"types": "./src/editors/unsupported-markup.ts",
|
|
53
|
+
"default": "./src/editors/unsupported-markup.ts"
|
|
54
|
+
},
|
|
45
55
|
"./package.json": "./package.json"
|
|
46
56
|
},
|
|
47
57
|
"dependencies": {
|
|
@@ -51,7 +61,7 @@
|
|
|
51
61
|
"peerDependencies": {
|
|
52
62
|
"drizzle-orm": "0.41.0",
|
|
53
63
|
"@everystack/api": ">=0.4.4",
|
|
54
|
-
"@everystack/ui": ">=0.4.
|
|
64
|
+
"@everystack/ui": ">=0.4.5",
|
|
55
65
|
"@tiptap/extension-code": ">=3.0.0",
|
|
56
66
|
"@tiptap/extension-code-block": ">=3.0.0",
|
|
57
67
|
"@tiptap/extension-image": ">=3.0.0",
|
|
@@ -117,8 +127,8 @@
|
|
|
117
127
|
"react-test-renderer": "19.2.0",
|
|
118
128
|
"ts-jest": "29.4.9",
|
|
119
129
|
"typescript": "5.9.3",
|
|
120
|
-
"@everystack/ui": "0.4.
|
|
121
|
-
"@everystack/api": "0.4.
|
|
130
|
+
"@everystack/ui": "0.4.5",
|
|
131
|
+
"@everystack/api": "0.4.6"
|
|
122
132
|
},
|
|
123
133
|
"scripts": {
|
|
124
134
|
"test": "jest",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createClient, type Client, type ClientOptions, type QueryBuilder } from '@everystack/api/client';
|
|
9
|
+
import { EverystackApiError } from './EverystackApiError';
|
|
9
10
|
import { toCamelCaseKeys } from '../utils/naming';
|
|
10
11
|
import { mergeFilters, normalizeSearchText } from '../utils/list-query';
|
|
11
12
|
import type { DataAdapter } from './types';
|
|
@@ -186,7 +187,7 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
186
187
|
const result = await this.client.from(resource).insert(data as Record<string, unknown>);
|
|
187
188
|
|
|
188
189
|
if (result.error) {
|
|
189
|
-
throw new
|
|
190
|
+
throw new EverystackApiError(result.error);
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
return result.data as T;
|
|
@@ -213,7 +214,7 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
213
214
|
const result = await builder.update(data as Record<string, unknown>);
|
|
214
215
|
|
|
215
216
|
if (result.error) {
|
|
216
|
-
throw new
|
|
217
|
+
throw new EverystackApiError(result.error);
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
const rows = (result.data ?? []) as T[];
|
|
@@ -234,7 +235,7 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
234
235
|
const result = await this.client.from(resource).eq('id', String(id)).remove();
|
|
235
236
|
|
|
236
237
|
if (result.error) {
|
|
237
|
-
throw new
|
|
238
|
+
throw new EverystackApiError(result.error);
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
if ((result.data ?? []).length === 0) throw new Error(ZERO_ROW_DELETE);
|
|
@@ -252,7 +253,7 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
252
253
|
const result = await this.client.rpc<T>(fn, body);
|
|
253
254
|
|
|
254
255
|
if (result.error) {
|
|
255
|
-
throw new
|
|
256
|
+
throw new EverystackApiError(result.error);
|
|
256
257
|
}
|
|
257
258
|
|
|
258
259
|
return result.data as T;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ClientError } from '@everystack/api/client';
|
|
2
|
+
|
|
3
|
+
/** An API refusal with the server's message and optional integrity metadata. */
|
|
4
|
+
export class EverystackApiError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly code?: string;
|
|
7
|
+
readonly constraint?: string;
|
|
8
|
+
readonly column?: string;
|
|
9
|
+
|
|
10
|
+
constructor(error: ClientError) {
|
|
11
|
+
super(error.message);
|
|
12
|
+
this.name = 'EverystackApiError';
|
|
13
|
+
this.status = error.status;
|
|
14
|
+
if (typeof error.code === 'string') this.code = error.code;
|
|
15
|
+
if (typeof error.constraint === 'string') this.constraint = error.constraint;
|
|
16
|
+
if (typeof error.column === 'string') this.column = error.column;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
export type {
|
|
11
11
|
FieldType,
|
|
12
12
|
FieldDefinition,
|
|
13
|
+
FieldDefinitionBase,
|
|
14
|
+
WallClockDatetime,
|
|
15
|
+
NoTimeZone,
|
|
13
16
|
TagGroup,
|
|
14
17
|
ImageSize,
|
|
15
18
|
CellContext,
|
|
@@ -56,6 +59,7 @@ export type {
|
|
|
56
59
|
} from './adapters/types';
|
|
57
60
|
|
|
58
61
|
// EverystackAdapter
|
|
62
|
+
export { EverystackApiError } from './adapters/EverystackApiError';
|
|
59
63
|
export { EverystackAdapter, type EverystackAdapterOptions } from './adapters/EverystackAdapter';
|
|
60
64
|
|
|
61
65
|
// Hooks
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Provides AdminConfig to the admin component tree.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import React from 'react';
|
|
7
|
+
import React, { useMemo } from 'react';
|
|
8
8
|
import type { AdminConfig } from '../types';
|
|
9
9
|
import { AdminConfigContext } from '../hooks/useAdminConfig';
|
|
10
|
+
import { validateAdminConfig } from '../utils/wall-clock';
|
|
10
11
|
|
|
11
12
|
export interface AdminConfigProviderProps {
|
|
12
13
|
config: AdminConfig;
|
|
@@ -14,5 +15,8 @@ export interface AdminConfigProviderProps {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function AdminConfigProvider({ config, children }: AdminConfigProviderProps) {
|
|
18
|
+
// Setup is where a bad config fails: loudly, naming the resource and field, not later on a
|
|
19
|
+
// screen that happens to read it.
|
|
20
|
+
useMemo(() => validateAdminConfig(config), [config]);
|
|
17
21
|
return <AdminConfigContext.Provider value={{ config }}>{children}</AdminConfigContext.Provider>;
|
|
18
22
|
}
|
|
@@ -12,7 +12,9 @@ import { useDataAdapter } from '../hooks/useAdminData';
|
|
|
12
12
|
import { useEditors } from '../hooks/useEditors';
|
|
13
13
|
import { editorFor } from '../utils/render-resolution';
|
|
14
14
|
import { toDateInputValue } from '../utils/format-cell-value';
|
|
15
|
+
import { integrityErrorField } from '../utils/integrity-error';
|
|
15
16
|
import { missingRequired } from '../utils/required';
|
|
17
|
+
import { isWallClock, wallClockError } from '../utils/wall-clock';
|
|
16
18
|
import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, ErrorState } from '@everystack/ui';
|
|
17
19
|
import { ImageFieldInput } from '../components/ImageFieldInput';
|
|
18
20
|
import { AvatarFieldInput } from '../components/AvatarFieldInput';
|
|
@@ -77,7 +79,10 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
|
|
|
77
79
|
const value = formData[field.name];
|
|
78
80
|
if (field.validation?.required && missingRequired(field, value)) {
|
|
79
81
|
newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
|
|
82
|
+
continue;
|
|
80
83
|
}
|
|
84
|
+
const skipped = wallClockError(field, value);
|
|
85
|
+
if (skipped) newErrors[field.name] = skipped;
|
|
81
86
|
}
|
|
82
87
|
setErrors(newErrors);
|
|
83
88
|
return newErrors;
|
|
@@ -99,7 +104,14 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
|
|
|
99
104
|
const record = await adapter.create(resource, formData as Record<string, unknown> & { id?: never });
|
|
100
105
|
onSuccess?.(record);
|
|
101
106
|
} catch (err) {
|
|
102
|
-
|
|
107
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
108
|
+
const field = integrityErrorField(err, fields);
|
|
109
|
+
if (field) {
|
|
110
|
+
setErrors({ [field.name]: message });
|
|
111
|
+
requestScrollToFirstError(fields);
|
|
112
|
+
} else {
|
|
113
|
+
setSubmitError(message);
|
|
114
|
+
}
|
|
103
115
|
} finally {
|
|
104
116
|
inFlight.current = false;
|
|
105
117
|
setSubmitting(false);
|
|
@@ -220,7 +232,11 @@ function FieldInput({ field, value, error, onChange, values }: FieldInputProps):
|
|
|
220
232
|
case 'datetime':
|
|
221
233
|
return (
|
|
222
234
|
<FormField label={label} error={error} required={required}>
|
|
223
|
-
|
|
235
|
+
{isWallClock(field) ? (
|
|
236
|
+
<DateInput value={toDateInputValue(value)} onValueChange={onChange} timeZone={field.timezone} storage="wall" />
|
|
237
|
+
) : (
|
|
238
|
+
<DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" />
|
|
239
|
+
)}
|
|
224
240
|
</FormField>
|
|
225
241
|
);
|
|
226
242
|
|
|
@@ -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';
|
|
@@ -12,7 +12,10 @@ import { useDataAdapter } from '../hooks/useAdminData';
|
|
|
12
12
|
import { useEditors } from '../hooks/useEditors';
|
|
13
13
|
import { editorFor } from '../utils/render-resolution';
|
|
14
14
|
import { toDateInputValue } from '../utils/format-cell-value';
|
|
15
|
+
import { integrityErrorField } from '../utils/integrity-error';
|
|
15
16
|
import { missingRequired } from '../utils/required';
|
|
17
|
+
import { isWallClock, wallClockError } from '../utils/wall-clock';
|
|
18
|
+
import { changedFields } from '../utils/changed-fields';
|
|
16
19
|
import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, LoadingState, ErrorState } from '@everystack/ui';
|
|
17
20
|
import { ImageFieldInput } from '../components/ImageFieldInput';
|
|
18
21
|
import { AvatarFieldInput } from '../components/AvatarFieldInput';
|
|
@@ -49,6 +52,8 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
49
52
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
50
53
|
// Set synchronously so two presses in one tick (before a re-render) submit once.
|
|
51
54
|
const inFlight = useRef(false);
|
|
55
|
+
// The values as loaded. A save sends only the fields that differ from these.
|
|
56
|
+
const initialValues = useRef<Record<string, unknown>>({});
|
|
52
57
|
|
|
53
58
|
const fields = useMemo(() => {
|
|
54
59
|
if (!resourceConfig) return [];
|
|
@@ -80,6 +85,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
80
85
|
for (const field of fields) {
|
|
81
86
|
initial[field.name] = result[field.name];
|
|
82
87
|
}
|
|
88
|
+
initialValues.current = initial;
|
|
83
89
|
setFormData(initial);
|
|
84
90
|
} catch (err) {
|
|
85
91
|
setFetchError(err instanceof Error ? err : new Error(String(err)));
|
|
@@ -103,11 +109,17 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
103
109
|
|
|
104
110
|
const validate = useCallback((): Record<string, string> => {
|
|
105
111
|
const newErrors: Record<string, string> = {};
|
|
112
|
+
// Only a value this save would send is checked for a skipped wall time: a stored one the user
|
|
113
|
+
// did not touch must not block saving another field.
|
|
114
|
+
const changes = changedFields(fields, initialValues.current, formData);
|
|
106
115
|
for (const field of fields) {
|
|
107
116
|
const value = formData[field.name];
|
|
108
117
|
if (field.validation?.required && missingRequired(field, value)) {
|
|
109
118
|
newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
|
|
119
|
+
continue;
|
|
110
120
|
}
|
|
121
|
+
const skipped = field.name in changes ? wallClockError(field, value) : null;
|
|
122
|
+
if (skipped) newErrors[field.name] = skipped;
|
|
111
123
|
}
|
|
112
124
|
setErrors(newErrors);
|
|
113
125
|
return newErrors;
|
|
@@ -121,20 +133,38 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
121
133
|
return;
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
const changes = changedFields(fields, initialValues.current, formData);
|
|
137
|
+
// The API refuses an empty update, and there is nothing to save.
|
|
138
|
+
if (Object.keys(changes).length === 0) {
|
|
139
|
+
setSubmitError(null);
|
|
140
|
+
if (record) onSuccess?.(record);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
inFlight.current = true;
|
|
125
145
|
setSubmitting(true);
|
|
126
146
|
setSubmitError(null);
|
|
127
147
|
|
|
128
148
|
try {
|
|
129
|
-
const updated = await adapter.update(resource, id,
|
|
149
|
+
const updated = await adapter.update(resource, id, changes);
|
|
150
|
+
// What was saved is now what is stored: the next save diffs against it.
|
|
151
|
+
initialValues.current = { ...formData };
|
|
152
|
+
setRecord(prev => ({ ...prev, ...updated }));
|
|
130
153
|
onSuccess?.(updated);
|
|
131
154
|
} catch (err) {
|
|
132
|
-
|
|
155
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
156
|
+
const field = integrityErrorField(err, fields);
|
|
157
|
+
if (field) {
|
|
158
|
+
setErrors({ [field.name]: message });
|
|
159
|
+
requestScrollToFirstError(fields);
|
|
160
|
+
} else {
|
|
161
|
+
setSubmitError(message);
|
|
162
|
+
}
|
|
133
163
|
} finally {
|
|
134
164
|
inFlight.current = false;
|
|
135
165
|
setSubmitting(false);
|
|
136
166
|
}
|
|
137
|
-
}, [adapter, resource, id, formData, validate, onSuccess, fields, requestScrollToFirstError]);
|
|
167
|
+
}, [adapter, resource, id, formData, record, validate, onSuccess, fields, requestScrollToFirstError]);
|
|
138
168
|
|
|
139
169
|
if (!resourceConfig) {
|
|
140
170
|
return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
|
|
@@ -263,7 +293,11 @@ function EditFieldInput({ field, value, error, onChange, record, values }: EditF
|
|
|
263
293
|
case 'datetime':
|
|
264
294
|
return (
|
|
265
295
|
<FormField label={label} error={error} required={required}>
|
|
266
|
-
|
|
296
|
+
{isWallClock(field) ? (
|
|
297
|
+
<DateInput value={toDateInputValue(value)} onValueChange={onChange} timeZone={field.timezone} storage="wall" />
|
|
298
|
+
) : (
|
|
299
|
+
<DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" />
|
|
300
|
+
)}
|
|
267
301
|
</FormField>
|
|
268
302
|
);
|
|
269
303
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
|
|
9
|
-
import { View, Text, ScrollView } from 'react-native';
|
|
9
|
+
import { View, Text, ScrollView, Platform } from 'react-native';
|
|
10
10
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
11
|
import { useDataAdapter } from '../hooks/useAdminData';
|
|
12
12
|
import { LoadingState, ErrorState, Button, Card, Divider, RichContent } from '@everystack/ui';
|
|
@@ -23,7 +23,7 @@ import { wrapBareText } from '../components/CellText';
|
|
|
23
23
|
import { visibleRowActions } from '../utils/row-actions';
|
|
24
24
|
import { restoreAction, softDeleteColumn } from '../utils/standard-actions';
|
|
25
25
|
import { toCamelCase } from '../utils/naming';
|
|
26
|
-
import { formatDateOnly } from '../utils/format-cell-value';
|
|
26
|
+
import { formatDateOnly, formatDateTime } from '../utils/format-cell-value';
|
|
27
27
|
import type { FieldDefinition, DataRecord, ResourceConfig } from '../types';
|
|
28
28
|
import type { DataAdapter } from '../adapters/types';
|
|
29
29
|
|
|
@@ -194,7 +194,7 @@ export function ResourceShow({
|
|
|
194
194
|
|
|
195
195
|
{restore && (
|
|
196
196
|
<View testID="soft-delete-banner" className="flex-row items-center justify-between mx-4 mb-4 p-3 rounded-md bg-red-50">
|
|
197
|
-
<Text className="text-sm text-red-700">{`Deleted ${formatDateTime(deletedAt)}`}</Text>
|
|
197
|
+
<Text className="text-sm text-red-700">{`Deleted ${formatDateTime(deletedField(resourceConfig, deletedColumn), deletedAt)}`}</Text>
|
|
198
198
|
<RowActionButton action={restore} record={record} recordId={id} adapter={adapter} refetch={refetchQuietly} />
|
|
199
199
|
</View>
|
|
200
200
|
)}
|
|
@@ -283,8 +283,9 @@ function FieldRow({ field, value, record, labels }: FieldRowProps): React.ReactE
|
|
|
283
283
|
<TagsDisplay field={field} value={value} />
|
|
284
284
|
) : field.type === 'relation' && field.relation && value != null && value !== '' ? (
|
|
285
285
|
<RelationValue resource={field.relation.resource} value={value} labels={labels.get(field.name)} />
|
|
286
|
-
) : value != null && (field.format === 'html' || field.format === 'markdown') ? (
|
|
287
|
-
// Stored rich text is sanitised at render by ui RichContent (web-only
|
|
286
|
+
) : value != null && Platform.OS === 'web' && (field.format === 'html' || field.format === 'markdown') ? (
|
|
287
|
+
// Stored rich text is sanitised at render by ui RichContent (web-only: it emits a div,
|
|
288
|
+
// which native has no host component for). Native prints the value as plain text below.
|
|
288
289
|
<RichContent value={value} format={field.format} className="text-sm text-gray-900" />
|
|
289
290
|
) : (
|
|
290
291
|
<Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
|
|
@@ -293,9 +294,9 @@ function FieldRow({ field, value, record, labels }: FieldRowProps): React.ReactE
|
|
|
293
294
|
);
|
|
294
295
|
}
|
|
295
296
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
return
|
|
297
|
+
/** The soft-delete column's declared field, so a wall-clock one shows its digits; else a plain datetime. */
|
|
298
|
+
function deletedField(resource: ResourceConfig, column: string | undefined): FieldDefinition {
|
|
299
|
+
return resource.fields.find(f => f.name === column && f.type === 'datetime') ?? { name: column ?? '', type: 'datetime' };
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
function formatFieldValue(value: unknown, field: FieldDefinition): string {
|
|
@@ -307,7 +308,7 @@ function formatFieldValue(value: unknown, field: FieldDefinition): string {
|
|
|
307
308
|
case 'date':
|
|
308
309
|
return formatDateOnly(value);
|
|
309
310
|
case 'datetime':
|
|
310
|
-
return formatDateTime(value);
|
|
311
|
+
return formatDateTime(field, value);
|
|
311
312
|
case 'json':
|
|
312
313
|
return JSON.stringify(value, null, 2);
|
|
313
314
|
case 'url':
|
package/src/types.ts
CHANGED
|
@@ -124,7 +124,8 @@ export interface TagGroup {
|
|
|
124
124
|
options: Array<{ value: string; label?: string }>;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
/** Every field option except the wall-clock datetime pair (see {@link FieldDefinition}). */
|
|
128
|
+
export interface FieldDefinitionBase {
|
|
128
129
|
/** Field name (matches data key) */
|
|
129
130
|
name: string;
|
|
130
131
|
/** Display label */
|
|
@@ -216,6 +217,33 @@ export interface FieldDefinition {
|
|
|
216
217
|
format?: ContentFormat;
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
/**
|
|
221
|
+
* A wall-clock datetime: the column holds "this wall-clock time in one business zone", and every
|
|
222
|
+
* screen shows and edits it in that zone, whatever the viewer's zone. ONLY for a naive `timestamp`
|
|
223
|
+
* column (`field.timestamp()`): the API carries its stored digits labelled `Z`, and the admin
|
|
224
|
+
* writes digits labelled `Z` back. On a `timestamptz` column those digits are a UTC instant and
|
|
225
|
+
* this option is wrong; the admin cannot see the column type, so it cannot catch that.
|
|
226
|
+
*
|
|
227
|
+
* A time the zone skips (a spring-forward gap) is a field error on submit. A time the zone
|
|
228
|
+
* repeats (a fall-back overlap) is stored as typed.
|
|
229
|
+
*/
|
|
230
|
+
export interface WallClockDatetime {
|
|
231
|
+
type: 'datetime';
|
|
232
|
+
/** IANA zone name, e.g. 'America/Los_Angeles'. Checked at setup: an invalid name throws. */
|
|
233
|
+
timezone: string;
|
|
234
|
+
/** How the column holds the time. Only 'wall' (a naive `timestamp`) exists today. */
|
|
235
|
+
storage: 'wall';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Any other field: neither `timezone` nor `storage` is set. */
|
|
239
|
+
export interface NoTimeZone {
|
|
240
|
+
timezone?: undefined;
|
|
241
|
+
storage?: undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** `storage` is required whenever `timezone` is set, and both only on a `datetime` field. */
|
|
245
|
+
export type FieldDefinition = FieldDefinitionBase & (WallClockDatetime | NoTimeZone);
|
|
246
|
+
|
|
219
247
|
// =============================================================================
|
|
220
248
|
// Resource Configuration
|
|
221
249
|
// =============================================================================
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The update body for an edit form: only the fields whose value differs from
|
|
3
|
+
* the value loaded with the record. A field that was never loaded, or that the
|
|
4
|
+
* user did not touch, stays out, so a save never overwrites a value it did not
|
|
5
|
+
* mean to change.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FieldDefinition } from '../types';
|
|
9
|
+
|
|
10
|
+
/** null, undefined and '' are one "empty" value: an input shows all three as blank. */
|
|
11
|
+
function isEmpty(value: unknown): boolean {
|
|
12
|
+
return value == null || value === '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Structural equality for form values: primitives, Dates, arrays and plain objects. */
|
|
16
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
17
|
+
if (Object.is(a, b)) return true;
|
|
18
|
+
if (a instanceof Date || b instanceof Date) {
|
|
19
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
20
|
+
}
|
|
21
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
|
22
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
23
|
+
if (Array.isArray(a)) {
|
|
24
|
+
const other = b as unknown[];
|
|
25
|
+
return a.length === other.length && a.every((item, i) => deepEqual(item, other[i]));
|
|
26
|
+
}
|
|
27
|
+
const left = a as Record<string, unknown>;
|
|
28
|
+
const right = b as Record<string, unknown>;
|
|
29
|
+
const keys = Object.keys(left);
|
|
30
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
31
|
+
return keys.every(key => Object.prototype.hasOwnProperty.call(right, key) && deepEqual(left[key], right[key]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The fields of `current` that differ from `initial`. A field changed to an
|
|
36
|
+
* empty value is sent as null.
|
|
37
|
+
*/
|
|
38
|
+
export function changedFields(
|
|
39
|
+
fields: readonly FieldDefinition[],
|
|
40
|
+
initial: Record<string, unknown>,
|
|
41
|
+
current: Record<string, unknown>,
|
|
42
|
+
): Record<string, unknown> {
|
|
43
|
+
const body: Record<string, unknown> = {};
|
|
44
|
+
for (const { name } of fields) {
|
|
45
|
+
const before = initial[name];
|
|
46
|
+
const after = current[name];
|
|
47
|
+
if (isEmpty(after)) {
|
|
48
|
+
if (!isEmpty(before)) body[name] = null;
|
|
49
|
+
} else if (!deepEqual(before, after)) {
|
|
50
|
+
body[name] = after;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return body;
|
|
54
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { FieldDefinition } from '../types';
|
|
10
10
|
import { tagLabel, tagValues } from './tags';
|
|
11
|
+
import { formatWallClock, isWallClock } from './wall-clock';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* A date-only ISO string has no time-of-day; `new Date(str)` parses it as UTC midnight, and
|
|
@@ -50,14 +51,23 @@ export function toDateInputValue(value: unknown): string {
|
|
|
50
51
|
return value == null ? '' : String(value);
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A datetime for display, on the list and the show page alike. A wall-clock field shows its
|
|
56
|
+
* stored digits and its zone, whatever the viewer's zone; any other datetime is an instant shown
|
|
57
|
+
* in the viewer's local time.
|
|
58
|
+
*/
|
|
59
|
+
export function formatDateTime(field: FieldDefinition, value: unknown): string {
|
|
60
|
+
if (isWallClock(field)) return formatWallClock(field, value);
|
|
61
|
+
const date = new Date(String(value));
|
|
62
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
export function formatCellValue(field: FieldDefinition, value: unknown): string {
|
|
54
66
|
if (value === null || value === undefined) return '—';
|
|
55
67
|
|
|
56
68
|
switch (field.type) {
|
|
57
|
-
case 'datetime':
|
|
58
|
-
|
|
59
|
-
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
60
|
-
}
|
|
69
|
+
case 'datetime':
|
|
70
|
+
return formatDateTime(field, value);
|
|
61
71
|
case 'date':
|
|
62
72
|
return formatDateOnly(value);
|
|
63
73
|
case 'boolean':
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { EverystackApiError } from '../adapters/EverystackApiError';
|
|
2
|
+
import type { FieldDefinition } from '../types';
|
|
3
|
+
import { toCamelCase, toSnakeCase } from './naming';
|
|
4
|
+
|
|
5
|
+
const INTEGRITY_CODES = new Set(['23000', '23502', '23503', '23505', '23514', '23P01']);
|
|
6
|
+
|
|
7
|
+
/** Match only fields in this form; an exact name wins over a converted alias. */
|
|
8
|
+
export function integrityErrorField(error: unknown, fields: FieldDefinition[]): FieldDefinition | undefined {
|
|
9
|
+
if (!(error instanceof EverystackApiError)
|
|
10
|
+
|| !INTEGRITY_CODES.has(error.code ?? '')
|
|
11
|
+
|| typeof error.column !== 'string') return undefined;
|
|
12
|
+
|
|
13
|
+
const column = error.column;
|
|
14
|
+
return fields.find(field => field.name === column)
|
|
15
|
+
?? fields.find(field => field.name === toCamelCase(column) || field.name === toSnakeCase(column));
|
|
16
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wall-clock datetime fields (`type: 'datetime', timezone, storage: 'wall'`): a naive `timestamp`
|
|
3
|
+
* column holding "this wall-clock time in one business zone". The API carries the stored digits
|
|
4
|
+
* labelled `Z`; these helpers read those digits literally and never through the viewer's zone.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { assertTimeZone, partsToWall, wallTimeExists, wallToParts, zoneAbbreviation } from '@everystack/ui/wall-time';
|
|
8
|
+
import type { AdminConfig, FieldDefinition, FieldDefinitionBase, WallClockDatetime } from '../types';
|
|
9
|
+
|
|
10
|
+
export type WallClockField = FieldDefinitionBase & WallClockDatetime;
|
|
11
|
+
|
|
12
|
+
export function isWallClock(field: FieldDefinition): field is WallClockField {
|
|
13
|
+
return field.storage === 'wall';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A wall value as the string the input and the parser read. A native Date (a custom adapter's
|
|
18
|
+
* prerogative) carries the digits as its UTC fields, the same as the `Z`-labelled string.
|
|
19
|
+
*/
|
|
20
|
+
function wallString(value: unknown): unknown {
|
|
21
|
+
return value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The stored digits, formatted with no zone shift (a Date built from the digits as UTC, shown in
|
|
26
|
+
* UTC), then the business zone's abbreviation. An unparseable value passes through untouched.
|
|
27
|
+
*/
|
|
28
|
+
export function formatWallClock(field: WallClockField, value: unknown): string {
|
|
29
|
+
const parts = wallToParts(wallString(value));
|
|
30
|
+
if (!parts) return String(value);
|
|
31
|
+
const digits = new Date(partsToWall(parts)).toLocaleString(undefined, { timeZone: 'UTC' });
|
|
32
|
+
return `${digits} ${zoneAbbreviation(parts, field.timezone)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The field error for a wall value the business zone's clocks skip (a spring-forward gap), or
|
|
37
|
+
* null. An overlap time exists (twice) and is accepted as typed. An empty or unparseable value
|
|
38
|
+
* is not this check's concern.
|
|
39
|
+
*/
|
|
40
|
+
export function wallClockError(field: FieldDefinition, value: unknown): string | null {
|
|
41
|
+
if (!isWallClock(field)) return null;
|
|
42
|
+
const parts = wallToParts(wallString(value));
|
|
43
|
+
if (!parts || wallTimeExists(parts, field.timezone)) return null;
|
|
44
|
+
return `${field.label ?? field.name} does not exist in ${field.timezone}: clocks skip that time. Pick another time.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Checks every field's wall-clock options at setup, loudly: a `timezone` needs `storage: 'wall'`
|
|
49
|
+
* on a `datetime` field and must be a time zone this engine knows. Config can arrive untyped,
|
|
50
|
+
* so the TypeScript union is not enough on its own.
|
|
51
|
+
*/
|
|
52
|
+
export function validateAdminConfig(config: AdminConfig): void {
|
|
53
|
+
for (const resource of config.resources ?? []) {
|
|
54
|
+
for (const field of resource.fields ?? []) {
|
|
55
|
+
const raw = field as { name: string; type: unknown; timezone?: unknown; storage?: unknown };
|
|
56
|
+
if (raw.timezone === undefined && raw.storage === undefined) continue;
|
|
57
|
+
const where = `Admin resource "${resource.name}", field "${raw.name}"`;
|
|
58
|
+
if (raw.type !== 'datetime') {
|
|
59
|
+
throw new TypeError(`${where}: timezone and storage are only for a datetime field, not type ${JSON.stringify(raw.type)}`);
|
|
60
|
+
}
|
|
61
|
+
if (raw.storage !== 'wall') {
|
|
62
|
+
throw new TypeError(`${where}: a timezone needs storage: 'wall' (a naive timestamp column), got storage ${JSON.stringify(raw.storage)}`);
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
assertTimeZone(raw.timezone as string);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new RangeError(`${where}: ${err instanceof Error ? err.message : String(err)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|