@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
|
@@ -16,11 +16,16 @@ const DEFAULT_PER_PAGE = 20;
|
|
|
16
16
|
/** Sizes the page bar always offers; the resource's own size is added when missing. */
|
|
17
17
|
const PAGE_SIZES = [20, 50, 100];
|
|
18
18
|
|
|
19
|
-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
19
|
+
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
20
20
|
import { View, Text } from 'react-native';
|
|
21
21
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
22
22
|
import { useAdminData, useDataAdapter } from '../hooks/useAdminData';
|
|
23
23
|
import { useListQuery } from '../hooks/useListQuery';
|
|
24
|
+
import { useViewState, valuesOf, ViewStateContext } from '../hooks/useViewState';
|
|
25
|
+
import { useDateRangeFilters } from '../hooks/useDateRangeFilters';
|
|
26
|
+
import { DateFilterBar, type DateFilterRow } from '../components/DateFilterBar';
|
|
27
|
+
import { dateFilterFields } from '../utils/date-filter-fields';
|
|
28
|
+
import type { DateRangePreset } from '../types';
|
|
24
29
|
import { useAdminNavigation } from '../hooks/useAdminNavigation';
|
|
25
30
|
import { adminHref } from '../utils/admin-routing';
|
|
26
31
|
import { buildListParams, mergeFilters, nextSort, normalizeSearchText, pageForSize, parseListQuery, resolvePageSize } from '../utils/list-query';
|
|
@@ -28,7 +33,9 @@ import { resolveScope } from '../utils/scopes';
|
|
|
28
33
|
import { useScopeCounts } from '../hooks/useScopeCounts';
|
|
29
34
|
import { RESERVED_PARAMS } from '../utils/view-filters';
|
|
30
35
|
import { DataTable, FilterBar, PaginationBar, ScopeTabs, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
|
|
36
|
+
import { DateTimeValue } from '../components/DateTimeValue';
|
|
31
37
|
import { formatCellValue } from '../utils/format-cell-value';
|
|
38
|
+
import { isFieldSortable } from '../utils/field-sortable';
|
|
32
39
|
import { cellRenderKind } from '../utils/render-resolution';
|
|
33
40
|
import { estimateActionsWidth, visibleRowActions } from '../utils/row-actions';
|
|
34
41
|
import { standardRowActions } from '../utils/standard-actions';
|
|
@@ -38,6 +45,7 @@ import { InlineEnumCell } from '../components/InlineEnumCell';
|
|
|
38
45
|
import { wrapBareText } from '../components/CellText';
|
|
39
46
|
import { RelationValue } from '../components/RelationValue';
|
|
40
47
|
import { useRelationLabels, type RelationLabels } from '../hooks/useRelationLabels';
|
|
48
|
+
import { isCannotSortError } from '../adapters/CannotSortError';
|
|
41
49
|
import type { DataAdapter } from '../adapters/types';
|
|
42
50
|
import type { ResourceConfig, FieldDefinition, DataRecord, RowActionConfig } from '../types';
|
|
43
51
|
|
|
@@ -57,10 +65,28 @@ export interface ResourceListProps {
|
|
|
57
65
|
baseFilter?: Record<string, unknown>;
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/** What a row the user added offers: the pinned row's own presets are its config. */
|
|
69
|
+
const ADDED_ROW_PRESETS: DateRangePreset[] = ['today', 'last7Days', 'thisMonth'];
|
|
70
|
+
|
|
60
71
|
export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress, baseFilter }: ResourceListProps): React.ReactElement {
|
|
61
72
|
const config = useAdminConfig();
|
|
62
73
|
const adapter = useDataAdapter();
|
|
63
74
|
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
75
|
+
const view = useViewState();
|
|
76
|
+
const { setParams } = view;
|
|
77
|
+
const hasViewState = useContext(ViewStateContext) !== null;
|
|
78
|
+
// Without a URL provider there is nowhere to keep the bounds: no rows, filter, or block.
|
|
79
|
+
const rangeConfig = hasViewState ? resourceConfig?.dateRange : undefined;
|
|
80
|
+
const filterable = hasViewState && resourceConfig ? dateFilterFields(resourceConfig) : [];
|
|
81
|
+
const [addedState, setAddedState] = useState<{ resource: string; fields: string[] }>(() => ({ resource, fields: [] }));
|
|
82
|
+
const added = addedState.resource === resource ? addedState.fields : [];
|
|
83
|
+
// The pinned row, then rows the user added, then any datetime column whose URL already holds values.
|
|
84
|
+
const activeFields = [...new Set([
|
|
85
|
+
...(rangeConfig ? [rangeConfig.field] : []),
|
|
86
|
+
...added,
|
|
87
|
+
...filterable.map(field => field.name).filter(name => valuesOf(view, name).length > 0),
|
|
88
|
+
])];
|
|
89
|
+
const dateRange = useDateRangeFilters(activeFields, field => valuesOf(view, field));
|
|
64
90
|
|
|
65
91
|
const defaultSort = resourceConfig?.defaultSort;
|
|
66
92
|
const defaultPerPage = resolvePageSize(resourceConfig?.perPage, DEFAULT_PER_PAGE);
|
|
@@ -75,9 +101,15 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
75
101
|
() => resourceConfig?.fields.filter(f => f.searchable).map(f => f.name) ?? [],
|
|
76
102
|
[resourceConfig],
|
|
77
103
|
);
|
|
104
|
+
// Fields the adapter refused to sort by (CannotSortError), for this resource. Dropping them
|
|
105
|
+
// from sortableFields drops them from the URL sort and the default sort alike.
|
|
106
|
+
const [refused, setRefused] = useState<{ resource: string; fields: ReadonlySet<string> }>(
|
|
107
|
+
() => ({ resource, fields: new Set() }),
|
|
108
|
+
);
|
|
109
|
+
const refusedSorts = refused.resource === resource ? refused.fields : undefined;
|
|
78
110
|
const sortableFields = useMemo(
|
|
79
|
-
() => new Set(resourceConfig?.fields.filter(f => f.
|
|
80
|
-
[resourceConfig],
|
|
111
|
+
() => new Set(resourceConfig?.fields.filter(f => isFieldSortable(f) && !refusedSorts?.has(f.name)).map(f => f.name) ?? []),
|
|
112
|
+
[resourceConfig, refusedSorts],
|
|
81
113
|
);
|
|
82
114
|
const activeScope = useMemo(
|
|
83
115
|
() => resolveScope(resourceConfig?.scopes, query.scope),
|
|
@@ -99,14 +131,23 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
99
131
|
? { [searchFields[0]]: `ilike.*${text}*` }
|
|
100
132
|
: undefined;
|
|
101
133
|
}, [adapter.supportsSearch, query.search, searchFields]);
|
|
134
|
+
const listFilter = useMemo(
|
|
135
|
+
() => mergeFilters(mergeFilters(listParams.filter, legacySearchFilter), dateRange.filter),
|
|
136
|
+
[listParams.filter, legacySearchFilter, dateRange.filter],
|
|
137
|
+
);
|
|
138
|
+
// Counts share the range, but each tab supplies its own scope filter.
|
|
139
|
+
const countsFilter = useMemo(
|
|
140
|
+
() => mergeFilters(mergeFilters(baseFilter, legacySearchFilter), dateRange.filter),
|
|
141
|
+
[baseFilter, legacySearchFilter, dateRange.filter],
|
|
142
|
+
);
|
|
102
143
|
|
|
103
144
|
// Bumped after an inline edit, so the tab counts follow the change.
|
|
104
145
|
const [countsToken, setCountsToken] = useState(0);
|
|
105
146
|
const scopeCounts = useScopeCounts({
|
|
106
147
|
resource,
|
|
107
148
|
scopes: resourceConfig?.scopes,
|
|
108
|
-
enabled: Boolean(resourceConfig?.scopeCounts),
|
|
109
|
-
baseFilter:
|
|
149
|
+
enabled: Boolean(resourceConfig?.scopeCounts) && !dateRange.block,
|
|
150
|
+
baseFilter: countsFilter,
|
|
110
151
|
...searchParams,
|
|
111
152
|
refreshToken: countsToken,
|
|
112
153
|
});
|
|
@@ -121,6 +162,7 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
121
162
|
page,
|
|
122
163
|
perPage,
|
|
123
164
|
} = useAdminData(resource, {
|
|
165
|
+
skip: dateRange.block,
|
|
124
166
|
controlled: true,
|
|
125
167
|
page: listParams.page,
|
|
126
168
|
perPage: listParams.perPage,
|
|
@@ -129,7 +171,7 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
129
171
|
...searchParams,
|
|
130
172
|
select: resourceConfig?.select,
|
|
131
173
|
deleted: listParams.deleted,
|
|
132
|
-
baseFilter:
|
|
174
|
+
baseFilter: listFilter,
|
|
133
175
|
});
|
|
134
176
|
|
|
135
177
|
// A column named like a list URL param cannot be filtered from the URL: say so while developing.
|
|
@@ -187,7 +229,7 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
187
229
|
const navigation = useAdminNavigation();
|
|
188
230
|
const columns = useMemo(() => {
|
|
189
231
|
if (!resourceConfig) return [];
|
|
190
|
-
const cols = fieldsToColumns(resourceConfig, adapter, afterInlineEdit, relationLabels, deletedOnly);
|
|
232
|
+
const cols = fieldsToColumns(resourceConfig, sortableFields, adapter, afterInlineEdit, relationLabels, deletedOnly);
|
|
191
233
|
const onConfirmDelete = navigation
|
|
192
234
|
? (record: DataRecord) => {
|
|
193
235
|
const id = (record[resourceConfig.primaryKey ?? 'id'] as string | number) ?? record.id;
|
|
@@ -202,7 +244,7 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
202
244
|
cols.push(actionsColumn(resourceConfig, actions, adapter, refetch));
|
|
203
245
|
}
|
|
204
246
|
return cols;
|
|
205
|
-
}, [resourceConfig, adapter, refetch, afterInlineEdit, onRowPress, onEditPress, relationLabels, deletedOnly, navigation, resource]);
|
|
247
|
+
}, [resourceConfig, sortableFields, adapter, refetch, afterInlineEdit, onRowPress, onEditPress, relationLabels, deletedOnly, navigation, resource]);
|
|
206
248
|
|
|
207
249
|
// The click cycles from what is shown (the default sort included), not from the URL alone.
|
|
208
250
|
const handleSort = useCallback((key: string) => {
|
|
@@ -217,16 +259,60 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
217
259
|
|
|
218
260
|
const label = resourceConfig.labelPlural ?? resourceConfig.label ?? resourceConfig.name;
|
|
219
261
|
const canCreate = resourceConfig.actions?.create !== false;
|
|
262
|
+
const rows: DateFilterRow[] = activeFields.map(field => {
|
|
263
|
+
const state = dateRange.rows.find(row => row.field === field);
|
|
264
|
+
const pinned = field === rangeConfig?.field;
|
|
265
|
+
return {
|
|
266
|
+
field,
|
|
267
|
+
label: resourceConfig.fields.find(candidate => candidate.name === field)?.label ?? field,
|
|
268
|
+
value: state?.pair ?? null,
|
|
269
|
+
invalidReason: state?.status === 'invalid' ? state.reason : undefined,
|
|
270
|
+
presets: pinned ? rangeConfig?.presets : ADDED_ROW_PRESETS,
|
|
271
|
+
weekStartsOn: rangeConfig?.weekStartsOn,
|
|
272
|
+
removable: !pinned,
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
const dateFilters = (
|
|
276
|
+
<DateFilterBar
|
|
277
|
+
rows={rows}
|
|
278
|
+
addable={filterable.filter(field => !activeFields.includes(field.name)).map(field => ({ field: field.name, label: field.label ?? field.name }))}
|
|
279
|
+
onApply={(field, values) => setParams({ [field]: values, page: undefined })}
|
|
280
|
+
onClear={field => setParams({ [field]: undefined, page: undefined })}
|
|
281
|
+
onRemove={field => {
|
|
282
|
+
setAddedState(current => ({ resource, fields: (current.resource === resource ? current.fields : []).filter(name => name !== field) }));
|
|
283
|
+
if (valuesOf(view, field).length > 0) setParams({ [field]: undefined, page: undefined });
|
|
284
|
+
}}
|
|
285
|
+
onAdd={field => setAddedState(current => {
|
|
286
|
+
const fields = current.resource === resource ? current.fields : [];
|
|
287
|
+
return { resource, fields: fields.includes(field) ? fields : [...fields, field] };
|
|
288
|
+
})}
|
|
289
|
+
/>
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const header = (
|
|
293
|
+
<View className="flex-row items-center justify-between px-4 py-4">
|
|
294
|
+
<Text className="text-xl font-bold text-gray-900">{label}</Text>
|
|
295
|
+
{canCreate && onCreatePress && (
|
|
296
|
+
<Button label="Create" onPress={onCreatePress} />
|
|
297
|
+
)}
|
|
298
|
+
</View>
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Check the synchronous range status before any stale rows, errors, totals, or tab counts render.
|
|
302
|
+
// The title and Create stay: a mistyped date must not take them away.
|
|
303
|
+
if (dateRange.block) {
|
|
304
|
+
return (
|
|
305
|
+
<View className="flex-1">
|
|
306
|
+
{header}
|
|
307
|
+
{dateFilters}
|
|
308
|
+
<EmptyState title="Choose a valid date range, or clear it, to see rows." />
|
|
309
|
+
</View>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
220
312
|
|
|
221
313
|
return (
|
|
222
314
|
<View className="flex-1">
|
|
223
|
-
{
|
|
224
|
-
<View className="flex-row items-center justify-between px-4 py-4">
|
|
225
|
-
<Text className="text-xl font-bold text-gray-900">{label}</Text>
|
|
226
|
-
{canCreate && onCreatePress && (
|
|
227
|
-
<Button label="Create" onPress={onCreatePress} />
|
|
228
|
-
)}
|
|
229
|
-
</View>
|
|
315
|
+
{header}
|
|
230
316
|
|
|
231
317
|
{/* Filter bar */}
|
|
232
318
|
{searchFields.length > 0 ? (
|
|
@@ -236,6 +322,7 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
236
322
|
value={searchText}
|
|
237
323
|
/>
|
|
238
324
|
) : null}
|
|
325
|
+
{dateFilters}
|
|
239
326
|
{query.search && searchFields.length > 0 && (!loading || refreshing) && !error ? (
|
|
240
327
|
<Text className="px-4 pb-2 text-sm text-gray-500">
|
|
241
328
|
{`${total} ${total === 1 ? 'result' : 'results'}`}
|
|
@@ -254,7 +341,19 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
254
341
|
{loading && !refreshing ? (
|
|
255
342
|
<LoadingState message={`Loading ${label.toLowerCase()}...`} />
|
|
256
343
|
) : error ? (
|
|
257
|
-
<ErrorState
|
|
344
|
+
<ErrorState
|
|
345
|
+
message={error.message}
|
|
346
|
+
onRetry={isCannotSortError(error)
|
|
347
|
+
? () => {
|
|
348
|
+
const field = error.field;
|
|
349
|
+
setRefused(previous => ({
|
|
350
|
+
resource,
|
|
351
|
+
fields: new Set([...(previous.resource === resource ? previous.fields : []), field]),
|
|
352
|
+
}));
|
|
353
|
+
update({ sort: undefined, order: undefined });
|
|
354
|
+
}
|
|
355
|
+
: refetch}
|
|
356
|
+
/>
|
|
258
357
|
) : data.length === 0 ? (
|
|
259
358
|
query.search ? (
|
|
260
359
|
<EmptyState title={`No results for \u201c${query.search}\u201d`} message="Try a different search." />
|
|
@@ -302,6 +401,7 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
302
401
|
|
|
303
402
|
function fieldsToColumns(
|
|
304
403
|
resourceConfig: ResourceConfig,
|
|
404
|
+
sortableFields: ReadonlySet<string>,
|
|
305
405
|
adapter: DataAdapter,
|
|
306
406
|
onInlineSaved: () => Promise<void>,
|
|
307
407
|
relationLabels: RelationLabels,
|
|
@@ -316,7 +416,7 @@ function fieldsToColumns(
|
|
|
316
416
|
.map(field => ({
|
|
317
417
|
key: field.name,
|
|
318
418
|
label: field.label ?? field.name,
|
|
319
|
-
sortable: field.
|
|
419
|
+
sortable: sortableFields.has(field.name),
|
|
320
420
|
...(field.listWidth !== undefined && { width: field.listWidth }),
|
|
321
421
|
...(field.listPriority !== undefined && { priority: field.listPriority }),
|
|
322
422
|
...(field.listHideBelow !== undefined && { hideBelow: field.listHideBelow }),
|
|
@@ -364,6 +464,7 @@ function cellRenderer(
|
|
|
364
464
|
case 'image':
|
|
365
465
|
return (value) => <ImageDisplay field={field} value={value} view="list" />;
|
|
366
466
|
default:
|
|
467
|
+
if (field.type === 'datetime') return value => <DateTimeValue field={field} value={value} />;
|
|
367
468
|
return (value) => (
|
|
368
469
|
<Text className="text-sm text-gray-900" numberOfLines={1}>
|
|
369
470
|
{formatCellValue(field, value)}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* link and drill-downs work (`/admin/log_events?level=error`, `?from=&to=`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { dateFilterFields } from '../utils/date-filter-fields';
|
|
13
14
|
import React, { useMemo } from 'react';
|
|
14
15
|
import { View } from 'react-native';
|
|
15
16
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
@@ -38,7 +39,7 @@ export function ResourceListView({
|
|
|
38
39
|
const config = useAdminConfig();
|
|
39
40
|
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
40
41
|
const dashboard = resourceConfig?.dashboard;
|
|
41
|
-
const { params, setParams } = useViewState();
|
|
42
|
+
const { params, paramValues, setParams } = useViewState();
|
|
42
43
|
|
|
43
44
|
const dimensions = dashboard?.dimensions ?? [];
|
|
44
45
|
const filters = useMemo(() => paramsToFilters(params, dimensions), [params, dimensions]);
|
|
@@ -49,10 +50,13 @@ export function ResourceListView({
|
|
|
49
50
|
const fromAnalytics = dashboard ? filtersToBaseFilter(filters, dashboard) : undefined;
|
|
50
51
|
const fieldNames = new Set((resourceConfig?.fields ?? []).map(f => f.name));
|
|
51
52
|
const exclude = new Set(dimensions.map(d => d.column));
|
|
52
|
-
|
|
53
|
+
// The date range rows own every filterable datetime column's URL values.
|
|
54
|
+
if (resourceConfig?.dateRange) exclude.add(resourceConfig.dateRange.field);
|
|
55
|
+
if (resourceConfig) for (const field of dateFilterFields(resourceConfig)) exclude.add(field.name);
|
|
56
|
+
const passthrough = paramsToColumnFilters(params, fieldNames, exclude, paramValues);
|
|
53
57
|
const merged = { ...fromAnalytics, ...passthrough };
|
|
54
58
|
return Object.keys(merged).length ? merged : undefined;
|
|
55
|
-
}, [filters, dashboard, params, resourceConfig, dimensions]);
|
|
59
|
+
}, [filters, dashboard, params, paramValues, resourceConfig, dimensions]);
|
|
56
60
|
|
|
57
61
|
const showHeader = Boolean(dashboard && baseUrl);
|
|
58
62
|
|
|
@@ -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,6 +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 { DateTimeValue } from '../components/DateTimeValue';
|
|
26
27
|
import { formatDateOnly } from '../utils/format-cell-value';
|
|
27
28
|
import type { FieldDefinition, DataRecord, ResourceConfig } from '../types';
|
|
28
29
|
import type { DataAdapter } from '../adapters/types';
|
|
@@ -194,7 +195,10 @@ export function ResourceShow({
|
|
|
194
195
|
|
|
195
196
|
{restore && (
|
|
196
197
|
<View testID="soft-delete-banner" className="flex-row items-center justify-between mx-4 mb-4 p-3 rounded-md bg-red-50">
|
|
197
|
-
<
|
|
198
|
+
<View>
|
|
199
|
+
<Text className="text-sm text-red-700">Deleted</Text>
|
|
200
|
+
<DateTimeValue field={deletedField(resourceConfig, deletedColumn)} value={deletedAt} />
|
|
201
|
+
</View>
|
|
198
202
|
<RowActionButton action={restore} record={record} recordId={id} adapter={adapter} refetch={refetchQuietly} />
|
|
199
203
|
</View>
|
|
200
204
|
)}
|
|
@@ -277,14 +281,17 @@ function FieldRow({ field, value, record, labels }: FieldRowProps): React.ReactE
|
|
|
277
281
|
</Text>
|
|
278
282
|
{field.render ? (
|
|
279
283
|
<>{wrapBareText(field.render(value, record, { view: 'show' }))}</>
|
|
284
|
+
) : field.type === 'datetime' ? (
|
|
285
|
+
<DateTimeValue field={field} value={value} />
|
|
280
286
|
) : field.type === 'image' ? (
|
|
281
287
|
<ImageDisplay field={field} value={value} view="show" />
|
|
282
288
|
) : field.type === 'tags' ? (
|
|
283
289
|
<TagsDisplay field={field} value={value} />
|
|
284
290
|
) : field.type === 'relation' && field.relation && value != null && value !== '' ? (
|
|
285
291
|
<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
|
|
292
|
+
) : value != null && Platform.OS === 'web' && (field.format === 'html' || field.format === 'markdown') ? (
|
|
293
|
+
// Stored rich text is sanitised at render by ui RichContent (web-only: it emits a div,
|
|
294
|
+
// which native has no host component for). Native prints the value as plain text below.
|
|
288
295
|
<RichContent value={value} format={field.format} className="text-sm text-gray-900" />
|
|
289
296
|
) : (
|
|
290
297
|
<Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
|
|
@@ -293,9 +300,9 @@ function FieldRow({ field, value, record, labels }: FieldRowProps): React.ReactE
|
|
|
293
300
|
);
|
|
294
301
|
}
|
|
295
302
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
return
|
|
303
|
+
/** The soft-delete column's declared field, so a wall-clock one shows its digits; else a plain datetime. */
|
|
304
|
+
function deletedField(resource: ResourceConfig, column: string | undefined): FieldDefinition {
|
|
305
|
+
return resource.fields.find(f => f.name === column && f.type === 'datetime') ?? { name: column ?? '', type: 'datetime' };
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
function formatFieldValue(value: unknown, field: FieldDefinition): string {
|
|
@@ -306,8 +313,6 @@ function formatFieldValue(value: unknown, field: FieldDefinition): string {
|
|
|
306
313
|
return value ? 'Yes' : 'No';
|
|
307
314
|
case 'date':
|
|
308
315
|
return formatDateOnly(value);
|
|
309
|
-
case 'datetime':
|
|
310
|
-
return formatDateTime(value);
|
|
311
316
|
case 'json':
|
|
312
317
|
return JSON.stringify(value, null, 2);
|
|
313
318
|
case 'url':
|
package/src/types.ts
CHANGED
|
@@ -124,15 +124,18 @@ 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 */
|
|
131
132
|
label?: string;
|
|
132
133
|
/** Field type for rendering */
|
|
133
134
|
type: FieldType;
|
|
134
|
-
/**
|
|
135
|
+
/** List sorting defaults on by type; false opts out, true opts in unless virtual. */
|
|
135
136
|
sortable?: boolean;
|
|
137
|
+
/** No backing column (adapter-computed). Never sortable, so the header never sends an order the server would refuse. */
|
|
138
|
+
virtual?: boolean;
|
|
136
139
|
/** Whether field is searchable */
|
|
137
140
|
searchable?: boolean;
|
|
138
141
|
/** Whether field is filterable */
|
|
@@ -216,10 +219,57 @@ export interface FieldDefinition {
|
|
|
216
219
|
format?: ContentFormat;
|
|
217
220
|
}
|
|
218
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A wall-clock datetime: the column holds "this wall-clock time in one business zone", and every
|
|
224
|
+
* screen shows and edits it in that zone, whatever the viewer's zone. ONLY for a naive `timestamp`
|
|
225
|
+
* column (`field.timestamp()`): the API carries its stored digits labelled `Z`, and the admin
|
|
226
|
+
* writes digits labelled `Z` back. On a `timestamptz` column those digits are a UTC instant and
|
|
227
|
+
* this option is wrong; the admin cannot see the column type, so it cannot catch that.
|
|
228
|
+
*
|
|
229
|
+
* A time the zone skips (a spring-forward gap) is a field error on submit. A time the zone
|
|
230
|
+
* repeats (a fall-back overlap) is stored as typed.
|
|
231
|
+
*/
|
|
232
|
+
export interface WallClockDatetime {
|
|
233
|
+
type: 'datetime';
|
|
234
|
+
/** IANA zone name, e.g. 'America/Los_Angeles'. Checked at setup: an invalid name throws. */
|
|
235
|
+
timezone: string;
|
|
236
|
+
/** Literal digits in a naive timestamp column, rather than an instant. */
|
|
237
|
+
storage: 'wall';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** An instant datetime. Omitting storage on a datetime has exactly the same meaning. */
|
|
241
|
+
export interface InstantDatetime {
|
|
242
|
+
type: 'datetime';
|
|
243
|
+
storage: 'utc';
|
|
244
|
+
timezone?: undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Any field without explicit storage; datetimes are instants by default. */
|
|
248
|
+
export interface NoTimeZone {
|
|
249
|
+
timezone?: undefined;
|
|
250
|
+
storage?: undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Field-level timezone is a wall-storage exception; instant zones come from AdminConfig. */
|
|
254
|
+
export type FieldDefinition = FieldDefinitionBase & (WallClockDatetime | InstantDatetime | NoTimeZone);
|
|
255
|
+
|
|
219
256
|
// =============================================================================
|
|
220
257
|
// Resource Configuration
|
|
221
258
|
// =============================================================================
|
|
222
259
|
|
|
260
|
+
/** Named calendar ranges in the display zone. Declaration only; list behavior comes later. */
|
|
261
|
+
export type DateRangePreset =
|
|
262
|
+
| 'today' | 'yesterday' | 'last7Days' | 'last30Days'
|
|
263
|
+
| 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth';
|
|
264
|
+
|
|
265
|
+
export interface DateRangeConfig {
|
|
266
|
+
/** A declared, non-virtual datetime field with instant storage (omitted or 'utc'). */
|
|
267
|
+
field: string;
|
|
268
|
+
presets?: DateRangePreset[];
|
|
269
|
+
/** 0 = Sunday through 6 = Saturday. */
|
|
270
|
+
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
271
|
+
}
|
|
272
|
+
|
|
223
273
|
/**
|
|
224
274
|
* One column of a Show/Edit/Create screen. `width`: 'wide' takes the space
|
|
225
275
|
* left over; 'narrow' is a fixed 320px; a number is a fixed width in px. The
|
|
@@ -279,6 +329,8 @@ export interface ResourceConfig {
|
|
|
279
329
|
icon?: string;
|
|
280
330
|
/** Field definitions */
|
|
281
331
|
fields: FieldDefinition[];
|
|
332
|
+
/** Pin a non-dimension instant datetime's date row. Overrides filterable:false; validated at setup. */
|
|
333
|
+
dateRange?: DateRangeConfig;
|
|
282
334
|
/** Default sort field */
|
|
283
335
|
defaultSort?: {
|
|
284
336
|
field: string;
|
|
@@ -322,6 +374,27 @@ export interface ResourceConfig {
|
|
|
322
374
|
* through a field `render`. RPC-backed resources ignore it.
|
|
323
375
|
*/
|
|
324
376
|
select?: string;
|
|
377
|
+
/**
|
|
378
|
+
* The table this resource reads and writes. Default: the resource `name`. Two resources may
|
|
379
|
+
* share a table, each with its own `where` (single-table inheritance). The URL, nav,
|
|
380
|
+
* `relation.resource`, scopes and `where` stay keyed by the resource name; the adapter receives
|
|
381
|
+
* the table name, so an adapter's own per-resource keys (EverystackAdapter `listRpcs` and
|
|
382
|
+
* `crudRpcs`) use the table name. A resource `dashboard` does NOT follow `table`: its
|
|
383
|
+
* `dashboard.tableName` still defaults to the resource name, so set it when `table` is set.
|
|
384
|
+
*/
|
|
385
|
+
table?: string;
|
|
386
|
+
/**
|
|
387
|
+
* A view filter and a forced default. It is not authorization: any client with the same token
|
|
388
|
+
* can address any row through the API. For a boundary, use a role and an RLS policy (or a view).
|
|
389
|
+
*
|
|
390
|
+
* Every adapter call for this resource (lists, counts, aggregates, show, update, delete) carries
|
|
391
|
+
* `column=eq.value` for each entry, after the caller's own filters and winning on the same key.
|
|
392
|
+
* Create and update bodies carry the values, and Create and Edit render no input for these
|
|
393
|
+
* columns. Keys are spelled like the resource's field names. RPC-backed resources ignore it.
|
|
394
|
+
* Equality only: values are plain, never operator strings (see WhereValues).
|
|
395
|
+
* Example: `where: { type: 'Adventure' }`.
|
|
396
|
+
*/
|
|
397
|
+
where?: WhereValues;
|
|
325
398
|
/** How the show view fetches one record. 'rpc' calls the role-gated
|
|
326
399
|
* {camelResource}EventDetail RPC (the resource stays off the REST surface);
|
|
327
400
|
* 'rest' (default) uses adapter.fetchOne. Lets a plain list (no dashboard) —
|
|
@@ -421,15 +494,18 @@ export interface ResourceDashboardConfig {
|
|
|
421
494
|
title?: string;
|
|
422
495
|
/**
|
|
423
496
|
* Physical table for dashboard SQL, may be schema-qualified (e.g. 'ops.runs',
|
|
424
|
-
* 'logs.crash_reports'). Defaults to the resource
|
|
425
|
-
*
|
|
426
|
-
* must hit — there is no search_path to paper over the difference.
|
|
497
|
+
* 'logs.crash_reports'). Defaults to the resource `table`, else the resource
|
|
498
|
+
* name. Required whenever neither is the table the raw aggregation queries
|
|
499
|
+
* must hit — there is no search_path to paper over the difference. When it
|
|
500
|
+
* names a table other than the resource's own, the resource `where` is not
|
|
501
|
+
* applied; set `where` here instead.
|
|
427
502
|
*/
|
|
428
503
|
tableName?: string;
|
|
429
504
|
/**
|
|
430
505
|
* Static predicates ANDed into every dashboard query (server-side constants,
|
|
431
506
|
* never user input). The STI case: jobs over ops.runs use
|
|
432
|
-
* [{ column: 'type', equals: 'job' }].
|
|
507
|
+
* [{ column: 'type', equals: 'job' }]. On the resource's own table the
|
|
508
|
+
* resource `where` is ANDed in too and wins on the same column.
|
|
433
509
|
*/
|
|
434
510
|
where?: Array<{ column: string; equals: string }>;
|
|
435
511
|
/** The datetime column for the time histogram */
|
|
@@ -444,7 +520,7 @@ export interface ResourceDashboardConfig {
|
|
|
444
520
|
dimensions: DimensionConfig[];
|
|
445
521
|
/** Primary key column(s) for the event list (default: 'id'). Use array for composite keys. */
|
|
446
522
|
primaryKey?: string | string[];
|
|
447
|
-
/** Columns to display in
|
|
523
|
+
/** Columns to display and allow sorting in eventList (snake_case or camelCase aliases). The timestamp column is always sortable; other columns are refused. */
|
|
448
524
|
listColumns: string[];
|
|
449
525
|
/** Columns searchable via ILIKE in the event list */
|
|
450
526
|
searchColumns?: string[];
|
|
@@ -560,6 +636,8 @@ export interface StorageConfig {
|
|
|
560
636
|
}
|
|
561
637
|
|
|
562
638
|
export interface AdminConfig {
|
|
639
|
+
/** IANA display zone for instants. Omit to resolve the viewer's browser zone after mount. */
|
|
640
|
+
timeZone?: string;
|
|
563
641
|
/** Application title */
|
|
564
642
|
title: string;
|
|
565
643
|
/** Application subtitle/description */
|
|
@@ -592,11 +670,9 @@ export interface AdminConfig {
|
|
|
592
670
|
* link (`resource_id` + `resource_type`, often with no foreign key) sets
|
|
593
671
|
* `typeColumn` and `typeValue` as well.
|
|
594
672
|
*/
|
|
595
|
-
|
|
673
|
+
interface AssociationBase {
|
|
596
674
|
/** Shown in the preview, e.g. 'Bookmarks'. */
|
|
597
675
|
label: string;
|
|
598
|
-
/** The related table or resource name, as the adapter knows it. */
|
|
599
|
-
resource: string;
|
|
600
676
|
/** The column on the related table that holds this record's id. */
|
|
601
677
|
foreignKey: string;
|
|
602
678
|
/** The polymorphic type column on the related table, e.g. 'resource_type'. */
|
|
@@ -605,6 +681,23 @@ export interface AssociationConfig {
|
|
|
605
681
|
typeValue?: string;
|
|
606
682
|
}
|
|
607
683
|
|
|
684
|
+
/** The related rows, named by exactly one of `resource` or `table`. */
|
|
685
|
+
export type AssociationConfig = AssociationBase & (
|
|
686
|
+
| {
|
|
687
|
+
/**
|
|
688
|
+
* The related table, or a resource name. A resource name is read through that resource's
|
|
689
|
+
* `table` and `where`. A plain table name that is no resource's name passes unchanged.
|
|
690
|
+
*/
|
|
691
|
+
resource: string;
|
|
692
|
+
table?: never;
|
|
693
|
+
}
|
|
694
|
+
| {
|
|
695
|
+
/** The related table, used as is: never read as a resource, no `table` mapping, no `where`. */
|
|
696
|
+
table: string;
|
|
697
|
+
resource?: never;
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
|
|
608
701
|
/**
|
|
609
702
|
* A named filter shown as a tab above a list ("Current", "Draft"). Scopes are
|
|
610
703
|
* ANDed with the search and any column filters.
|
|
@@ -624,12 +717,22 @@ export interface ScopeConfig {
|
|
|
624
717
|
/** Soft-delete reads: 'include' returns live and deleted rows, 'only' deleted rows. */
|
|
625
718
|
export type DeletedMode = 'include' | 'only';
|
|
626
719
|
|
|
720
|
+
/**
|
|
721
|
+
* `ResourceConfig.where`: column → the one value every row of the resource has. Equality only: a
|
|
722
|
+
* value is a plain string, finite number or boolean, matched exactly. It is never an operator
|
|
723
|
+
* string; `'in.(a,b)'` matches the literal text `in.(a,b)`.
|
|
724
|
+
*/
|
|
725
|
+
export type WhereValues = Record<string, string | number | boolean>;
|
|
726
|
+
|
|
627
727
|
export interface ListParams {
|
|
628
728
|
/** Page number (1-indexed) */
|
|
629
729
|
page?: number;
|
|
630
730
|
/** Items per page */
|
|
631
731
|
perPage?: number;
|
|
632
|
-
/**
|
|
732
|
+
/**
|
|
733
|
+
* The field name the user sorted by. An adapter that cannot order by it MUST
|
|
734
|
+
* throw CannotSortError(field); it must not fall back to another order.
|
|
735
|
+
*/
|
|
633
736
|
sort?: string;
|
|
634
737
|
/** Sort order */
|
|
635
738
|
order?: 'asc' | 'desc';
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FieldDefinition, ResourceConfig } from '../types';
|
|
2
|
+
import { toCamelCase } from './naming';
|
|
3
|
+
import { RESERVED_PARAMS } from './view-filters';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The datetime fields the list can filter with a date range row: real instant columns, not wall-clock,
|
|
7
|
+
* not opted out with `filterable: false`, and not named like a reserved view param (`page`, `from`, ...), which the URL never carries as a column filter. A resource with a `dashboard` may be listed by the eventList RPC,
|
|
8
|
+
* which only accepts its list and timestamp columns here unless a field opts in with `filterable: true`.
|
|
9
|
+
* Dimension columns always belong to analytics, so they never get a date row.
|
|
10
|
+
*/
|
|
11
|
+
export function dateFilterFields(resource: ResourceConfig): FieldDefinition[] {
|
|
12
|
+
const accepted = resource.dashboard ? acceptedColumns(resource.dashboard) : undefined;
|
|
13
|
+
return (resource.fields ?? []).filter(field =>
|
|
14
|
+
field.type === 'datetime'
|
|
15
|
+
&& !field.virtual
|
|
16
|
+
&& field.storage !== 'wall'
|
|
17
|
+
&& field.filterable !== false
|
|
18
|
+
&& !RESERVED_PARAMS.has(field.name)
|
|
19
|
+
&& !isDashboardDimension(resource, field.name)
|
|
20
|
+
&& (accepted === undefined || field.filterable === true || accepted.has(field.name)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** SQL names and their camelCase field names identify the same analytics dimension. */
|
|
24
|
+
export function isDashboardDimension(resource: ResourceConfig, field: string): boolean {
|
|
25
|
+
const name = toCamelCase(field);
|
|
26
|
+
return resource.dashboard?.dimensions.some(dimension => toCamelCase(dimension.column) === name) ?? false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function acceptedColumns(dashboard: NonNullable<ResourceConfig['dashboard']>): Set<string> {
|
|
30
|
+
const columns = [...dashboard.listColumns, dashboard.timestampColumn];
|
|
31
|
+
return new Set(columns.flatMap(column => [column, toCamelCase(column)]));
|
|
32
|
+
}
|