@everystack/admin 0.4.3 → 0.4.5

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.
@@ -5,19 +5,23 @@
5
5
  * Fetches via adapter.fetchOne() and displays field values.
6
6
  */
7
7
 
8
- import React, { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
8
+ import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
9
9
  import { View, Text, ScrollView } from 'react-native';
10
10
  import { useAdminConfig } from '../hooks/useAdminConfig';
11
11
  import { useDataAdapter } from '../hooks/useAdminData';
12
- import { LoadingState, ErrorState, Button, Card, Divider } from '@everystack/ui';
12
+ import { LoadingState, ErrorState, Button, Card, Divider, RichContent } from '@everystack/ui';
13
13
  import { ImageDisplay } from '../components/ImageDisplay';
14
14
  import { TagsDisplay } from '../components/TagsDisplay';
15
15
  import { RowActionButton } from '../components/RowActionButton';
16
+ import { DeletePreview } from '../components/DeletePreview';
16
17
  import { FieldColumns } from '../components/FieldColumns';
18
+ import { RelationValue } from '../components/RelationValue';
19
+ import { useRelationLabels, type RelationLabels } from '../hooks/useRelationLabels';
17
20
  import { useEditors } from '../hooks/useEditors';
18
21
  import { resolveColumns, screenLayout } from '../utils/resolve-columns';
19
22
  import { wrapBareText } from '../components/CellText';
20
23
  import { visibleRowActions } from '../utils/row-actions';
24
+ import { restoreAction, softDeleteColumn } from '../utils/standard-actions';
21
25
  import { toCamelCase } from '../utils/naming';
22
26
  import { formatDateOnly } from '../utils/format-cell-value';
23
27
  import type { FieldDefinition, DataRecord, ResourceConfig } from '../types';
@@ -30,10 +34,15 @@ export interface ResourceShowProps {
30
34
  id: string | number;
31
35
  /** Called when the edit button is pressed */
32
36
  onEditPress?: () => void;
33
- /** Called when the delete button is pressed */
34
- onDeletePress?: () => void;
37
+ /**
38
+ * Called when the delete button is pressed. When the resource declares
39
+ * `associations`, Delete opens the delete preview and its Confirm calls this.
40
+ */
41
+ onDeletePress?: () => void | Promise<void>;
35
42
  /** Called when the back/list button is pressed */
36
43
  onBackPress?: () => void;
44
+ /** Open the delete preview on mount (a resource with `associations` only). */
45
+ confirmDelete?: boolean;
37
46
  }
38
47
 
39
48
  export function ResourceShow({
@@ -42,6 +51,7 @@ export function ResourceShow({
42
51
  onEditPress,
43
52
  onDeletePress,
44
53
  onBackPress,
54
+ confirmDelete,
45
55
  }: ResourceShowProps): React.ReactElement {
46
56
  const config = useAdminConfig();
47
57
  const adapter = useDataAdapter();
@@ -51,6 +61,8 @@ export function ResourceShow({
51
61
  const [record, setRecord] = useState<DataRecord | null>(null);
52
62
  const [loading, setLoading] = useState(true);
53
63
  const [error, setError] = useState<Error | null>(null);
64
+ // Declared associations put a preview of the linked rows between Delete and the delete.
65
+ const [previewOpen, setPreviewOpen] = useState(Boolean(confirmDelete));
54
66
 
55
67
  // Latest-request guard: only the most recent load may write state, so an older
56
68
  // id's or an older request's response can't land over a newer one.
@@ -90,6 +102,15 @@ export function ResourceShow({
90
102
  fetchRecord();
91
103
  }, [fetchRecord]);
92
104
 
105
+ // Relation fields print the related row's displayField (one request per related resource).
106
+ const relationFields = useMemo(() => {
107
+ if (!resourceConfig) return [];
108
+ const shown = new Set(resourceConfig.showFields ?? resourceConfig.fields.map(f => f.name));
109
+ return resourceConfig.fields.filter(f => shown.has(f.name) && f.type === 'relation' && !f.render);
110
+ }, [resourceConfig]);
111
+ const relationRows = useMemo(() => (record ? [record] : []), [record]);
112
+ const relationLabels = useRelationLabels(adapter, relationFields, relationRows);
113
+
93
114
  if (!resourceConfig) {
94
115
  return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
95
116
  }
@@ -109,8 +130,14 @@ export function ResourceShow({
109
130
  const label = resourceConfig.label ?? resourceConfig.name;
110
131
  const showFields = resourceConfig.showFields ?? resourceConfig.fields.map(f => f.name);
111
132
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
112
- const canEdit = resourceConfig.actions?.edit !== false;
113
- const canDelete = resourceConfig.actions?.delete !== false;
133
+ // A soft-deleted row shows a banner with Restore instead of Edit and Delete.
134
+ const deletedColumn = softDeleteColumn(resourceConfig);
135
+ const deletedAt = deletedColumn ? record[deletedColumn] : undefined;
136
+ const isDeleted = deletedAt != null;
137
+ const restore = isDeleted ? restoreAction(resourceConfig) : undefined;
138
+ const canEdit = resourceConfig.actions?.edit !== false && !isDeleted;
139
+ const canDelete = resourceConfig.actions?.delete !== false && !isDeleted;
140
+ const associations = resourceConfig.associations?.length ? resourceConfig.associations : undefined;
114
141
  const rowActions = visibleRowActions(resourceConfig.rowActions, record);
115
142
  const layout = screenLayout(resourceConfig, 'show');
116
143
  const columns = layout
@@ -148,11 +175,30 @@ export function ResourceShow({
148
175
  <Button label="Edit" variant="outline" onPress={onEditPress} />
149
176
  )}
150
177
  {canDelete && onDeletePress && (
151
- <Button label="Delete" variant="destructive" onPress={onDeletePress} />
178
+ <Button label="Delete" variant="destructive" onPress={associations ? () => setPreviewOpen(true) : onDeletePress} />
152
179
  )}
153
180
  </View>
154
181
  </View>
155
182
 
183
+ {previewOpen && associations && canDelete && onDeletePress && (
184
+ <DeletePreview
185
+ adapter={adapter}
186
+ label={label}
187
+ id={id}
188
+ associations={associations}
189
+ softDelete={Boolean(deletedColumn)}
190
+ onConfirm={async () => { await onDeletePress(); setPreviewOpen(false); }}
191
+ onCancel={() => setPreviewOpen(false)}
192
+ />
193
+ )}
194
+
195
+ {restore && (
196
+ <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(deletedAt)}`}</Text>
198
+ <RowActionButton action={restore} record={record} recordId={id} adapter={adapter} refetch={refetchQuietly} />
199
+ </View>
200
+ )}
201
+
156
202
  {/* Fields */}
157
203
  <Card style={{ marginHorizontal: 16, marginBottom: 16 }}>
158
204
  {columns ? (
@@ -161,7 +207,7 @@ export function ResourceShow({
161
207
  renderField={(fieldDef, idx) => (
162
208
  <>
163
209
  {idx > 0 && <Divider />}
164
- <FieldRow field={fieldDef} value={record[fieldDef.name]} record={record} />
210
+ <FieldRow field={fieldDef} value={record[fieldDef.name]} record={record} labels={relationLabels} />
165
211
  </>
166
212
  )}
167
213
  />
@@ -172,7 +218,7 @@ export function ResourceShow({
172
218
  return (
173
219
  <React.Fragment key={fieldName}>
174
220
  {idx > 0 && <Divider />}
175
- <FieldRow field={fieldDef} value={value} record={record} />
221
+ <FieldRow field={fieldDef} value={value} record={record} labels={relationLabels} />
176
222
  </React.Fragment>
177
223
  );
178
224
  })}
@@ -204,7 +250,12 @@ async function fetchDetail(
204
250
  }
205
251
  return (await adapter.rpc(`${toCamelCase(resource)}EventDetail`, { id })) as DataRecord;
206
252
  }
207
- return adapter.fetchOne(resource, id);
253
+ const select = resourceConfig?.select;
254
+ // A soft-delete resource loads the row even when it is deleted, so Show can offer Restore.
255
+ if (resourceConfig?.softDelete) {
256
+ return adapter.fetchOne(resource, id, { ...(select ? { select } : {}), deleted: 'include' });
257
+ }
258
+ return select ? adapter.fetchOne(resource, id, { select }) : adapter.fetchOne(resource, id);
208
259
  }
209
260
 
210
261
  // =============================================================================
@@ -215,9 +266,10 @@ interface FieldRowProps {
215
266
  field: FieldDefinition;
216
267
  value: unknown;
217
268
  record: DataRecord;
269
+ labels: RelationLabels;
218
270
  }
219
271
 
220
- function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
272
+ function FieldRow({ field, value, record, labels }: FieldRowProps): React.ReactElement {
221
273
  return (
222
274
  <View className="py-3">
223
275
  <Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
@@ -229,6 +281,11 @@ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
229
281
  <ImageDisplay field={field} value={value} view="show" />
230
282
  ) : field.type === 'tags' ? (
231
283
  <TagsDisplay field={field} value={value} />
284
+ ) : field.type === 'relation' && field.relation && value != null && value !== '' ? (
285
+ <RelationValue resource={field.relation.resource} value={value} labels={labels.get(field.name)} />
286
+ ) : value != null && (field.format === 'html' || field.format === 'markdown') ? (
287
+ // Stored rich text is sanitised at render by ui RichContent (web-only).
288
+ <RichContent value={value} format={field.format} className="text-sm text-gray-900" />
232
289
  ) : (
233
290
  <Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
234
291
  )}
@@ -236,6 +293,11 @@ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
236
293
  );
237
294
  }
238
295
 
296
+ function formatDateTime(value: unknown): string {
297
+ const date = new Date(String(value));
298
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
299
+ }
300
+
239
301
  function formatFieldValue(value: unknown, field: FieldDefinition): string {
240
302
  if (value == null) return '\u2014';
241
303
 
@@ -244,10 +306,8 @@ function formatFieldValue(value: unknown, field: FieldDefinition): string {
244
306
  return value ? 'Yes' : 'No';
245
307
  case 'date':
246
308
  return formatDateOnly(value);
247
- case 'datetime': {
248
- const date = new Date(String(value));
249
- return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
250
- }
309
+ case 'datetime':
310
+ return formatDateTime(value);
251
311
  case 'json':
252
312
  return JSON.stringify(value, null, 2);
253
313
  case 'url':
package/src/types.ts CHANGED
@@ -60,6 +60,8 @@ export interface InputContext {
60
60
  /**
61
61
  * The whole form's current values, keyed by field name (what Save would
62
62
  * send). Read another field here to render a block that depends on it.
63
+ * Required since 0.4.3: code that builds an InputContext by hand, such as a
64
+ * test, must pass it.
63
65
  */
64
66
  values: Record<string, unknown>;
65
67
  }
@@ -314,6 +316,12 @@ export interface ResourceConfig {
314
316
  editFields?: string[];
315
317
  /** Primary key field name */
316
318
  primaryKey?: string;
319
+ /**
320
+ * PostgREST select string for list, show and edit, embeds included, e.g.
321
+ * '*,user:users(name)'. One string for all three. Show an embedded value
322
+ * through a field `render`. RPC-backed resources ignore it.
323
+ */
324
+ select?: string;
317
325
  /** How the show view fetches one record. 'rpc' calls the role-gated
318
326
  * {camelResource}EventDetail RPC (the resource stays off the REST surface);
319
327
  * 'rest' (default) uses adapter.fetchOne. Lets a plain list (no dashboard) —
@@ -328,6 +336,21 @@ export interface ResourceConfig {
328
336
  rowActions?: RowActionConfig[];
329
337
  /** Tabs above the list, each a named filter. Without this the list shows no tabs. */
330
338
  scopes?: ScopeConfig[];
339
+ /**
340
+ * The table soft-deletes. Turns on Restore for deleted rows (in a scope with
341
+ * `deleted: 'only'`, and on Show) and makes Show load a deleted row. Delete
342
+ * stays shown unless `actions.delete` is false.
343
+ */
344
+ softDelete?: {
345
+ /** The soft-delete column, as the API names it. Default 'deletedAt'. */
346
+ column?: string;
347
+ };
348
+ /**
349
+ * Rows in other tables that point at this record. Before a delete, Show lists
350
+ * each one with a count of the linked rows (one request each, at most 12).
351
+ * Without it, Delete works as before.
352
+ */
353
+ associations?: AssociationConfig[];
331
354
  /**
332
355
  * Show a row count on each tab. Off unless set: counts cost queries. The values of one
333
356
  * enum column share one grouped query; every other tab costs one count. Counts follow the
@@ -564,6 +587,24 @@ export interface AdminConfig {
564
587
  // Data Types
565
588
  // =============================================================================
566
589
 
590
+ /**
591
+ * A table that links to a record, counted in the delete preview. A polymorphic
592
+ * link (`resource_id` + `resource_type`, often with no foreign key) sets
593
+ * `typeColumn` and `typeValue` as well.
594
+ */
595
+ export interface AssociationConfig {
596
+ /** Shown in the preview, e.g. 'Bookmarks'. */
597
+ label: string;
598
+ /** The related table or resource name, as the adapter knows it. */
599
+ resource: string;
600
+ /** The column on the related table that holds this record's id. */
601
+ foreignKey: string;
602
+ /** The polymorphic type column on the related table, e.g. 'resource_type'. */
603
+ typeColumn?: string;
604
+ /** The value of `typeColumn` that means this resource, e.g. 'Content'. */
605
+ typeValue?: string;
606
+ }
607
+
567
608
  /**
568
609
  * A named filter shown as a tab above a list ("Current", "Draft"). Scopes are
569
610
  * ANDed with the search and any column filters.
@@ -576,8 +617,13 @@ export interface ScopeConfig {
576
617
  filter?: Record<string, unknown>;
577
618
  /** The scope active when the URL names none. If none is marked, the first is. */
578
619
  default?: boolean;
620
+ /** Soft-deleted rows: 'include' adds them, 'only' lists nothing else. See ListParams.deleted. */
621
+ deleted?: DeletedMode;
579
622
  }
580
623
 
624
+ /** Soft-delete reads: 'include' returns live and deleted rows, 'only' deleted rows. */
625
+ export type DeletedMode = 'include' | 'only';
626
+
581
627
  export interface ListParams {
582
628
  /** Page number (1-indexed) */
583
629
  page?: number;
@@ -597,6 +643,17 @@ export interface ListParams {
597
643
  * returning unfiltered rows. Plain column names only.
598
644
  */
599
645
  searchFields?: string[];
646
+ /**
647
+ * PostgREST select string, embeds included, e.g. '*,user:users(name)'.
648
+ * Passed verbatim to the query builder. Ignored by RPC-backed resources
649
+ * (listRpcs); an RPC decides its own shape.
650
+ */
651
+ select?: string;
652
+ /**
653
+ * Soft-deleted rows, sent as `?deleted=` to the API. RPC-backed resources
654
+ * get it in the RPC body, and the RPC owner decides.
655
+ */
656
+ deleted?: DeletedMode;
600
657
  }
601
658
 
602
659
  export interface ListResult<T = Record<string, unknown>> {
@@ -643,6 +700,8 @@ export interface AggregateParams {
643
700
  search?: string;
644
701
  /** Columns `search` looks in. Required with `search`. Plain column names only. */
645
702
  searchFields?: string[];
703
+ /** Soft-deleted rows, exactly as `ListParams.deleted`, so a Deleted tab counts deleted rows. */
704
+ deleted?: DeletedMode;
646
705
  }
647
706
 
648
707
  export interface AggregateResult {
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { ViewParams } from '../hooks/useViewState';
18
- import type { ListParams } from '../types';
18
+ import type { DeletedMode, ListParams } from '../types';
19
19
 
20
20
  /** Longest search text kept. A pasted paragraph is not a search. */
21
21
  export const MAX_SEARCH_LENGTH = 200;
@@ -86,15 +86,15 @@ export function parseListQuery(params: ViewParams): ListQuery {
86
86
  /**
87
87
  * List state → a `setParams` delta. Every key is present, so a cleared field
88
88
  * removes its key from the URL. Defaults (page 1, no search, the resource's
89
- * `defaultPerPage`) are not written.
89
+ * `defaultPerPage` and `defaultScope`) are not written.
90
90
  */
91
91
  export function listQueryToParams(
92
92
  query: ListQuery,
93
- options: { defaultPerPage?: number } = {},
93
+ options: { defaultPerPage?: number; defaultScope?: string } = {},
94
94
  ): Record<string, string | undefined> {
95
95
  return {
96
96
  q: query.search || undefined,
97
- tab: query.scope || undefined,
97
+ tab: query.scope && query.scope !== options.defaultScope ? query.scope : undefined,
98
98
  sort: query.sort || undefined,
99
99
  dir: query.order,
100
100
  page: query.page && query.page > 1 ? String(query.page) : undefined,
@@ -145,6 +145,8 @@ export interface BuildListParamsOptions {
145
145
  baseFilter?: Record<string, unknown>;
146
146
  /** The active scope's filter, already resolved from the scope id. */
147
147
  scopeFilter?: Record<string, unknown>;
148
+ /** The active scope's `deleted`, copied into ListParams beside the filter. */
149
+ scopeDeleted?: DeletedMode;
148
150
  /** Columns a user may sort by. A sort outside this set is dropped. */
149
151
  sortableFields?: ReadonlySet<string>;
150
152
  /** Used when the query has no valid sort of its own. */
@@ -172,6 +174,7 @@ export function buildListParams(query: ListQuery, options: BuildListParamsOption
172
174
  sort,
173
175
  order,
174
176
  filter: mergeFilters(options.baseFilter, options.scopeFilter),
177
+ ...(options.scopeDeleted ? { deleted: options.scopeDeleted } : {}),
175
178
  };
176
179
  }
177
180
 
@@ -21,6 +21,8 @@ export interface ScopeCountPlan {
21
21
 
22
22
  /** `{ state: 'eq.draft' }` is the one shape that can ride a grouped query. */
23
23
  function equality(scope: ScopeConfig): { column: string; value: string } | null {
24
+ // The grouped query reads live rows only, so a scope over deleted rows counts alone.
25
+ if (scope.deleted) return null;
24
26
  const entries = Object.entries(scope.filter ?? {});
25
27
  if (entries.length !== 1) return null;
26
28
  const [column, raw] = entries[0];
@@ -6,6 +6,9 @@
6
6
  * the same actions column as custom rowActions and share the confirm/busy/error
7
7
  * chrome. Show/Edit navigate via the supplied callbacks; Delete self-serves
8
8
  * through adapter.delete and lets RowActionButton refetch afterward.
9
+ *
10
+ * In a scope with `deleted: 'only'` the rows are soft-deleted: Edit and Delete
11
+ * are hidden, and a resource that declares `softDelete` gets Restore instead.
9
12
  */
10
13
 
11
14
  import type { ResourceConfig, RowActionConfig, DataRecord } from '../types';
@@ -15,32 +18,65 @@ export interface StandardActionNav {
15
18
  onShow?: (record: DataRecord) => void;
16
19
  /** Navigate to the record's edit screen. */
17
20
  onEdit?: (record: DataRecord) => void;
21
+ /**
22
+ * Navigate to the record's Show with its delete preview open. Used by Delete
23
+ * when the resource declares `associations`; without it Delete keeps its two taps.
24
+ */
25
+ onConfirmDelete?: (record: DataRecord) => void;
26
+ }
27
+
28
+ const idOf = (resourceConfig: ResourceConfig, record: DataRecord): string | number =>
29
+ (record[resourceConfig.primaryKey ?? 'id'] as string | number) ?? (record.id as string | number);
30
+
31
+ /** The soft-delete column of a resource that declares `softDelete`, else undefined. */
32
+ export function softDeleteColumn(resourceConfig: ResourceConfig): string | undefined {
33
+ return resourceConfig.softDelete ? resourceConfig.softDelete.column ?? 'deletedAt' : undefined;
34
+ }
35
+
36
+ /** Restore a soft-deleted row: clear its column, matching deleted rows only. Undefined without `softDelete`. */
37
+ export function restoreAction(resourceConfig: ResourceConfig): RowActionConfig | undefined {
38
+ const column = softDeleteColumn(resourceConfig);
39
+ if (!column) return undefined;
40
+ return {
41
+ id: '__restore',
42
+ label: 'Restore',
43
+ variant: 'outline',
44
+ confirm: 'Confirm restore',
45
+ onPress: async ({ record, adapter }) => {
46
+ await adapter.update(resourceConfig.name, idOf(resourceConfig, record), { [column]: null }, { deleted: 'only' });
47
+ },
48
+ };
18
49
  }
19
50
 
20
51
  export function standardRowActions(
21
52
  resourceConfig: ResourceConfig,
22
53
  nav: StandardActionNav,
54
+ scope: { deletedOnly?: boolean } = {},
23
55
  ): RowActionConfig[] {
24
56
  const actions = resourceConfig.actions ?? {};
25
- const primaryKey = resourceConfig.primaryKey ?? 'id';
26
- const idOf = (record: DataRecord): string | number =>
27
- (record[primaryKey] as string | number) ?? (record.id as string | number);
28
57
 
29
58
  const out: RowActionConfig[] = [];
30
59
 
31
60
  if (actions.show !== false && nav.onShow) {
32
61
  out.push({ id: '__show', label: 'Show', variant: 'outline', onPress: ({ record }) => nav.onShow!(record) });
33
62
  }
63
+ if (scope.deletedOnly) {
64
+ const restore = restoreAction(resourceConfig);
65
+ if (restore) out.push(restore);
66
+ return out;
67
+ }
34
68
  if (actions.edit !== false && nav.onEdit) {
35
69
  out.push({ id: '__edit', label: 'Edit', variant: 'outline', onPress: ({ record }) => nav.onEdit!(record) });
36
70
  }
37
- if (actions.delete !== false) {
71
+ if (actions.delete !== false && resourceConfig.associations?.length && nav.onConfirmDelete) {
72
+ out.push({ id: '__delete', label: 'Delete', variant: 'destructive', onPress: ({ record }) => nav.onConfirmDelete!(record) });
73
+ } else if (actions.delete !== false) {
38
74
  out.push({
39
75
  id: '__delete',
40
76
  label: 'Delete',
41
77
  variant: 'destructive',
42
78
  confirm: 'Confirm delete',
43
- onPress: ({ record, adapter }) => adapter.delete(resourceConfig.name, idOf(record)),
79
+ onPress: ({ record, adapter }) => adapter.delete(resourceConfig.name, idOf(resourceConfig, record)),
44
80
  });
45
81
  }
46
82
 
@@ -15,6 +15,7 @@ export function paramsToFilters(params: ViewParams, dimensions: DimensionConfig[
15
15
  const filters: DashboardFilters = {};
16
16
  if (params.from && params.to) filters.time = [params.from, params.to];
17
17
  for (const dim of dimensions) {
18
+ if (RESERVED_PARAMS.has(dim.column)) continue;
18
19
  const v = params[dim.column];
19
20
  if (v) filters[dim.column] = v.split(',');
20
21
  }
@@ -24,12 +25,13 @@ export function paramsToFilters(params: ViewParams, dimensions: DimensionConfig[
24
25
  /** Analytics filters → a query-param delta that clears then sets controlled keys. */
25
26
  export function filtersToParams(filters: DashboardFilters, dimensions: DimensionConfig[]): Record<string, string | undefined> {
26
27
  const update: Record<string, string | undefined> = { from: undefined, to: undefined };
27
- for (const dim of dimensions) update[dim.column] = undefined;
28
+ for (const dim of dimensions) if (!RESERVED_PARAMS.has(dim.column)) update[dim.column] = undefined;
28
29
  if (filters.time && filters.time[0] && filters.time[1]) {
29
30
  update.from = filters.time[0];
30
31
  update.to = filters.time[1];
31
32
  }
32
33
  for (const dim of dimensions) {
34
+ if (RESERVED_PARAMS.has(dim.column)) continue;
33
35
  const v = filters[dim.column] as string[] | undefined;
34
36
  if (v && v.length) update[dim.column] = v.join(',');
35
37
  }