@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 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.5",
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.2",
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.4",
121
- "@everystack/api": "0.4.5"
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 Error(result.error.message);
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 Error(result.error.message);
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 Error(result.error.message);
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 Error(result.error.message);
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
- setSubmitError(err instanceof Error ? err.message : String(err));
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
- <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" />
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, formData);
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
- setSubmitError(err instanceof Error ? err.message : String(err));
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
- <DateInput value={toDateInputValue(value)} onValueChange={onChange} granularity="datetime" />
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
- function formatDateTime(value: unknown): string {
297
- const date = new Date(String(value));
298
- return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
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
- export interface FieldDefinition {
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
- const date = new Date(String(value));
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
+ }