@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.
@@ -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.sortable).map(f => f.name) ?? []),
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: mergeFilters(baseFilter, legacySearchFilter),
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: mergeFilters(listParams.filter, legacySearchFilter),
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
- {/* Header */}
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 message={error.message} onRetry={refetch} />
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.sortable,
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
- const passthrough = paramsToColumnFilters(params, fieldNames, exclude);
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 { formatDateOnly, formatDateTime } from '../utils/format-cell-value';
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
- <Text className="text-sm text-red-700">{`Deleted ${formatDateTime(deletedField(resourceConfig, deletedColumn), deletedAt)}`}</Text>
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
- /** Whether field is sortable in tables */
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
- /** How the column holds the time. Only 'wall' (a naive `timestamp`) exists today. */
236
+ /** Literal digits in a naive timestamp column, rather than an instant. */
235
237
  storage: 'wall';
236
238
  }
237
239
 
238
- /** Any other field: neither `timezone` nor `storage` is set. */
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
- /** `storage` is required whenever `timezone` is set, and both only on a `datetime` field. */
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 name. Required whenever the
453
- * exposed resource name differs from the table the raw aggregation queries
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 the event list */
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
- export interface AssociationConfig {
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
- /** Sort field */
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
+ }