@everystack/admin 0.4.6 → 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 +24 -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 +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 +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 +89 -14
- 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 +303 -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;
|
|
@@ -350,6 +374,27 @@ export interface ResourceConfig {
|
|
|
350
374
|
* through a field `render`. RPC-backed resources ignore it.
|
|
351
375
|
*/
|
|
352
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;
|
|
353
398
|
/** How the show view fetches one record. 'rpc' calls the role-gated
|
|
354
399
|
* {camelResource}EventDetail RPC (the resource stays off the REST surface);
|
|
355
400
|
* 'rest' (default) uses adapter.fetchOne. Lets a plain list (no dashboard) —
|
|
@@ -449,15 +494,18 @@ export interface ResourceDashboardConfig {
|
|
|
449
494
|
title?: string;
|
|
450
495
|
/**
|
|
451
496
|
* 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.
|
|
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.
|
|
455
502
|
*/
|
|
456
503
|
tableName?: string;
|
|
457
504
|
/**
|
|
458
505
|
* Static predicates ANDed into every dashboard query (server-side constants,
|
|
459
506
|
* never user input). The STI case: jobs over ops.runs use
|
|
460
|
-
* [{ 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.
|
|
461
509
|
*/
|
|
462
510
|
where?: Array<{ column: string; equals: string }>;
|
|
463
511
|
/** The datetime column for the time histogram */
|
|
@@ -472,7 +520,7 @@ export interface ResourceDashboardConfig {
|
|
|
472
520
|
dimensions: DimensionConfig[];
|
|
473
521
|
/** Primary key column(s) for the event list (default: 'id'). Use array for composite keys. */
|
|
474
522
|
primaryKey?: string | string[];
|
|
475
|
-
/** 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. */
|
|
476
524
|
listColumns: string[];
|
|
477
525
|
/** Columns searchable via ILIKE in the event list */
|
|
478
526
|
searchColumns?: string[];
|
|
@@ -588,6 +636,8 @@ export interface StorageConfig {
|
|
|
588
636
|
}
|
|
589
637
|
|
|
590
638
|
export interface AdminConfig {
|
|
639
|
+
/** IANA display zone for instants. Omit to resolve the viewer's browser zone after mount. */
|
|
640
|
+
timeZone?: string;
|
|
591
641
|
/** Application title */
|
|
592
642
|
title: string;
|
|
593
643
|
/** Application subtitle/description */
|
|
@@ -620,11 +670,9 @@ export interface AdminConfig {
|
|
|
620
670
|
* link (`resource_id` + `resource_type`, often with no foreign key) sets
|
|
621
671
|
* `typeColumn` and `typeValue` as well.
|
|
622
672
|
*/
|
|
623
|
-
|
|
673
|
+
interface AssociationBase {
|
|
624
674
|
/** Shown in the preview, e.g. 'Bookmarks'. */
|
|
625
675
|
label: string;
|
|
626
|
-
/** The related table or resource name, as the adapter knows it. */
|
|
627
|
-
resource: string;
|
|
628
676
|
/** The column on the related table that holds this record's id. */
|
|
629
677
|
foreignKey: string;
|
|
630
678
|
/** The polymorphic type column on the related table, e.g. 'resource_type'. */
|
|
@@ -633,6 +681,23 @@ export interface AssociationConfig {
|
|
|
633
681
|
typeValue?: string;
|
|
634
682
|
}
|
|
635
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
|
+
|
|
636
701
|
/**
|
|
637
702
|
* A named filter shown as a tab above a list ("Current", "Draft"). Scopes are
|
|
638
703
|
* ANDed with the search and any column filters.
|
|
@@ -652,12 +717,22 @@ export interface ScopeConfig {
|
|
|
652
717
|
/** Soft-delete reads: 'include' returns live and deleted rows, 'only' deleted rows. */
|
|
653
718
|
export type DeletedMode = 'include' | 'only';
|
|
654
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
|
+
|
|
655
727
|
export interface ListParams {
|
|
656
728
|
/** Page number (1-indexed) */
|
|
657
729
|
page?: number;
|
|
658
730
|
/** Items per page */
|
|
659
731
|
perPage?: number;
|
|
660
|
-
/**
|
|
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
|
+
*/
|
|
661
736
|
sort?: string;
|
|
662
737
|
/** Sort order */
|
|
663
738
|
order?: 'asc' | 'desc';
|
|
@@ -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
|
+
}
|