@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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { dayRangeBounds, instantToParts, parseDay, parseInstant } from '@everystack/ui/date-time';
|
|
2
|
+
import { assertIanaTimeZone } from '@everystack/ui/time-zone';
|
|
3
|
+
import type { DateRangeConfig, DateRangePreset } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface DateRange {
|
|
6
|
+
from: string;
|
|
7
|
+
to: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DateRangeFilter {
|
|
11
|
+
gte: string;
|
|
12
|
+
lt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type CompiledDateRange =
|
|
16
|
+
| { kind: 'ok'; filter: DateRangeFilter }
|
|
17
|
+
| { kind: 'invalid'; reason: string };
|
|
18
|
+
|
|
19
|
+
/** Inclusive calendar days become an inclusive start and an exclusive end in UTC. */
|
|
20
|
+
export function compileDateRange(range: DateRange, timeZone: string): CompiledDateRange {
|
|
21
|
+
try {
|
|
22
|
+
assertIanaTimeZone(timeZone);
|
|
23
|
+
const bounds = dayRangeBounds(range.from, range.to, timeZone);
|
|
24
|
+
return { kind: 'ok', filter: { gte: bounds.from, lt: bounds.until } };
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof RangeError) return { kind: 'invalid', reason: error.message };
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The adapter ANDs the two conditions on the configured field. */
|
|
32
|
+
export function rangeFilter(field: string, bounds: DateRangeFilter): Record<string, string[]> {
|
|
33
|
+
return { [field]: [`gte.${bounds.gte}`, `lt.${bounds.lt}`] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The caller supplies today in its display zone; arithmetic stays on calendar days. */
|
|
37
|
+
export function presetRange(
|
|
38
|
+
preset: DateRangePreset,
|
|
39
|
+
todayInZone: string,
|
|
40
|
+
weekStartsOn: NonNullable<DateRangeConfig['weekStartsOn']> = 1,
|
|
41
|
+
): DateRange {
|
|
42
|
+
const today = parseDay(todayInZone);
|
|
43
|
+
if (!today) throw new RangeError('Today must be a valid YYYY-MM-DD day.');
|
|
44
|
+
let from = today;
|
|
45
|
+
let to = today;
|
|
46
|
+
switch (preset) {
|
|
47
|
+
case 'today': break;
|
|
48
|
+
case 'yesterday': from = to = today.subtract({ days: 1 }); break;
|
|
49
|
+
case 'last7Days': from = today.subtract({ days: 6 }); break;
|
|
50
|
+
case 'last30Days': from = today.subtract({ days: 29 }); break;
|
|
51
|
+
case 'thisWeek':
|
|
52
|
+
from = today.subtract({ days: (today.dayOfWeek - weekStartsOn + 7) % 7 });
|
|
53
|
+
break;
|
|
54
|
+
case 'lastWeek': {
|
|
55
|
+
const start = today.subtract({ days: (today.dayOfWeek - weekStartsOn + 7) % 7 });
|
|
56
|
+
from = start.subtract({ days: 7 });
|
|
57
|
+
to = start.subtract({ days: 1 });
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case 'thisMonth': from = today.with({ day: 1 }); break;
|
|
61
|
+
case 'lastMonth':
|
|
62
|
+
to = today.with({ day: 1 }).subtract({ days: 1 });
|
|
63
|
+
from = to.with({ day: 1 });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return { from: from.toString(), to: to.toString() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type OwnedRange =
|
|
70
|
+
| { kind: 'absent' }
|
|
71
|
+
| ({ kind: 'pair' } & DateRangeFilter)
|
|
72
|
+
| { kind: 'invalid'; reason: string };
|
|
73
|
+
|
|
74
|
+
export type DateRangeConditions =
|
|
75
|
+
| { kind: 'ok'; values: string[] }
|
|
76
|
+
| { kind: 'invalid'; reason: string };
|
|
77
|
+
|
|
78
|
+
const STRICT_INSTANT = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/;
|
|
79
|
+
|
|
80
|
+
function isStrictInstant(value: string): boolean {
|
|
81
|
+
// A year-0000 instant is refused because PostgreSQL has no year 0.
|
|
82
|
+
if (!STRICT_INSTANT.test(value) || value.startsWith('0000-')) return false;
|
|
83
|
+
const instant = new Date(value);
|
|
84
|
+
return Number.isFinite(instant.getTime()) && instant.toISOString() === value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Validate all conditions on the owned field without a display zone or a clock. */
|
|
88
|
+
export function parseOwnedRange(values: readonly string[]): OwnedRange {
|
|
89
|
+
if (values.length === 0) return { kind: 'absent' };
|
|
90
|
+
const invalidPair: OwnedRange = {
|
|
91
|
+
kind: 'invalid', reason: 'A date range requires exactly one gte condition and one lt condition.',
|
|
92
|
+
};
|
|
93
|
+
if (values.length !== 2) return invalidPair;
|
|
94
|
+
let gte: string | undefined;
|
|
95
|
+
let lt: string | undefined;
|
|
96
|
+
for (const value of values) {
|
|
97
|
+
if (typeof value !== 'string') return invalidPair;
|
|
98
|
+
if (value.startsWith('gte.') && gte === undefined) gte = value.slice(4);
|
|
99
|
+
else if (value.startsWith('lt.') && lt === undefined) lt = value.slice(3);
|
|
100
|
+
else return invalidPair;
|
|
101
|
+
}
|
|
102
|
+
if (gte === undefined || lt === undefined) return invalidPair;
|
|
103
|
+
if (!isStrictInstant(gte) || !isStrictInstant(lt)) {
|
|
104
|
+
return { kind: 'invalid', reason: 'Date range bounds must be valid UTC instants in YYYY-MM-DDTHH:mm:ss.sssZ format.' };
|
|
105
|
+
}
|
|
106
|
+
if (gte >= lt) return { kind: 'invalid', reason: 'The gte bound must be before the lt bound.' };
|
|
107
|
+
return { kind: 'pair', gte, lt };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function instantDay(value: string, timeZone: string): string | null {
|
|
111
|
+
const parts = instantToParts(value, timeZone);
|
|
112
|
+
if (!parts) return null;
|
|
113
|
+
return `${String(parts.year).padStart(4, '0')}-${String(parts.month + 1).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Only exact calendar-day intervals project to days; invalid zones return null. */
|
|
117
|
+
export function instantsToDays(pair: DateRangeFilter, timeZone: string): DateRange | null {
|
|
118
|
+
try {
|
|
119
|
+
assertIanaTimeZone(timeZone);
|
|
120
|
+
const end = parseInstant(pair.lt)?.epochMilliseconds;
|
|
121
|
+
if (end === undefined) return null;
|
|
122
|
+
const from = instantDay(pair.gte, timeZone);
|
|
123
|
+
// The exclusive end can follow a skipped day. Inspect the last included instant.
|
|
124
|
+
const to = instantDay(new Date(end - 1).toISOString(), timeZone);
|
|
125
|
+
if (!from || !to || from > to) return null;
|
|
126
|
+
const range = { from, to };
|
|
127
|
+
const compiled = compileDateRange(range, timeZone);
|
|
128
|
+
if (compiled.kind !== 'ok' || compiled.filter.gte !== pair.gte || compiled.filter.lt !== pair.lt) return null;
|
|
129
|
+
return range;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error instanceof RangeError) return null;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Values for the field's repeated URL keys, using the existing calendar-day compiler. */
|
|
137
|
+
export function daysToConditions(field: string, range: DateRange, timeZone: string): DateRangeConditions {
|
|
138
|
+
const compiled = compileDateRange(range, timeZone);
|
|
139
|
+
if (compiled.kind !== 'ok') return compiled;
|
|
140
|
+
// At the calendar extremes the compiler can emit year 0000 or +010000; the URL format cannot
|
|
141
|
+
// carry those, so refuse here instead of writing a URL that parseOwnedRange would reject.
|
|
142
|
+
if (!isStrictInstant(compiled.filter.gte) || !isStrictInstant(compiled.filter.lt)) {
|
|
143
|
+
return { kind: 'invalid', reason: 'That date range is outside the supported years 0001 to 9999.' };
|
|
144
|
+
}
|
|
145
|
+
return { kind: 'ok', values: rangeFilter(field, compiled.filter)[field] };
|
|
146
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { parseInstant } from '@everystack/ui/date-time';
|
|
2
|
+
import type { FieldDefinition } from '../types';
|
|
3
|
+
import { toDateInputValue } from './format-cell-value';
|
|
4
|
+
|
|
5
|
+
/** Normalize only values the form will send. Untouched edit fields never enter this body. */
|
|
6
|
+
export function normalizeDateTimeSubmission(
|
|
7
|
+
fields: readonly FieldDefinition[],
|
|
8
|
+
values: Record<string, unknown>,
|
|
9
|
+
): Record<string, unknown> {
|
|
10
|
+
const body = { ...values };
|
|
11
|
+
for (const field of fields) {
|
|
12
|
+
if (field.type !== 'datetime' || field.storage === 'wall' || !(field.name in body)) continue;
|
|
13
|
+
const value = body[field.name];
|
|
14
|
+
if (value == null || value === '') {
|
|
15
|
+
body[field.name] = null;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const raw = toDateInputValue(value);
|
|
19
|
+
const instant = parseInstant(raw);
|
|
20
|
+
if (!instant) throw new RangeError(`${field.label ?? field.name} is not a valid date and time.`);
|
|
21
|
+
// A picker already emits Z. Keep its precision; Temporal retains nanoseconds for other inputs.
|
|
22
|
+
body[field.name] = raw.endsWith('Z') ? raw.replace(' ', 'T') : instant.toString();
|
|
23
|
+
}
|
|
24
|
+
return body;
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FieldDefinition } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Whether this field can send a list order to the adapter. */
|
|
4
|
+
export function isFieldSortable(field: FieldDefinition): boolean {
|
|
5
|
+
if (field.virtual === true) return false;
|
|
6
|
+
if (field.sortable === false) return false;
|
|
7
|
+
if (field.sortable === true) return true;
|
|
8
|
+
if (field.format === 'html' || field.format === 'markdown') return false;
|
|
9
|
+
switch (field.type) {
|
|
10
|
+
case 'json':
|
|
11
|
+
case 'tags':
|
|
12
|
+
case 'file':
|
|
13
|
+
case 'image':
|
|
14
|
+
case 'relation':
|
|
15
|
+
return false;
|
|
16
|
+
default:
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* through untouched — never hide data behind "Invalid Date".
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { parseInstant } from '@everystack/ui/date-time';
|
|
9
10
|
import type { FieldDefinition } from '../types';
|
|
10
11
|
import { tagLabel, tagValues } from './tags';
|
|
12
|
+
import { formatWallClock, isWallClock } from './wall-clock';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* A date-only ISO string has no time-of-day; `new Date(str)` parses it as UTC midnight, and
|
|
@@ -50,14 +52,35 @@ export function toDateInputValue(value: unknown): string {
|
|
|
50
52
|
return value == null ? '' : String(value);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
/** A wall field retains literal digits; instants require an explicitly resolved display zone. */
|
|
56
|
+
export function formatDateTime(field: FieldDefinition, value: unknown, timeZone?: string): string {
|
|
57
|
+
if (isWallClock(field)) return formatWallClock(field, value);
|
|
58
|
+
const instant = parseInstant(value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : value);
|
|
59
|
+
if (!instant) return String(value);
|
|
60
|
+
if (!timeZone) return 'Resolving time zone…';
|
|
61
|
+
const display = new Intl.DateTimeFormat('en-US', {
|
|
62
|
+
timeZone, year: 'numeric', month: 'short', day: 'numeric',
|
|
63
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZoneName: 'short',
|
|
64
|
+
}).format(new Date(instant.epochMilliseconds));
|
|
65
|
+
// The short name (PDT, GMT+9) sits beside the stamp; the zone's full name is not repeated in every cell.
|
|
66
|
+
return display;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Exact stored wall carrier or canonical UTC instant, including sub-millisecond precision. */
|
|
70
|
+
export function dateTimeRawValue(field: FieldDefinition, value: unknown): string | null {
|
|
71
|
+
if (value == null) return null;
|
|
72
|
+
const raw = value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : String(value);
|
|
73
|
+
if (isWallClock(field)) return `Stored digits: ${raw}`;
|
|
74
|
+
const instant = parseInstant(raw);
|
|
75
|
+
return instant ? `UTC: ${instant.toString()}` : `Raw value: ${raw}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatCellValue(field: FieldDefinition, value: unknown, timeZone?: string): string {
|
|
54
79
|
if (value === null || value === undefined) return '—';
|
|
55
80
|
|
|
56
81
|
switch (field.type) {
|
|
57
|
-
case 'datetime':
|
|
58
|
-
|
|
59
|
-
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
60
|
-
}
|
|
82
|
+
case 'datetime':
|
|
83
|
+
return formatDateTime(field, value, timeZone);
|
|
61
84
|
case 'date':
|
|
62
85
|
return formatDateOnly(value);
|
|
63
86
|
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
|
+
}
|
package/src/utils/list-query.ts
CHANGED
|
@@ -125,15 +125,15 @@ export function mergeFilters(
|
|
|
125
125
|
a?: Record<string, unknown>,
|
|
126
126
|
b?: Record<string, unknown>,
|
|
127
127
|
): Record<string, unknown> | undefined {
|
|
128
|
-
const merged
|
|
128
|
+
const merged = new Map<string, unknown>();
|
|
129
129
|
for (const source of [a, b]) {
|
|
130
130
|
if (!source) continue;
|
|
131
131
|
for (const [key, value] of Object.entries(source)) {
|
|
132
132
|
if (value == null) continue;
|
|
133
|
-
merged
|
|
133
|
+
merged.set(key, merged.has(key) ? [...toArray(merged.get(key)), ...toArray(value)] : value);
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
-
return
|
|
136
|
+
return merged.size ? Object.fromEntries(merged) : undefined;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
const toArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [value]);
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ResourceConfig.table` and `ResourceConfig.where`: which table a resource reads and writes, and
|
|
3
|
+
* which slice of it. Two resources may share a table, each with its own `where` (single-table
|
|
4
|
+
* inheritance). withResourceQuery() applies both once, on the adapter the providers hand to every
|
|
5
|
+
* screen; everything above the adapter (URL, nav, relations, scopes) keeps the resource name.
|
|
6
|
+
*
|
|
7
|
+
* `where` is a view filter and a forced default. It is not authorization. Any client with the same
|
|
8
|
+
* token can address any row through the API. For a boundary, use a role and an RLS policy (or a
|
|
9
|
+
* view).
|
|
10
|
+
*
|
|
11
|
+
* The name is SQL's: the option is an equality WHERE on the resource's table. Rails' `where(type:
|
|
12
|
+
* 'Adventure').new` presets the same attribute on a new record, and so does this on a create.
|
|
13
|
+
*
|
|
14
|
+
* It keeps staff from mistakes (a wrong type typed into a form, an id typed into the wrong URL):
|
|
15
|
+
* every adapter call for a resource with `where` is narrowed to its slice, and every write carries
|
|
16
|
+
* the `where` values.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DataAdapter, SubscriptionEvent } from '../adapters/types';
|
|
20
|
+
import type {
|
|
21
|
+
AdminConfig,
|
|
22
|
+
AggregateParams,
|
|
23
|
+
AggregateResult,
|
|
24
|
+
DataRecord,
|
|
25
|
+
FieldDefinition,
|
|
26
|
+
ListParams,
|
|
27
|
+
ListResult,
|
|
28
|
+
ResourceConfig,
|
|
29
|
+
WhereValues,
|
|
30
|
+
} from '../types';
|
|
31
|
+
|
|
32
|
+
type Filter = Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The caller's filter with `where` ANDed in: each `where` key is replaced by an exact match and
|
|
36
|
+
* moved last, so `where` wins over a user or tab filter on the same key. `eq.` comes first, so the
|
|
37
|
+
* rest of the value is matched literally, operator-like text, dots, commas and parentheses included.
|
|
38
|
+
*/
|
|
39
|
+
export function whereFilter(filter: Filter | undefined, where: WhereValues): Filter {
|
|
40
|
+
const out: Filter = { ...filter };
|
|
41
|
+
for (const [key, value] of Object.entries(where)) {
|
|
42
|
+
delete out[key];
|
|
43
|
+
out[key] = `eq.${String(value)}`;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the wrapper does for one resource: the table it names, its `where`, if any, and its id column. */
|
|
49
|
+
interface ResourceQuery {
|
|
50
|
+
table: string;
|
|
51
|
+
where?: WhereValues;
|
|
52
|
+
primaryKey: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Keyed by RESOURCE name, never by table: two resources on one table keep their own `where`, and a
|
|
57
|
+
* resource with no entry here passes through as is, even when its name is another resource's table.
|
|
58
|
+
*/
|
|
59
|
+
function queriesOf(config: AdminConfig): Map<string, ResourceQuery> {
|
|
60
|
+
const queries = new Map<string, ResourceQuery>();
|
|
61
|
+
for (const resource of config.resources ?? []) {
|
|
62
|
+
const table = resource.table ?? resource.name;
|
|
63
|
+
const where = resource.where && Object.keys(resource.where).length > 0 ? resource.where : undefined;
|
|
64
|
+
if (table !== resource.name || where) queries.set(resource.name, { table, where, primaryKey: resource.primaryKey ?? 'id' });
|
|
65
|
+
}
|
|
66
|
+
return queries;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A stable text form of everything withResourceQuery() reads from the config. Two configs with the
|
|
71
|
+
* same signature give wrappers that behave the same, so a caller can reuse one wrapper for both.
|
|
72
|
+
*/
|
|
73
|
+
export function resourceQuerySignature(config: AdminConfig): string {
|
|
74
|
+
return JSON.stringify([...queriesOf(config)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NUMERIC_TEXT = /^-?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* True when a returned value matches the `where` value the way the API returns it. A number
|
|
81
|
+
* compares numerically, so numeric(n,2) text "2.00" matches 2. A boolean accepts true or "true" or
|
|
82
|
+
* "t" (false or "false" or "f"). Anything else is exact text: case-sensitive, no trimming.
|
|
83
|
+
*/
|
|
84
|
+
function sameAsWhere(value: unknown, expected: WhereValues[string]): boolean {
|
|
85
|
+
if (typeof expected === 'number') {
|
|
86
|
+
if (typeof value === 'number') return value === expected;
|
|
87
|
+
if (typeof value === 'string' && NUMERIC_TEXT.test(value)) return Number(value) === expected;
|
|
88
|
+
}
|
|
89
|
+
if (typeof expected === 'boolean') {
|
|
90
|
+
return expected ? value === true || value === 'true' || value === 't' : value === false || value === 'false' || value === 'f';
|
|
91
|
+
}
|
|
92
|
+
return String(value) === String(expected);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `{ type: "Adventure", tier: 2 }`, for an error message. */
|
|
96
|
+
function describeWhere(where: WhereValues): string {
|
|
97
|
+
return `{ ${Object.entries(where).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A write body that names a DIFFERENT value for a `where` column is not silently taken as if it
|
|
102
|
+
* applied: the `where` value is written (the body's is ignored) and, outside production, one warning
|
|
103
|
+
* names every column that disagreed. The admin's own forms never send these columns, so this fires
|
|
104
|
+
* for an adapter or custom code that passes the full record back.
|
|
105
|
+
*/
|
|
106
|
+
function warnOverridden(resource: string, data: unknown, where: WhereValues): void {
|
|
107
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') return;
|
|
108
|
+
if (data === null || typeof data !== 'object') return;
|
|
109
|
+
const body = data as Record<string, unknown>;
|
|
110
|
+
const clashes = Object.entries(where).filter(([key, expected]) =>
|
|
111
|
+
Object.prototype.hasOwnProperty.call(body, key) && body[key] !== undefined && !sameAsWhere(body[key], expected));
|
|
112
|
+
if (clashes.length === 0) return;
|
|
113
|
+
const named = clashes.map(([key, expected]) => `${key}: the body has ${JSON.stringify(body[key])}, where forces ${JSON.stringify(expected)}`).join('; ');
|
|
114
|
+
// eslint-disable-next-line no-console
|
|
115
|
+
console.warn(`Resource "${resource}": the write names a value that disagrees with its where, so the where value was written and the body's was ignored (${named}).`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Throws when a row the adapter returned is outside the resource's `where`. Only keys present in
|
|
120
|
+
* the row are compared: a hidden column or a narrow select leaves a key out, and that is no error.
|
|
121
|
+
* The row is returned as is. `saved` says the write already happened.
|
|
122
|
+
*/
|
|
123
|
+
function checkRow<T>(resource: string, q: ResourceQuery, where: WhereValues, row: T, saved: boolean): T {
|
|
124
|
+
if (row === null || typeof row !== 'object') return row;
|
|
125
|
+
const record = row as Record<string, unknown>;
|
|
126
|
+
for (const [key, expected] of Object.entries(where)) {
|
|
127
|
+
if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) continue;
|
|
128
|
+
if (sameAsWhere(record[key], expected)) continue;
|
|
129
|
+
const id = record[q.primaryKey];
|
|
130
|
+
const which = id === undefined || id === null ? '' : ` (id ${String(id)})`;
|
|
131
|
+
const rest = `${key} ${JSON.stringify(record[key])}, outside its where ${describeWhere(where)}.`;
|
|
132
|
+
throw new Error(saved
|
|
133
|
+
? `Resource "${resource}": the row was saved${which} but has ${rest}`
|
|
134
|
+
: `Resource "${resource}": the row${which} has ${rest}`);
|
|
135
|
+
}
|
|
136
|
+
return row;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The adapter, with every call for a resource that sets `table` or `where` rewritten on the way in.
|
|
141
|
+
* The resource name becomes its table for fetchList, aggregate, fetchOne, create, update, delete and
|
|
142
|
+
* subscribe. `where` narrows the call to its slice: lists, counts and aggregates get it as a filter;
|
|
143
|
+
* fetchOne, update and delete get it as `params.filter` beside the id, in the one request they send;
|
|
144
|
+
* create and update bodies get the `where` values over the form's. The row that create, update or
|
|
145
|
+
* fetchOne returns is checked against `where`, and one outside it throws, naming it. subscribe
|
|
146
|
+
* drops events whose record is outside `where` and delivers the rest under the resource name.
|
|
147
|
+
* Everything else (rpc,
|
|
148
|
+
* supportsSearch, an adapter's own members) passes through, called on the adapter itself. With no
|
|
149
|
+
* `table` or `where` anywhere, the adapter is returned as is.
|
|
150
|
+
*/
|
|
151
|
+
export function withResourceQuery(adapter: DataAdapter, config: AdminConfig): DataAdapter {
|
|
152
|
+
const queries = queriesOf(config);
|
|
153
|
+
if (queries.size === 0) return adapter;
|
|
154
|
+
|
|
155
|
+
type FetchOneParams = Parameters<DataAdapter['fetchOne']>[2];
|
|
156
|
+
type UpdateParams = Parameters<DataAdapter['update']>[3];
|
|
157
|
+
type DeleteParams = Parameters<DataAdapter['delete']>[2];
|
|
158
|
+
|
|
159
|
+
const overrides: Partial<DataAdapter> = {
|
|
160
|
+
fetchList<T extends DataRecord = DataRecord>(resource: string, params: ListParams): Promise<ListResult<T>> {
|
|
161
|
+
const q = queries.get(resource);
|
|
162
|
+
if (!q) return adapter.fetchList<T>(resource, params);
|
|
163
|
+
return adapter.fetchList<T>(q.table, q.where ? { ...params, filter: whereFilter(params.filter, q.where) } : params);
|
|
164
|
+
},
|
|
165
|
+
aggregate(resource: string, params: AggregateParams): Promise<AggregateResult> {
|
|
166
|
+
const q = queries.get(resource);
|
|
167
|
+
if (!q) return adapter.aggregate!(resource, params);
|
|
168
|
+
return adapter.aggregate!(q.table, q.where ? { ...params, filter: whereFilter(params.filter, q.where) } : params);
|
|
169
|
+
},
|
|
170
|
+
fetchOne<T extends DataRecord = DataRecord>(resource: string, id: string | number, ...rest: [params?: FetchOneParams]): Promise<T> {
|
|
171
|
+
const q = queries.get(resource);
|
|
172
|
+
if (!q) return adapter.fetchOne<T>(resource, id, ...rest);
|
|
173
|
+
if (!q.where) return adapter.fetchOne<T>(q.table, id, ...rest);
|
|
174
|
+
const [params] = rest;
|
|
175
|
+
const where = q.where;
|
|
176
|
+
return adapter
|
|
177
|
+
.fetchOne<T>(q.table, id, { ...params, filter: whereFilter(params?.filter, where) })
|
|
178
|
+
.then(row => checkRow(resource, q, where, row, false));
|
|
179
|
+
},
|
|
180
|
+
create<T extends DataRecord = DataRecord>(resource: string, data: Omit<T, 'id'>): Promise<T> {
|
|
181
|
+
const q = queries.get(resource);
|
|
182
|
+
if (!q) return adapter.create<T>(resource, data);
|
|
183
|
+
if (!q.where) return adapter.create<T>(q.table, data);
|
|
184
|
+
const where = q.where;
|
|
185
|
+
warnOverridden(resource, data, where);
|
|
186
|
+
return adapter.create<T>(q.table, { ...data, ...where }).then(row => checkRow(resource, q, where, row, true));
|
|
187
|
+
},
|
|
188
|
+
update<T extends DataRecord = DataRecord>(
|
|
189
|
+
resource: string,
|
|
190
|
+
id: string | number,
|
|
191
|
+
data: Partial<T>,
|
|
192
|
+
...rest: [params?: UpdateParams]
|
|
193
|
+
): Promise<T> {
|
|
194
|
+
const q = queries.get(resource);
|
|
195
|
+
if (!q) return adapter.update<T>(resource, id, data, ...rest);
|
|
196
|
+
if (!q.where) return adapter.update<T>(q.table, id, data, ...rest);
|
|
197
|
+
const [params] = rest;
|
|
198
|
+
const where = q.where;
|
|
199
|
+
warnOverridden(resource, data, where);
|
|
200
|
+
return adapter
|
|
201
|
+
.update<T>(q.table, id, { ...data, ...where } as Partial<T>, { ...params, filter: whereFilter(params?.filter, where) })
|
|
202
|
+
.then(row => checkRow(resource, q, where, row, true));
|
|
203
|
+
},
|
|
204
|
+
delete(resource: string, id: string | number, ...rest: [params?: DeleteParams]): Promise<void> {
|
|
205
|
+
const q = queries.get(resource);
|
|
206
|
+
if (!q) return adapter.delete(resource, id, ...rest);
|
|
207
|
+
if (!q.where) return adapter.delete(q.table, id, ...rest);
|
|
208
|
+
const [params] = rest;
|
|
209
|
+
return adapter.delete(q.table, id, { ...params, filter: whereFilter(params?.filter, q.where) });
|
|
210
|
+
},
|
|
211
|
+
subscribe<T extends DataRecord = DataRecord>(resource: string, callback: (event: SubscriptionEvent<T>) => void): () => void {
|
|
212
|
+
const q = queries.get(resource);
|
|
213
|
+
if (!q) return adapter.subscribe!<T>(resource, callback);
|
|
214
|
+
const where = q.where;
|
|
215
|
+
return adapter.subscribe!<T>(q.table, event => {
|
|
216
|
+
// An update that moves a row out of the slice still reaches the list, by its previousRecord.
|
|
217
|
+
if (where && !inSlice(event.record, where) && !(isObject(event.previousRecord) && inSlice(event.previousRecord, where))) return;
|
|
218
|
+
callback({ ...event, resource });
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// One bound function per original, so a member read twice is the same function (stable deps).
|
|
224
|
+
const bound = new WeakMap<object, unknown>();
|
|
225
|
+
return new Proxy(adapter, {
|
|
226
|
+
get(target, prop) {
|
|
227
|
+
const value: unknown = Reflect.get(target, prop, target);
|
|
228
|
+
if (typeof value !== 'function') return value;
|
|
229
|
+
if (Object.prototype.hasOwnProperty.call(overrides, prop)) return overrides[prop as keyof DataAdapter];
|
|
230
|
+
let fn = bound.get(value);
|
|
231
|
+
if (!fn) {
|
|
232
|
+
fn = (value as (...args: unknown[]) => unknown).bind(target);
|
|
233
|
+
bound.set(value, fn);
|
|
234
|
+
}
|
|
235
|
+
return fn;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* False only when the record has a `where` key with another value. No record, or a record without
|
|
242
|
+
* the key, passes: the slice cannot be told, and dropping it would hide a change.
|
|
243
|
+
*/
|
|
244
|
+
function inSlice(record: unknown, where: WhereValues): boolean {
|
|
245
|
+
if (!isObject(record)) return true;
|
|
246
|
+
const row = record as Record<string, unknown>;
|
|
247
|
+
return Object.entries(where).every(([key, expected]) =>
|
|
248
|
+
!Object.prototype.hasOwnProperty.call(row, key) || row[key] === undefined || sameAsWhere(row[key], expected));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isObject(value: unknown): value is object {
|
|
252
|
+
return value !== null && typeof value === 'object';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The form's fields without the `where` columns: Create and Edit render no input for them. */
|
|
256
|
+
export function withoutWhere(fields: FieldDefinition[], resource: ResourceConfig): FieldDefinition[] {
|
|
257
|
+
const where = resource.where;
|
|
258
|
+
return where ? fields.filter(f => !Object.prototype.hasOwnProperty.call(where, f.name)) : fields;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Checks every resource's `table` at setup, loudly: absent, or a non-empty string. Two resources may share one. */
|
|
262
|
+
export function validateTable(config: AdminConfig): void {
|
|
263
|
+
for (const resource of config.resources ?? []) {
|
|
264
|
+
const table: unknown = resource.table;
|
|
265
|
+
if (table === undefined) continue;
|
|
266
|
+
if (typeof table !== 'string' || table.trim() === '') {
|
|
267
|
+
throw new TypeError(`Admin resource "${resource.name}", table: must be a non-empty string, got ${JSON.stringify(table)}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Checks every resource's `where` at setup, loudly: an object whose keys each name a field of the
|
|
274
|
+
* resource and whose values are a string, a finite number or a boolean. Config can arrive untyped.
|
|
275
|
+
*/
|
|
276
|
+
export function validateWhere(config: AdminConfig): void {
|
|
277
|
+
for (const resource of config.resources ?? []) {
|
|
278
|
+
const where: unknown = resource.where;
|
|
279
|
+
if (where === undefined) continue;
|
|
280
|
+
const at = `Admin resource "${resource.name}", where`;
|
|
281
|
+
if (where === null || typeof where !== 'object' || Array.isArray(where)) {
|
|
282
|
+
throw new TypeError(`${at}: must be an object of column → value, got ${JSON.stringify(where)}`);
|
|
283
|
+
}
|
|
284
|
+
const names = new Set((resource.fields ?? []).map(f => f.name));
|
|
285
|
+
for (const [key, value] of Object.entries(where)) {
|
|
286
|
+
if (!names.has(key)) {
|
|
287
|
+
throw new TypeError(`${at} "${key}": the resource has no field "${key}" (spell it like the field name)`);
|
|
288
|
+
}
|
|
289
|
+
// Both of these would look fine and do nothing: `where` overwrites the pick, and the tab shows the same slice.
|
|
290
|
+
if ((resource.fields ?? []).some(f => f.name === key && f.inlineEdit)) {
|
|
291
|
+
throw new TypeError(`${at} "${key}": the column is in where, so inlineEdit on it would be overwritten by where. Remove inlineEdit.`);
|
|
292
|
+
}
|
|
293
|
+
const scope = (resource.scopes ?? []).find(s => s.filter && Object.prototype.hasOwnProperty.call(s.filter, key));
|
|
294
|
+
if (scope) {
|
|
295
|
+
throw new TypeError(`${at} "${key}": scope "${scope.id}" filters the where column, so where overrides it. Remove that filter or the where entry.`);
|
|
296
|
+
}
|
|
297
|
+
const ok = typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value));
|
|
298
|
+
if (!ok) {
|
|
299
|
+
throw new TypeError(`${at} "${key}": the value must be a string, a finite number or a boolean, got ${typeof value === 'number' || value === undefined ? String(value) : JSON.stringify(value)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup is where a bad config fails: loudly, naming the resource and the field or key, not later on
|
|
3
|
+
* a screen that happens to read it. AdminConfigProvider runs this once per config.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { assertIanaTimeZone } from '@everystack/ui/time-zone';
|
|
7
|
+
import type { AdminConfig } from '../types';
|
|
8
|
+
import { validateTable, validateWhere } from './resource-query';
|
|
9
|
+
import { validateWallClockFields } from './wall-clock';
|
|
10
|
+
import { RESERVED_PARAMS } from './view-filters';
|
|
11
|
+
import { isDashboardDimension } from './date-filter-fields';
|
|
12
|
+
|
|
13
|
+
export function validateAdminConfig(config: AdminConfig): void {
|
|
14
|
+
validateResourceNames(config);
|
|
15
|
+
validateWallClockFields(config);
|
|
16
|
+
validateDateConfig(config);
|
|
17
|
+
validateTable(config);
|
|
18
|
+
validateWhere(config);
|
|
19
|
+
validateAssociations(config);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An association names its related rows by exactly one of `resource` (read through that resource's
|
|
24
|
+
* `table` and `where`) or `table` (the real table, as is). Both, or neither, would count the wrong rows.
|
|
25
|
+
*/
|
|
26
|
+
function validateAssociations(config: AdminConfig): void {
|
|
27
|
+
for (const resource of config.resources ?? []) {
|
|
28
|
+
for (const association of resource.associations ?? []) {
|
|
29
|
+
const at = `Admin resource "${resource.name}", association "${association.label}"`;
|
|
30
|
+
const named = (['resource', 'table'] as const).filter(key => association[key] !== undefined);
|
|
31
|
+
if (named.length !== 1) {
|
|
32
|
+
throw new TypeError(`${at}: set exactly one of resource or table, got ${named.length === 0 ? 'neither' : 'both'}`);
|
|
33
|
+
}
|
|
34
|
+
const value: unknown = association[named[0]];
|
|
35
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
36
|
+
throw new TypeError(`${at}: ${named[0]} must be a non-empty string, got ${JSON.stringify(value)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The resource name keys the URL, nav, relations, scopes and `where`, so it must be unique. A table may be shared. */
|
|
43
|
+
function validateResourceNames(config: AdminConfig): void {
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
for (const resource of config.resources ?? []) {
|
|
46
|
+
if (seen.has(resource.name)) {
|
|
47
|
+
throw new TypeError(
|
|
48
|
+
`Admin resource "${resource.name}": the name is used by more than one resource. Give each its own name; two resources may share a table.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
seen.add(resource.name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Instant display zones and future list ranges fail at setup, including untyped config. */
|
|
56
|
+
function validateDateConfig(config: AdminConfig): void {
|
|
57
|
+
if (config.timeZone !== undefined) {
|
|
58
|
+
try {
|
|
59
|
+
assertIanaTimeZone(config.timeZone);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new RangeError(`Admin timeZone: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const resource of config.resources ?? []) {
|
|
65
|
+
const range = resource.dateRange;
|
|
66
|
+
if (range === undefined) continue;
|
|
67
|
+
const at = `Admin resource "${resource.name}", dateRange`;
|
|
68
|
+
if (range === null || typeof range !== 'object' || typeof range.field !== 'string' || range.field.trim() === '') {
|
|
69
|
+
throw new TypeError(`${at}: field must name a declared instant datetime field`);
|
|
70
|
+
}
|
|
71
|
+
if (RESERVED_PARAMS.has(range.field)) {
|
|
72
|
+
throw new TypeError(`${at} field "${range.field}": the URL reserves this name for list view state, so it cannot carry a range`);
|
|
73
|
+
}
|
|
74
|
+
const field = resource.fields?.find(candidate => candidate.name === range.field);
|
|
75
|
+
if (!field || field.type !== 'datetime' || field.virtual || field.storage === 'wall') {
|
|
76
|
+
throw new TypeError(`${at} field "${range.field}": must name a non-virtual instant datetime field (storage omitted or 'utc')`);
|
|
77
|
+
}
|
|
78
|
+
if (isDashboardDimension(resource, range.field)) {
|
|
79
|
+
throw new TypeError(`${at} field "${range.field}": a dashboard dimension owns its URL values and cannot also have a date range row`);
|
|
80
|
+
}
|
|
81
|
+
if (range.weekStartsOn !== undefined && (!Number.isInteger(range.weekStartsOn) || range.weekStartsOn < 0 || range.weekStartsOn > 6)) {
|
|
82
|
+
throw new TypeError(`${at}: weekStartsOn must be an integer from 0 to 6`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|