@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 CHANGED
@@ -4,6 +4,40 @@ All notable changes to `@everystack/admin` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.8] - 2026-10-18
8
+
9
+ ### Added
10
+
11
+ - a table-mapped resource reaches the adapter with its resource name
12
+
13
+ ### Documentation
14
+
15
+ - select examples use declared relation names; an alias answers 400
16
+
17
+ ## [0.4.7] - 2026-10-18
18
+
19
+ ### BREAKING CHANGES
20
+
21
+ - list fields sort by default; the API refuses an order it cannot apply (#92)
22
+ - datetimes show in the viewer's zone and edit round-trip in it (#94)
23
+ - eventList applies the list-protocol filter; a time bound replaces the default window
24
+ - Add filter on the list; a datetime column adds its own date range row that owns every URL value of that column (see docs/upgrading.md)
25
+
26
+ ### Added
27
+
28
+ - add ResourceConfig.where and table, a view filter and forced default for one slice of a table
29
+ - date foundations: zone resolver, DST-correct day bounds, zone-aware DateInput (#93)
30
+ - date-range helpers: DST-correct UTC bounds and presets
31
+ - DateConditionInput range mode: draft From/To days, Apply once, presets fill
32
+ - block list fetches until the date range and its zone are known
33
+ - eventList ilike on search columns, with literal matching and no lone surrogates
34
+ - repeated URL parameters survive the view state; ignoreParams for route params
35
+ - read a date range back from its URL instants; refuse what the URL cannot carry
36
+ - the list date range lives in native repeated instant keys on its field
37
+ - which datetime columns the list can filter with a date range row
38
+ - the DateInput calendar floats above the page and closes on an outside press or Escape
39
+ - the Add filter menu is a floating card that closes on an outside press or Escape
40
+
7
41
  ## [0.4.6] - 2026-10-16
8
42
 
9
43
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Declarative admin dashboard framework for PostgREST APIs",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -32,6 +32,11 @@
32
32
  "types": "./src/rpc/resource-dashboard-rpc.ts",
33
33
  "default": "./src/rpc/resource-dashboard-rpc.ts"
34
34
  },
35
+ "./sortable": {
36
+ "source": "./src/utils/field-sortable.ts",
37
+ "types": "./src/utils/field-sortable.ts",
38
+ "default": "./src/utils/field-sortable.ts"
39
+ },
35
40
  "./presets/twitter": {
36
41
  "source": "./src/presets/twitter.ts",
37
42
  "types": "./src/presets/twitter.ts",
@@ -60,8 +65,8 @@
60
65
  },
61
66
  "peerDependencies": {
62
67
  "drizzle-orm": "0.41.0",
63
- "@everystack/api": ">=0.4.4",
64
- "@everystack/ui": ">=0.4.5",
68
+ "@everystack/api": ">=0.4.7",
69
+ "@everystack/ui": ">=0.4.6",
65
70
  "@tiptap/extension-code": ">=3.0.0",
66
71
  "@tiptap/extension-code-block": ">=3.0.0",
67
72
  "@tiptap/extension-image": ">=3.0.0",
@@ -123,12 +128,14 @@
123
128
  "jsdom": "^20.0.3",
124
129
  "parse5": "^7.3.0",
125
130
  "react": "19.2.0",
131
+ "react-dom": "19.2.0",
126
132
  "react-native": "0.83.6",
133
+ "react-native-web": "0.20.0",
127
134
  "react-test-renderer": "19.2.0",
128
135
  "ts-jest": "29.4.9",
129
136
  "typescript": "5.9.3",
130
- "@everystack/ui": "0.4.5",
131
- "@everystack/api": "0.4.6"
137
+ "@everystack/api": "0.4.8",
138
+ "@everystack/ui": "0.4.6"
132
139
  },
133
140
  "scripts": {
134
141
  "test": "jest",
@@ -0,0 +1,19 @@
1
+ /** An adapter cannot order a list by the field the admin sent. */
2
+ export class CannotSortError extends Error {
3
+ readonly field: string;
4
+
5
+ constructor(field: string) {
6
+ super(`Cannot sort by ${field}`);
7
+ this.name = 'CannotSortError';
8
+ this.field = field;
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Whether an error is a sort refusal. Also matches one thrown by another copy of
14
+ * this package, where `instanceof` fails: an Error named 'CannotSortError' with a string field.
15
+ */
16
+ export function isCannotSortError(error: unknown): error is CannotSortError {
17
+ return error instanceof CannotSortError
18
+ || (error instanceof Error && error.name === 'CannotSortError' && typeof (error as { field?: unknown }).field === 'string');
19
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { createClient, type Client, type ClientOptions, type QueryBuilder } from '@everystack/api/client';
9
9
  import { EverystackApiError } from './EverystackApiError';
10
+ import { CannotSortError } from './CannotSortError';
10
11
  import { toCamelCaseKeys } from '../utils/naming';
11
12
  import { mergeFilters, normalizeSearchText } from '../utils/list-query';
12
13
  import type { DataAdapter } from './types';
@@ -46,7 +47,7 @@ const ZERO_ROW_DELETE = 'No row was deleted. It may not exist, or you may not ha
46
47
  export interface EverystackAdapterOptions extends ClientOptions {
47
48
  /**
48
49
  * Resources whose list rows come from an RPC instead of the PostgREST table.
49
- * Maps resource name → RPC function name. The RPC speaks the same list
50
+ * Maps resource name (the table name, when the resource sets `table`) → RPC function name. The RPC speaks the same list
50
51
  * protocol — body `{ limit, offset, sort, dir, filter }`, result
51
52
  * `{ data, total }` — so the list UI is identical to the REST path. Used to
52
53
  * serve DB-free observability tables (logs, crashes) from the S3 archive.
@@ -54,7 +55,7 @@ export interface EverystackAdapterOptions extends ClientOptions {
54
55
  listRpcs?: Record<string, string>;
55
56
  /**
56
57
  * Resources whose single-record reads + writes go through an RPC instead of
57
- * the PostgREST table. Maps resource name → RPC function name. The RPC is
58
+ * the PostgREST table. Maps resource name (the table name, when the resource sets `table`) → RPC function name. The RPC is
58
59
  * action-dispatched: body `{ action: 'get'|'create'|'update'|'delete', id?,
59
60
  * data? }` → the record (get/create/update) or `{}` (delete). Used to serve
60
61
  * DB-free mutable config (alert rules) from an S3 object with CAS. Pair with
@@ -94,14 +95,22 @@ export class EverystackAdapter implements DataAdapter {
94
95
  : params.filter;
95
96
  // Accept either the S3 list shape ({ data, total }) or the dashboard eventList shape
96
97
  // ({ rows, total }) so a resource can be served by either kind of RPC.
97
- const result = await this.rpc<{ data?: T[]; rows?: T[]; total: number }>(listRpc, {
98
- limit: perPage,
99
- offset,
100
- sort: params.sort,
101
- dir: params.order ?? 'asc',
102
- filter,
103
- ...(params.deleted ? { deleted: params.deleted } : {}),
104
- });
98
+ let result: { data?: T[]; rows?: T[]; total: number };
99
+ try {
100
+ result = await this.rpc<typeof result>(listRpc, {
101
+ limit: perPage,
102
+ offset,
103
+ sort: params.sort,
104
+ dir: params.order ?? 'asc',
105
+ filter,
106
+ ...(params.deleted ? { deleted: params.deleted } : {}),
107
+ });
108
+ } catch (error) {
109
+ if (params.sort && error instanceof EverystackApiError && error.status === 400 && error.code === 'invalid_order') {
110
+ throw new CannotSortError(params.sort);
111
+ }
112
+ throw error;
113
+ }
105
114
  // Normalize row keys to camelCase so the list columns resolve regardless of whether
106
115
  // the RPC returns snake_case (e.g. jobsEventList over ops.runs) or camelCase (S3 lists,
107
116
  // already camelCase so this is a no-op). The REST path is camelCase too.
@@ -133,6 +142,9 @@ export class EverystackAdapter implements DataAdapter {
133
142
  const result = await builder.executeWithCount();
134
143
 
135
144
  if (result.error) {
145
+ if (params.sort && result.error.status === 400 && result.error.code === 'invalid_order') {
146
+ throw new CannotSortError(params.sort);
147
+ }
136
148
  throw new Error(result.error.message);
137
149
  }
138
150
 
@@ -147,7 +159,7 @@ export class EverystackAdapter implements DataAdapter {
147
159
  async fetchOne<T extends DataRecord = DataRecord>(
148
160
  resource: string,
149
161
  id: string | number,
150
- params?: { select?: string; deleted?: DeletedMode },
162
+ params?: { select?: string; deleted?: DeletedMode; filter?: Record<string, unknown> },
151
163
  ): Promise<T> {
152
164
  // RPC-backed single read (e.g. DB-free alert rules from the S3 config object).
153
165
  const crudRpc = this.crudRpcs[resource];
@@ -161,7 +173,7 @@ export class EverystackAdapter implements DataAdapter {
161
173
  return record;
162
174
  }
163
175
 
164
- let builder = this.client.from(resource).eq('id', String(id));
176
+ let builder = applyFilters(this.client.from(resource).eq('id', String(id)), params?.filter);
165
177
  if (params?.select) builder = builder.select(params.select);
166
178
  if (params?.deleted) builder = builder.deleted(params.deleted);
167
179
  const result = await builder.single();
@@ -197,7 +209,7 @@ export class EverystackAdapter implements DataAdapter {
197
209
  resource: string,
198
210
  id: string | number,
199
211
  data: Partial<T>,
200
- params?: { deleted?: DeletedMode },
212
+ params?: { deleted?: DeletedMode; filter?: Record<string, unknown> },
201
213
  ): Promise<T> {
202
214
  const crudRpc = this.crudRpcs[resource];
203
215
  if (crudRpc) {
@@ -209,7 +221,7 @@ export class EverystackAdapter implements DataAdapter {
209
221
  }));
210
222
  }
211
223
 
212
- let builder = this.client.from(resource).eq('id', String(id));
224
+ let builder = applyFilters(this.client.from(resource).eq('id', String(id)), params?.filter);
213
225
  if (params?.deleted) builder = builder.deleted(params.deleted);
214
226
  const result = await builder.update(data as Record<string, unknown>);
215
227
 
@@ -225,14 +237,14 @@ export class EverystackAdapter implements DataAdapter {
225
237
  return rows[0];
226
238
  }
227
239
 
228
- async delete(resource: string, id: string | number): Promise<void> {
240
+ async delete(resource: string, id: string | number, params?: { filter?: Record<string, unknown> }): Promise<void> {
229
241
  const crudRpc = this.crudRpcs[resource];
230
242
  if (crudRpc) {
231
243
  this.rpcResult(await this.rpc<{ error?: string }>(crudRpc, { action: 'delete', id: String(id) }));
232
244
  return;
233
245
  }
234
246
 
235
- const result = await this.client.from(resource).eq('id', String(id)).remove();
247
+ const result = await applyFilters(this.client.from(resource).eq('id', String(id)), params?.filter).remove();
236
248
 
237
249
  if (result.error) {
238
250
  throw new EverystackApiError(result.error);
@@ -34,33 +34,48 @@ export interface DataAdapter {
34
34
  * Fetch a single record by ID. `params.select` is a PostgREST select string,
35
35
  * embeds included. EverystackAdapter ignores it on the crudRpcs branch.
36
36
  * `params.deleted` reads a soft-deleted row (see ListParams.deleted).
37
+ *
38
+ * `params.filter` (same shape as ListParams.filter) is ANDed with the id: a row
39
+ * that matches the id but not the filter is not found. fetchOne, update and
40
+ * delete must all honor it in the one request they send. `ResourceConfig.where`
41
+ * depends on it, and the types cannot enforce it: a custom adapter that drops it
42
+ * lets an id-addressed call reach a row outside the `where` slice.
43
+ * EverystackAdapter honors it on the REST path; its crudRpcs branch ignores it.
44
+ *
45
+ * `params.resource` is the resource name, set only when `ResourceConfig.table` maps the
46
+ * resource to another table (then `resource` above is the table). See ListParams.resource.
37
47
  */
38
48
  fetchOne<T extends DataRecord = DataRecord>(
39
49
  resource: string,
40
50
  id: string | number,
41
- params?: { select?: string; deleted?: DeletedMode }
51
+ params?: { select?: string; deleted?: DeletedMode; filter?: Record<string, unknown>; resource?: string }
42
52
  ): Promise<T>;
43
53
 
44
54
  /**
45
- * Create a new record
55
+ * Create a new record. `params.resource` is the resource name, set only when
56
+ * `ResourceConfig.table` maps the resource to another table (then `resource` above is the
57
+ * table). See ListParams.resource.
46
58
  */
47
- create<T extends DataRecord = DataRecord>(resource: string, data: Omit<T, 'id'>): Promise<T>;
59
+ create<T extends DataRecord = DataRecord>(resource: string, data: Omit<T, 'id'>, params?: { resource?: string }): Promise<T>;
48
60
 
49
61
  /**
50
62
  * Update an existing record. `params.deleted` lets the write match a
51
63
  * soft-deleted row: Restore sends `{ [column]: null }` with `{ deleted: 'only' }`.
64
+ * `params.filter` is ANDed with the id in the write itself (see fetchOne).
65
+ * `params.resource`: see fetchOne.
52
66
  */
53
67
  update<T extends DataRecord = DataRecord>(
54
68
  resource: string,
55
69
  id: string | number,
56
70
  data: Partial<T>,
57
- params?: { deleted?: DeletedMode }
71
+ params?: { deleted?: DeletedMode; filter?: Record<string, unknown>; resource?: string }
58
72
  ): Promise<T>;
59
73
 
60
74
  /**
61
- * Delete a record
75
+ * Delete a record. `params.filter` is ANDed with the id in the delete itself
76
+ * (see fetchOne). `params.resource`: see fetchOne.
62
77
  */
63
- delete(resource: string, id: string | number): Promise<void>;
78
+ delete(resource: string, id: string | number, params?: { filter?: Record<string, unknown>; resource?: string }): Promise<void>;
64
79
 
65
80
  /**
66
81
  * Fetch aggregate data (count, sum, avg, min, max) with optional grouping.
@@ -0,0 +1,148 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Text, View } from 'react-native';
3
+ import { instantToParts, parseDay } from '@everystack/ui/date-time';
4
+ import { Button, DateInput, nowInZone, useTimeZone, zoneAbbreviation } from '@everystack/ui';
5
+ import type { DateRangeConfig, DateRangePreset } from '../types';
6
+ import {
7
+ daysToConditions, instantsToDays, presetRange, type DateRange, type DateRangeFilter,
8
+ } from '../utils/date-range';
9
+
10
+ export interface DateConditionInputProps {
11
+ mode: 'range';
12
+ field: string;
13
+ /** The column's name, shown in bold before From. */
14
+ label?: string;
15
+ value: DateRangeFilter | null;
16
+ invalidReason?: string;
17
+ presets?: DateRangePreset[];
18
+ weekStartsOn?: DateRangeConfig['weekStartsOn'];
19
+ onApply: (values: string[]) => void;
20
+ onClear: () => void;
21
+ /** Given for a row the user added: a Remove button drops the row. */
22
+ onRemove?: () => void;
23
+ }
24
+
25
+ const PRESET_LABELS: Record<DateRangePreset, string> = {
26
+ today: 'Today', yesterday: 'Yesterday', last7Days: 'Last 7 days', last30Days: 'Last 30 days',
27
+ thisWeek: 'This week', lastWeek: 'Last week', thisMonth: 'This month', lastMonth: 'Last month',
28
+ };
29
+
30
+ /** Edits local calendar-day drafts; the parent owns the applied range and navigation. */
31
+ export function DateConditionInput({
32
+ field, label, value, invalidReason, presets = [], weekStartsOn = 1, onApply, onClear, onRemove,
33
+ }: DateConditionInputProps): React.ReactElement {
34
+ const zone = useTimeZone();
35
+ const [todayZone, setTodayZone] = useState<string | null>(null);
36
+ // Only after mount: an empty draft names today's zone, and reading the clock in render would let server and client markup differ.
37
+ useEffect(() => {
38
+ setTodayZone(zone.ready ? dayAbbreviation('', zone.timeZone) : null);
39
+ }, [zone.ready, zone.timeZone]);
40
+ const appliedDays = value && zone.ready && invalidReason === undefined ? instantsToDays(value, zone.timeZone) : null;
41
+ const from = appliedDays?.from ?? '';
42
+ const to = appliedDays?.to ?? '';
43
+ const [draft, setDraft] = useState<DateRange>({ from, to });
44
+ useEffect(() => { setDraft({ from, to }); }, [value?.gte, value?.lt, zone.ready, zone.timeZone, from, to, invalidReason]);
45
+
46
+ const validDays = parseDay(draft.from) !== null && parseDay(draft.to) !== null;
47
+ const conditions = validDays && zone.ready ? daysToConditions(field, draft, zone.timeZone) : undefined;
48
+ const canApply = conditions?.kind === 'ok';
49
+ const error = !validDays && (draft.from || draft.to) ? 'From and To must both be valid YYYY-MM-DD days.'
50
+ : conditions?.kind === 'invalid' ? conditions.reason : undefined;
51
+ const customLabel = value && !appliedDays
52
+ ? zone.ready
53
+ ? `Custom range: ${formatInZone(value.gte, zone.timeZone)} ${instantAbbreviation(value.gte, zone.timeZone)} to before ${formatInZone(value.lt, zone.timeZone)} ${instantAbbreviation(value.lt, zone.timeZone)} (${value.gte} to ${value.lt} UTC)`
54
+ : `Custom range (time zone resolving): ${value.gte} to before ${value.lt} UTC`
55
+ : undefined;
56
+
57
+ function apply(): void {
58
+ if (conditions?.kind !== 'ok') return;
59
+ onApply(conditions.values);
60
+ }
61
+
62
+ function clear(): void {
63
+ setDraft({ from: '', to: '' });
64
+ onClear();
65
+ }
66
+
67
+ function fillPreset(preset: DateRangePreset): void {
68
+ if (!zone.ready) return;
69
+ const now = nowInZone(zone.timeZone);
70
+ const today = `${String(now.year).padStart(4, '0')}-${String(now.month + 1).padStart(2, '0')}-${String(now.day).padStart(2, '0')}`;
71
+ setDraft(presetRange(preset, today, weekStartsOn));
72
+ }
73
+
74
+ return (
75
+ <View className="gap-2 bg-gray-100 border border-gray-200 rounded-xl px-3 py-2">
76
+ <View style={{ zIndex: 1 }} className="flex-row flex-wrap items-center gap-2">
77
+ {label !== undefined && <Text testID="date-range-label" className="text-sm font-bold text-gray-900">{label}</Text>}
78
+ <View testID="date-range-from" style={{ zIndex: 2 }} className="flex-row items-center gap-2">
79
+ <Text className="text-sm text-gray-600">From</Text>
80
+ <DateInput value={draft.from} granularity="date" weekStartsOn={weekStartsOn}
81
+ placeholder="Select From date" disabled={!zone.ready}
82
+ suffix={zone.ready ? stampZone(draft.from, zone.timeZone, todayZone) ?? undefined : undefined}
83
+ onValueChange={next => setDraft(current => ({ ...current, from: next }))} />
84
+ </View>
85
+ <View testID="date-range-to" style={{ zIndex: 1 }} className="flex-row items-center gap-2">
86
+ <Text className="text-sm text-gray-600">To</Text>
87
+ <DateInput value={draft.to} granularity="date" weekStartsOn={weekStartsOn}
88
+ placeholder="Select To date" disabled={!zone.ready}
89
+ suffix={zone.ready ? stampZone(draft.to, zone.timeZone, todayZone) ?? undefined : undefined}
90
+ onValueChange={next => setDraft(current => ({ ...current, to: next }))} />
91
+ </View>
92
+ {!zone.ready && <Text className="text-sm text-gray-500">Resolving time zone…</Text>}
93
+ {presets.map(preset => (
94
+ <View key={preset} testID={`date-range-preset-${preset}`}>
95
+ <Button label={PRESET_LABELS[preset]} variant="outline" disabled={!zone.ready} onPress={() => fillPreset(preset)} />
96
+ </View>
97
+ ))}
98
+ <View testID="date-range-actions" className="ml-auto flex-row items-center gap-2">
99
+ <View testID="date-range-apply">
100
+ <Button label="Apply" disabled={!canApply} onPress={apply} />
101
+ </View>
102
+ {(value !== null || draft.from || draft.to || invalidReason !== undefined) && (
103
+ <View testID="date-range-clear">
104
+ <Button label="Clear" variant="ghost" onPress={clear} />
105
+ </View>
106
+ )}
107
+ {onRemove && (
108
+ <View testID="date-range-remove">
109
+ <Button label="Remove" variant="ghost" onPress={onRemove} />
110
+ </View>
111
+ )}
112
+ </View>
113
+ </View>
114
+ {customLabel && <Text testID="date-range-custom" className="text-sm text-gray-600">{customLabel}</Text>}
115
+ {invalidReason !== undefined && <Text testID="date-range-invalid" accessibilityRole="alert" className="text-sm text-red-700">{invalidReason}</Text>}
116
+ {error && <Text testID="date-range-error" accessibilityRole="alert" className="text-sm text-red-700">{error}</Text>}
117
+ </View>
118
+ );
119
+ }
120
+
121
+ /** Exact local clock digits for a validated instant; custom ranges keep their milliseconds. */
122
+ function formatInZone(value: string, timeZone: string): string {
123
+ const parts = instantToParts(value, timeZone)!;
124
+ const pad = (n: number, width = 2): string => String(n).padStart(width, '0');
125
+ return `${pad(parts.year, 4)}-${pad(parts.month + 1)}-${pad(parts.day)} ${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}.${pad(parts.millisecond, 3)}`;
126
+ }
127
+
128
+ /** A draft day names its own day's zone; an empty draft names today's once that is known. */
129
+ function stampZone(day: string, timeZone: string, todayZone: string | null): string | null {
130
+ return parseDay(day) ? dayAbbreviation(day, timeZone) : todayZone;
131
+ }
132
+
133
+ /** The zone's short name on a draft day (noon, so never in a DST gap or overlap); today's when the draft is empty. */
134
+ function dayAbbreviation(day: string, timeZone: string): string {
135
+ const parsed = parseDay(day);
136
+ const parts = parsed
137
+ ? { year: parsed.year, month: parsed.month - 1, day: parsed.day, hour: 12, minute: 0, second: 0, millisecond: 0 }
138
+ : { ...nowInZone(timeZone), hour: 12, minute: 0, second: 0, millisecond: 0 };
139
+ return zoneAbbreviation(parts, timeZone);
140
+ }
141
+
142
+ /** The zone's short name at an exact validated instant: 'PST', 'PDT', or 'GMT+9' where English has no letters. */
143
+ function instantAbbreviation(value: string, timeZone: string): string {
144
+ const name = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'short' })
145
+ .formatToParts(new Date(value))
146
+ .find(part => part.type === 'timeZoneName');
147
+ return name?.value ?? timeZone;
148
+ }
@@ -0,0 +1,85 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import { Button, useDismissOnOutsidePress } from '@everystack/ui';
4
+ import type { DateRangePreset } from '../types';
5
+ import type { DateRangeFilter } from '../utils/date-range';
6
+ import { DateConditionInput, type DateConditionInputProps } from './DateConditionInput';
7
+
8
+ export interface DateFilterRow {
9
+ field: string;
10
+ /** The column's name, shown in bold. */
11
+ label: string;
12
+ value: DateRangeFilter | null;
13
+ invalidReason?: string;
14
+ presets?: DateRangePreset[];
15
+ weekStartsOn?: DateConditionInputProps['weekStartsOn'];
16
+ /** True for a row the user added; the pinned row cannot be removed. */
17
+ removable: boolean;
18
+ }
19
+
20
+ export interface DateFilterBarProps {
21
+ rows: DateFilterRow[];
22
+ /** Datetime columns that have no row yet. */
23
+ addable: Array<{ field: string; label: string }>;
24
+ onApply: (field: string, values: string[]) => void;
25
+ onClear: (field: string) => void;
26
+ onRemove: (field: string) => void;
27
+ onAdd: (field: string) => void;
28
+ }
29
+
30
+ /** One date range row per filtered datetime column, and an Add filter menu for the rest. */
31
+ export function DateFilterBar({ rows, addable, onApply, onClear, onRemove, onAdd }: DateFilterBarProps): React.ReactElement | null {
32
+ const [open, setOpen] = useState(false);
33
+ const addRef = useRef<View>(null);
34
+ useDismissOnOutsidePress(addRef, open, () => setOpen(false));
35
+ if (rows.length === 0 && addable.length === 0) return null;
36
+ return (
37
+ // Each View is its own stacking context: an open calendar must paint above the page content after the bar,
38
+ // and above the rows after its own row, so the bar and every row carry a descending z-index.
39
+ <View style={{ zIndex: 1 }} className="px-4 py-3 gap-2">
40
+ {rows.map((row, index) => (
41
+ <View key={row.field} testID={`date-filter-row-${row.field}`} style={{ zIndex: rows.length - index + 1 }}>
42
+ <DateConditionInput
43
+ mode="range"
44
+ field={row.field}
45
+ label={row.label}
46
+ value={row.value}
47
+ invalidReason={row.invalidReason}
48
+ presets={row.presets}
49
+ weekStartsOn={row.weekStartsOn}
50
+ onApply={values => onApply(row.field, values)}
51
+ onClear={() => onClear(row.field)}
52
+ onRemove={row.removable ? () => onRemove(row.field) : undefined}
53
+ />
54
+ </View>
55
+ ))}
56
+ {addable.length > 0 && (
57
+ // The open card lifts this block above the content after the bar (every View is its own stacking context).
58
+ <View ref={addRef} style={open ? { zIndex: 50, alignSelf: 'flex-start' } : { alignSelf: 'flex-start' }}>
59
+ <View testID="date-filter-add">
60
+ <Button label="Add filter" variant="outline" onPress={() => setOpen(current => !current)} />
61
+ </View>
62
+ {open && (
63
+ <View
64
+ testID="date-filter-menu"
65
+ style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, minWidth: 200, zIndex: 50 }}
66
+ className="border border-gray-200 rounded-lg bg-white shadow-lg overflow-hidden py-1"
67
+ >
68
+ {addable.map(option => (
69
+ <View key={option.field} testID={`date-filter-option-${option.field}`}>
70
+ <Pressable
71
+ accessibilityRole="menuitem"
72
+ onPress={() => { setOpen(false); onAdd(option.field); }}
73
+ className="px-4 py-2"
74
+ >
75
+ <Text className="text-sm text-gray-900">{option.label}</Text>
76
+ </Pressable>
77
+ </View>
78
+ ))}
79
+ </View>
80
+ )}
81
+ </View>
82
+ )}
83
+ </View>
84
+ );
85
+ }
@@ -0,0 +1,37 @@
1
+ import React, { useState } from 'react';
2
+ import { Platform, Pressable, Text, View } from 'react-native';
3
+ import { useTimeZone } from '@everystack/ui';
4
+ import type { FieldDefinition } from '../types';
5
+ import { dateTimeRawValue, formatCellValue } from '../utils/format-cell-value';
6
+ import { isWallClock } from '../utils/wall-clock';
7
+
8
+ /** One datetime presentation for list cells, records and deletion metadata. */
9
+ export function DateTimeValue({ field, value }: {
10
+ field: FieldDefinition;
11
+ value: unknown;
12
+ }): React.ReactElement {
13
+ const zone = useTimeZone();
14
+ const [expanded, setExpanded] = useState(false);
15
+ const raw = isWallClock(field) || zone.ready ? dateTimeRawValue(field, value) : null;
16
+ const content = (
17
+ <>
18
+ <Text className="text-sm text-gray-900">{formatCellValue(field, value, zone.timeZone)}</Text>
19
+ {raw && (
20
+ <Pressable
21
+ testID="datetime-value-detail-toggle"
22
+ accessibilityRole="button"
23
+ accessibilityLabel="Show raw date value"
24
+ accessibilityState={{ expanded }}
25
+ onPress={event => { event?.stopPropagation(); setExpanded(open => !open); }}
26
+ >
27
+ <Text className="text-xs text-gray-500">{expanded ? 'Hide raw value' : 'Raw value'}</Text>
28
+ </Pressable>
29
+ )}
30
+ {raw && expanded && <Text testID="datetime-value-detail" selectable className="text-xs text-gray-600">{raw}</Text>}
31
+ </>
32
+ );
33
+ // React Native Web filters title out of View props. The DOM wrapper owns hover detail.
34
+ return Platform.OS === 'web'
35
+ ? <div data-testid="datetime-value" title={raw ?? undefined}>{content}</div>
36
+ : <View testID="datetime-value">{content}</View>;
37
+ }
@@ -10,11 +10,13 @@ import { Button } from '@everystack/ui';
10
10
  import type { DataAdapter } from '../adapters/types';
11
11
  import type { AssociationConfig } from '../types';
12
12
  import { safeRelationId } from '../hooks/useRelationLabels';
13
+ import { useRawDataAdapter } from '../hooks/useAdminData';
13
14
 
14
15
  /** Each association costs one request, so the preview counts at most this many. */
15
16
  export const MAX_ASSOCIATIONS = 12;
16
17
 
17
18
  export interface DeletePreviewProps {
19
+ /** The screen's adapter (useDataAdapter), for associations named by `resource`. */
18
20
  adapter: DataAdapter;
19
21
  /** e.g. 'Contents' */
20
22
  label: string;
@@ -27,7 +29,11 @@ export interface DeletePreviewProps {
27
29
  onCancel: () => void;
28
30
  }
29
31
 
30
- /** The linked rows of one association: a `count` aggregate when the adapter has one, else a one-row list's total. */
32
+ /**
33
+ * The linked rows of one association: a `count` aggregate when the adapter has one, else a one-row
34
+ * list's total. It reads `table ?? resource` on the adapter it is given; the caller picks that
35
+ * adapter (the raw one for `table`, so the name is never read as a resource).
36
+ */
31
37
  export async function countAssociation(adapter: DataAdapter, association: AssociationConfig, id: string | number): Promise<number> {
32
38
  const safeId = safeRelationId(id);
33
39
  if (safeId === null) throw new Error('Association count needs a safe record id');
@@ -35,15 +41,16 @@ export async function countAssociation(adapter: DataAdapter, association: Associ
35
41
  if (association.typeColumn && association.typeValue == null) {
36
42
  throw new Error(`association "${association.label}": typeColumn needs a typeValue`);
37
43
  }
44
+ const related = association.table ?? association.resource;
38
45
  const filter = {
39
46
  [association.foreignKey]: `eq.${safeId}`,
40
47
  ...(association.typeColumn ? { [association.typeColumn]: association.typeValue } : {}),
41
48
  };
42
49
  if (adapter.aggregate) {
43
- const result = await adapter.aggregate(association.resource, { aggregates: [{ function: 'count' }], filter });
50
+ const result = await adapter.aggregate(related, { aggregates: [{ function: 'count' }], filter });
44
51
  return Number(result.data[0]?.count ?? 0);
45
52
  }
46
- return (await adapter.fetchList(association.resource, { page: 1, perPage: 1, filter })).total;
53
+ return (await adapter.fetchList(related, { page: 1, perPage: 1, filter })).total;
47
54
  }
48
55
 
49
56
  type Count = number | '?' | undefined;
@@ -61,6 +68,8 @@ export function DeletePreview({
61
68
  const [counts, setCounts] = useState<Count[]>([]);
62
69
  const busy = useRef(false);
63
70
  const [error, setError] = useState<string | null>(null);
71
+ // An association's `table` is a real table: count it on the adapter without the resource mapping.
72
+ const raw = useRawDataAdapter();
64
73
 
65
74
  useEffect(() => {
66
75
  if (associations.length > MAX_ASSOCIATIONS) {
@@ -68,12 +77,12 @@ export function DeletePreview({
68
77
  }
69
78
  let live = true;
70
79
  setCounts([]);
71
- Promise.allSettled(associations.slice(0, MAX_ASSOCIATIONS).map(a => countAssociation(adapter, a, id)))
80
+ Promise.allSettled(associations.slice(0, MAX_ASSOCIATIONS).map(a => countAssociation(a.table !== undefined ? raw : adapter, a, id)))
72
81
  .then(results => {
73
82
  if (live) setCounts(results.map(r => (r.status === 'fulfilled' ? r.value : '?')));
74
83
  });
75
84
  return () => { live = false; };
76
- }, [adapter, associations, id, label]);
85
+ }, [adapter, raw, associations, id, label]);
77
86
 
78
87
  const confirm = async (): Promise<void> => {
79
88
  if (busy.current) return;
@@ -92,7 +101,7 @@ export function DeletePreview({
92
101
  <View testID="delete-preview" className="mx-4 mb-4 p-3 rounded-md bg-red-50">
93
102
  <Text className="text-base font-semibold text-red-800 mb-2">{`Delete ${label} #${String(id)}?`}</Text>
94
103
  {shown.map((association, i) => (
95
- <Text key={`${association.resource}:${association.foreignKey}:${i}`} className="text-sm text-gray-900">
104
+ <Text key={`${association.table ?? association.resource}:${association.foreignKey}:${i}`} className="text-sm text-gray-900">
96
105
  {`${association.label}: ${counts[i] ?? '…'}`}
97
106
  </Text>
98
107
  ))}