@everystack/admin 0.4.6 → 0.4.8
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 +34 -0
- package/package.json +12 -5
- package/src/adapters/CannotSortError.ts +19 -0
- package/src/adapters/EverystackAdapter.ts +28 -16
- package/src/adapters/types.ts +21 -6
- package/src/components/DateConditionInput.tsx +148 -0
- package/src/components/DateFilterBar.tsx +85 -0
- package/src/components/DateTimeValue.tsx +37 -0
- package/src/components/DeletePreview.tsx +15 -6
- package/src/hooks/useAdminData.ts +44 -3
- package/src/hooks/useDateRangeFilters.ts +47 -0
- package/src/hooks/useViewState.ts +41 -8
- package/src/index.ts +12 -0
- package/src/providers/AdminConfigProvider.tsx +7 -2
- package/src/providers/DataAdapterProvider.tsx +5 -3
- package/src/rpc/list-filter.ts +210 -0
- package/src/rpc/resource-dashboard-rpc.ts +62 -13
- package/src/screens/AdminRouter.tsx +19 -6
- package/src/screens/ResourceCreate.tsx +34 -5
- package/src/screens/ResourceEdit.tsx +34 -5
- package/src/screens/ResourceList.tsx +118 -17
- package/src/screens/ResourceListView.tsx +7 -3
- package/src/screens/ResourceShow.tsx +8 -4
- package/src/types.ts +107 -18
- package/src/utils/date-filter-fields.ts +32 -0
- package/src/utils/date-range.ts +146 -0
- package/src/utils/datetime-submission.ts +25 -0
- package/src/utils/field-sortable.ts +19 -0
- package/src/utils/format-cell-value.ts +23 -10
- package/src/utils/list-query.ts +3 -3
- package/src/utils/resource-query.ts +318 -0
- package/src/utils/validate-config.ts +85 -0
- package/src/utils/view-filters.ts +9 -3
- package/src/utils/wall-clock.ts +2 -1
|
@@ -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
|
|
|
@@ -23,7 +23,8 @@ 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 {
|
|
26
|
+
import { DateTimeValue } from '../components/DateTimeValue';
|
|
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';
|
|
29
30
|
|
|
@@ -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,6 +281,8 @@ 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' ? (
|
|
@@ -307,8 +313,6 @@ function formatFieldValue(value: unknown, field: FieldDefinition): string {
|
|
|
307
313
|
return value ? 'Yes' : 'No';
|
|
308
314
|
case 'date':
|
|
309
315
|
return formatDateOnly(value);
|
|
310
|
-
case 'datetime':
|
|
311
|
-
return formatDateTime(field, value);
|
|
312
316
|
case 'json':
|
|
313
317
|
return JSON.stringify(value, null, 2);
|
|
314
318
|
case 'url':
|
package/src/types.ts
CHANGED
|
@@ -132,8 +132,10 @@ export interface FieldDefinitionBase {
|
|
|
132
132
|
label?: string;
|
|
133
133
|
/** Field type for rendering */
|
|
134
134
|
type: FieldType;
|
|
135
|
-
/**
|
|
135
|
+
/** List sorting defaults on by type; false opts out, true opts in unless virtual. */
|
|
136
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;
|
|
137
139
|
/** Whether field is searchable */
|
|
138
140
|
searchable?: boolean;
|
|
139
141
|
/** Whether field is filterable */
|
|
@@ -231,23 +233,43 @@ export interface WallClockDatetime {
|
|
|
231
233
|
type: 'datetime';
|
|
232
234
|
/** IANA zone name, e.g. 'America/Los_Angeles'. Checked at setup: an invalid name throws. */
|
|
233
235
|
timezone: string;
|
|
234
|
-
/**
|
|
236
|
+
/** Literal digits in a naive timestamp column, rather than an instant. */
|
|
235
237
|
storage: 'wall';
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
/**
|
|
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. */
|
|
239
248
|
export interface NoTimeZone {
|
|
240
249
|
timezone?: undefined;
|
|
241
250
|
storage?: undefined;
|
|
242
251
|
}
|
|
243
252
|
|
|
244
|
-
/**
|
|
245
|
-
export type FieldDefinition = FieldDefinitionBase & (WallClockDatetime | NoTimeZone);
|
|
253
|
+
/** Field-level timezone is a wall-storage exception; instant zones come from AdminConfig. */
|
|
254
|
+
export type FieldDefinition = FieldDefinitionBase & (WallClockDatetime | InstantDatetime | NoTimeZone);
|
|
246
255
|
|
|
247
256
|
// =============================================================================
|
|
248
257
|
// Resource Configuration
|
|
249
258
|
// =============================================================================
|
|
250
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
|
+
|
|
251
273
|
/**
|
|
252
274
|
* One column of a Show/Edit/Create screen. `width`: 'wide' takes the space
|
|
253
275
|
* left over; 'narrow' is a fixed 320px; a number is a fixed width in px. The
|
|
@@ -307,6 +329,8 @@ export interface ResourceConfig {
|
|
|
307
329
|
icon?: string;
|
|
308
330
|
/** Field definitions */
|
|
309
331
|
fields: FieldDefinition[];
|
|
332
|
+
/** Pin a non-dimension instant datetime's date row. Overrides filterable:false; validated at setup. */
|
|
333
|
+
dateRange?: DateRangeConfig;
|
|
310
334
|
/** Default sort field */
|
|
311
335
|
defaultSort?: {
|
|
312
336
|
field: string;
|
|
@@ -346,10 +370,34 @@ export interface ResourceConfig {
|
|
|
346
370
|
primaryKey?: string;
|
|
347
371
|
/**
|
|
348
372
|
* PostgREST select string for list, show and edit, embeds included, e.g.
|
|
349
|
-
* '*,user
|
|
350
|
-
* through a field `render`.
|
|
373
|
+
* '*,user(name)', where `user` is a relation declared in the handler's `relations`.
|
|
374
|
+
* One string for all three. Show an embedded value through a field `render`.
|
|
375
|
+
* RPC-backed resources ignore it.
|
|
351
376
|
*/
|
|
352
377
|
select?: string;
|
|
378
|
+
/**
|
|
379
|
+
* The table this resource reads and writes. Default: the resource `name`. Two resources may
|
|
380
|
+
* share a table, each with its own `where` (single-table inheritance). The URL, nav,
|
|
381
|
+
* `relation.resource`, scopes and `where` stay keyed by the resource name; the adapter receives
|
|
382
|
+
* the table name, so an adapter's own per-resource keys (EverystackAdapter `listRpcs` and
|
|
383
|
+
* `crudRpcs`) use the table name. When the table is not the name, the call also carries the
|
|
384
|
+
* resource name as `params.resource` (a third argument on create), so a custom adapter can still
|
|
385
|
+
* tell two resources on one table apart. A resource `dashboard` does NOT follow `table`: its
|
|
386
|
+
* `dashboard.tableName` still defaults to the resource name, so set it when `table` is set.
|
|
387
|
+
*/
|
|
388
|
+
table?: string;
|
|
389
|
+
/**
|
|
390
|
+
* A view filter and a forced default. It is not authorization: any client with the same token
|
|
391
|
+
* can address any row through the API. For a boundary, use a role and an RLS policy (or a view).
|
|
392
|
+
*
|
|
393
|
+
* Every adapter call for this resource (lists, counts, aggregates, show, update, delete) carries
|
|
394
|
+
* `column=eq.value` for each entry, after the caller's own filters and winning on the same key.
|
|
395
|
+
* Create and update bodies carry the values, and Create and Edit render no input for these
|
|
396
|
+
* columns. Keys are spelled like the resource's field names. RPC-backed resources ignore it.
|
|
397
|
+
* Equality only: values are plain, never operator strings (see WhereValues).
|
|
398
|
+
* Example: `where: { type: 'Adventure' }`.
|
|
399
|
+
*/
|
|
400
|
+
where?: WhereValues;
|
|
353
401
|
/** How the show view fetches one record. 'rpc' calls the role-gated
|
|
354
402
|
* {camelResource}EventDetail RPC (the resource stays off the REST surface);
|
|
355
403
|
* 'rest' (default) uses adapter.fetchOne. Lets a plain list (no dashboard) —
|
|
@@ -449,15 +497,18 @@ export interface ResourceDashboardConfig {
|
|
|
449
497
|
title?: string;
|
|
450
498
|
/**
|
|
451
499
|
* Physical table for dashboard SQL, may be schema-qualified (e.g. 'ops.runs',
|
|
452
|
-
* 'logs.crash_reports'). Defaults to the resource
|
|
453
|
-
*
|
|
454
|
-
* must hit — there is no search_path to paper over the difference.
|
|
500
|
+
* 'logs.crash_reports'). Defaults to the resource `table`, else the resource
|
|
501
|
+
* name. Required whenever neither is the table the raw aggregation queries
|
|
502
|
+
* must hit — there is no search_path to paper over the difference. When it
|
|
503
|
+
* names a table other than the resource's own, the resource `where` is not
|
|
504
|
+
* applied; set `where` here instead.
|
|
455
505
|
*/
|
|
456
506
|
tableName?: string;
|
|
457
507
|
/**
|
|
458
508
|
* Static predicates ANDed into every dashboard query (server-side constants,
|
|
459
509
|
* never user input). The STI case: jobs over ops.runs use
|
|
460
|
-
* [{ column: 'type', equals: 'job' }].
|
|
510
|
+
* [{ column: 'type', equals: 'job' }]. On the resource's own table the
|
|
511
|
+
* resource `where` is ANDed in too and wins on the same column.
|
|
461
512
|
*/
|
|
462
513
|
where?: Array<{ column: string; equals: string }>;
|
|
463
514
|
/** The datetime column for the time histogram */
|
|
@@ -472,7 +523,7 @@ export interface ResourceDashboardConfig {
|
|
|
472
523
|
dimensions: DimensionConfig[];
|
|
473
524
|
/** Primary key column(s) for the event list (default: 'id'). Use array for composite keys. */
|
|
474
525
|
primaryKey?: string | string[];
|
|
475
|
-
/** Columns to display in
|
|
526
|
+
/** Columns to display and allow sorting in eventList (snake_case or camelCase aliases). The timestamp column is always sortable; other columns are refused. */
|
|
476
527
|
listColumns: string[];
|
|
477
528
|
/** Columns searchable via ILIKE in the event list */
|
|
478
529
|
searchColumns?: string[];
|
|
@@ -588,6 +639,8 @@ export interface StorageConfig {
|
|
|
588
639
|
}
|
|
589
640
|
|
|
590
641
|
export interface AdminConfig {
|
|
642
|
+
/** IANA display zone for instants. Omit to resolve the viewer's browser zone after mount. */
|
|
643
|
+
timeZone?: string;
|
|
591
644
|
/** Application title */
|
|
592
645
|
title: string;
|
|
593
646
|
/** Application subtitle/description */
|
|
@@ -620,11 +673,9 @@ export interface AdminConfig {
|
|
|
620
673
|
* link (`resource_id` + `resource_type`, often with no foreign key) sets
|
|
621
674
|
* `typeColumn` and `typeValue` as well.
|
|
622
675
|
*/
|
|
623
|
-
|
|
676
|
+
interface AssociationBase {
|
|
624
677
|
/** Shown in the preview, e.g. 'Bookmarks'. */
|
|
625
678
|
label: string;
|
|
626
|
-
/** The related table or resource name, as the adapter knows it. */
|
|
627
|
-
resource: string;
|
|
628
679
|
/** The column on the related table that holds this record's id. */
|
|
629
680
|
foreignKey: string;
|
|
630
681
|
/** The polymorphic type column on the related table, e.g. 'resource_type'. */
|
|
@@ -633,6 +684,23 @@ export interface AssociationConfig {
|
|
|
633
684
|
typeValue?: string;
|
|
634
685
|
}
|
|
635
686
|
|
|
687
|
+
/** The related rows, named by exactly one of `resource` or `table`. */
|
|
688
|
+
export type AssociationConfig = AssociationBase & (
|
|
689
|
+
| {
|
|
690
|
+
/**
|
|
691
|
+
* The related table, or a resource name. A resource name is read through that resource's
|
|
692
|
+
* `table` and `where`. A plain table name that is no resource's name passes unchanged.
|
|
693
|
+
*/
|
|
694
|
+
resource: string;
|
|
695
|
+
table?: never;
|
|
696
|
+
}
|
|
697
|
+
| {
|
|
698
|
+
/** The related table, used as is: never read as a resource, no `table` mapping, no `where`. */
|
|
699
|
+
table: string;
|
|
700
|
+
resource?: never;
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
|
|
636
704
|
/**
|
|
637
705
|
* A named filter shown as a tab above a list ("Current", "Draft"). Scopes are
|
|
638
706
|
* ANDed with the search and any column filters.
|
|
@@ -652,12 +720,31 @@ export interface ScopeConfig {
|
|
|
652
720
|
/** Soft-delete reads: 'include' returns live and deleted rows, 'only' deleted rows. */
|
|
653
721
|
export type DeletedMode = 'include' | 'only';
|
|
654
722
|
|
|
723
|
+
/**
|
|
724
|
+
* `ResourceConfig.where`: column → the one value every row of the resource has. Equality only: a
|
|
725
|
+
* value is a plain string, finite number or boolean, matched exactly. It is never an operator
|
|
726
|
+
* string; `'in.(a,b)'` matches the literal text `in.(a,b)`.
|
|
727
|
+
*/
|
|
728
|
+
export type WhereValues = Record<string, string | number | boolean>;
|
|
729
|
+
|
|
655
730
|
export interface ListParams {
|
|
731
|
+
/**
|
|
732
|
+
* The resource name, set only when `ResourceConfig.table` maps the resource to another table.
|
|
733
|
+
* The adapter's `resource` argument is then the table, and this is the resource that asked, so
|
|
734
|
+
* an adapter with per-resource flows can still tell two resources on one table apart. Absent
|
|
735
|
+
* when the table is the name: the `resource` argument IS the resource. The library sets it; a caller
|
|
736
|
+
* must not pass it, and for a resource that sets no `table` it is passed through as given. The same key rides on
|
|
737
|
+
* AggregateParams and on the params of fetchOne, create, update and delete.
|
|
738
|
+
*/
|
|
739
|
+
resource?: string;
|
|
656
740
|
/** Page number (1-indexed) */
|
|
657
741
|
page?: number;
|
|
658
742
|
/** Items per page */
|
|
659
743
|
perPage?: number;
|
|
660
|
-
/**
|
|
744
|
+
/**
|
|
745
|
+
* The field name the user sorted by. An adapter that cannot order by it MUST
|
|
746
|
+
* throw CannotSortError(field); it must not fall back to another order.
|
|
747
|
+
*/
|
|
661
748
|
sort?: string;
|
|
662
749
|
/** Sort order */
|
|
663
750
|
order?: 'asc' | 'desc';
|
|
@@ -672,8 +759,8 @@ export interface ListParams {
|
|
|
672
759
|
*/
|
|
673
760
|
searchFields?: string[];
|
|
674
761
|
/**
|
|
675
|
-
* PostgREST select string, embeds included, e.g. '*,user
|
|
676
|
-
* Passed verbatim to the query builder. Ignored by RPC-backed resources
|
|
762
|
+
* PostgREST select string, embeds included, e.g. '*,user(name)' for a relation
|
|
763
|
+
* declared in the handler's `relations`. Passed verbatim to the query builder. Ignored by RPC-backed resources
|
|
677
764
|
* (listRpcs); an RPC decides its own shape.
|
|
678
765
|
*/
|
|
679
766
|
select?: string;
|
|
@@ -718,6 +805,8 @@ export interface AggregateQuery {
|
|
|
718
805
|
}
|
|
719
806
|
|
|
720
807
|
export interface AggregateParams {
|
|
808
|
+
/** The resource name when `ResourceConfig.table` maps it to another table. See ListParams.resource. */
|
|
809
|
+
resource?: string;
|
|
721
810
|
/** Aggregate functions to compute */
|
|
722
811
|
aggregates: AggregateQuery[];
|
|
723
812
|
/** Columns to group by */
|
|
@@ -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
|
+
}
|