@everystack/admin 0.4.5 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +22 -5
  3. package/src/adapters/CannotSortError.ts +19 -0
  4. package/src/adapters/EverystackAdapter.ts +33 -20
  5. package/src/adapters/EverystackApiError.ts +18 -0
  6. package/src/adapters/types.ts +13 -4
  7. package/src/components/DateConditionInput.tsx +148 -0
  8. package/src/components/DateFilterBar.tsx +85 -0
  9. package/src/components/DateTimeValue.tsx +37 -0
  10. package/src/components/DeletePreview.tsx +15 -6
  11. package/src/hooks/useAdminData.ts +44 -3
  12. package/src/hooks/useDateRangeFilters.ts +47 -0
  13. package/src/hooks/useViewState.ts +41 -8
  14. package/src/index.ts +16 -0
  15. package/src/providers/AdminConfigProvider.tsx +11 -2
  16. package/src/providers/DataAdapterProvider.tsx +5 -3
  17. package/src/rpc/list-filter.ts +210 -0
  18. package/src/rpc/resource-dashboard-rpc.ts +62 -13
  19. package/src/screens/AdminRouter.tsx +19 -6
  20. package/src/screens/ResourceCreate.tsx +51 -6
  21. package/src/screens/ResourceEdit.tsx +71 -8
  22. package/src/screens/ResourceList.tsx +118 -17
  23. package/src/screens/ResourceListView.tsx +7 -3
  24. package/src/screens/ResourceShow.tsx +14 -9
  25. package/src/types.ts +114 -11
  26. package/src/utils/changed-fields.ts +54 -0
  27. package/src/utils/date-filter-fields.ts +32 -0
  28. package/src/utils/date-range.ts +146 -0
  29. package/src/utils/datetime-submission.ts +25 -0
  30. package/src/utils/field-sortable.ts +19 -0
  31. package/src/utils/format-cell-value.ts +28 -5
  32. package/src/utils/integrity-error.ts +16 -0
  33. package/src/utils/list-query.ts +3 -3
  34. package/src/utils/resource-query.ts +303 -0
  35. package/src/utils/validate-config.ts +85 -0
  36. package/src/utils/view-filters.ts +9 -3
  37. package/src/utils/wall-clock.ts +72 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ 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.7] - 2026-10-18
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - list fields sort by default; the API refuses an order it cannot apply (#92)
12
+ - datetimes show in the viewer's zone and edit round-trip in it (#94)
13
+ - eventList applies the list-protocol filter; a time bound replaces the default window
14
+ - 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)
15
+
16
+ ### Added
17
+
18
+ - add ResourceConfig.where and table, a view filter and forced default for one slice of a table
19
+ - date foundations: zone resolver, DST-correct day bounds, zone-aware DateInput (#93)
20
+ - date-range helpers: DST-correct UTC bounds and presets
21
+ - DateConditionInput range mode: draft From/To days, Apply once, presets fill
22
+ - block list fetches until the date range and its zone are known
23
+ - eventList ilike on search columns, with literal matching and no lone surrogates
24
+ - repeated URL parameters survive the view state; ignoreParams for route params
25
+ - read a date range back from its URL instants; refuse what the URL cannot carry
26
+ - the list date range lives in native repeated instant keys on its field
27
+ - which datetime columns the list can filter with a date range row
28
+ - the DateInput calendar floats above the page and closes on an outside press or Escape
29
+ - the Add filter menu is a floating card that closes on an outside press or Escape
30
+
31
+ ## [0.4.6] - 2026-10-16
32
+
33
+ ### Added
34
+
35
+ - export editors/rich-html-extensions and editors/unsupported-markup
36
+ - the Edit form sends only the fields you changed, so beforeUpdate hooks and PATCH bodies carry less; a cleared field sends null, and a save with no changes sends nothing
37
+ - wall-clock datetime fields in a fixed business zone ({ timezone, storage: 'wall' } on a naive timestamp column); admin's FieldDefinition is now a union type, so an interface that extends it should extend FieldDefinitionBase instead
38
+
39
+ ### Fixed
40
+
41
+ - show integrity errors under matching form fields
42
+ - match a column for integrity code 23000 too
43
+ - ResourceShow prints an html or markdown field as text on native
44
+
7
45
  ## [0.4.5] - 2026-10-15
8
46
 
9
47
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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",
@@ -42,6 +47,16 @@
42
47
  "types": "./src/editors/tiptap.tsx",
43
48
  "default": "./src/editors/tiptap.tsx"
44
49
  },
50
+ "./editors/rich-html-extensions": {
51
+ "source": "./src/editors/rich-html-extensions.ts",
52
+ "types": "./src/editors/rich-html-extensions.ts",
53
+ "default": "./src/editors/rich-html-extensions.ts"
54
+ },
55
+ "./editors/unsupported-markup": {
56
+ "source": "./src/editors/unsupported-markup.ts",
57
+ "types": "./src/editors/unsupported-markup.ts",
58
+ "default": "./src/editors/unsupported-markup.ts"
59
+ },
45
60
  "./package.json": "./package.json"
46
61
  },
47
62
  "dependencies": {
@@ -50,8 +65,8 @@
50
65
  },
51
66
  "peerDependencies": {
52
67
  "drizzle-orm": "0.41.0",
53
- "@everystack/api": ">=0.4.4",
54
- "@everystack/ui": ">=0.4.2",
68
+ "@everystack/api": ">=0.4.7",
69
+ "@everystack/ui": ">=0.4.6",
55
70
  "@tiptap/extension-code": ">=3.0.0",
56
71
  "@tiptap/extension-code-block": ">=3.0.0",
57
72
  "@tiptap/extension-image": ">=3.0.0",
@@ -113,12 +128,14 @@
113
128
  "jsdom": "^20.0.3",
114
129
  "parse5": "^7.3.0",
115
130
  "react": "19.2.0",
131
+ "react-dom": "19.2.0",
116
132
  "react-native": "0.83.6",
133
+ "react-native-web": "0.20.0",
117
134
  "react-test-renderer": "19.2.0",
118
135
  "ts-jest": "29.4.9",
119
136
  "typescript": "5.9.3",
120
- "@everystack/ui": "0.4.4",
121
- "@everystack/api": "0.4.5"
137
+ "@everystack/api": "0.4.7",
138
+ "@everystack/ui": "0.4.6"
122
139
  },
123
140
  "scripts": {
124
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
+ }
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import { createClient, type Client, type ClientOptions, type QueryBuilder } from '@everystack/api/client';
9
+ import { EverystackApiError } from './EverystackApiError';
10
+ import { CannotSortError } from './CannotSortError';
9
11
  import { toCamelCaseKeys } from '../utils/naming';
10
12
  import { mergeFilters, normalizeSearchText } from '../utils/list-query';
11
13
  import type { DataAdapter } from './types';
@@ -45,7 +47,7 @@ const ZERO_ROW_DELETE = 'No row was deleted. It may not exist, or you may not ha
45
47
  export interface EverystackAdapterOptions extends ClientOptions {
46
48
  /**
47
49
  * Resources whose list rows come from an RPC instead of the PostgREST table.
48
- * 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
49
51
  * protocol — body `{ limit, offset, sort, dir, filter }`, result
50
52
  * `{ data, total }` — so the list UI is identical to the REST path. Used to
51
53
  * serve DB-free observability tables (logs, crashes) from the S3 archive.
@@ -53,7 +55,7 @@ export interface EverystackAdapterOptions extends ClientOptions {
53
55
  listRpcs?: Record<string, string>;
54
56
  /**
55
57
  * Resources whose single-record reads + writes go through an RPC instead of
56
- * 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
57
59
  * action-dispatched: body `{ action: 'get'|'create'|'update'|'delete', id?,
58
60
  * data? }` → the record (get/create/update) or `{}` (delete). Used to serve
59
61
  * DB-free mutable config (alert rules) from an S3 object with CAS. Pair with
@@ -93,14 +95,22 @@ export class EverystackAdapter implements DataAdapter {
93
95
  : params.filter;
94
96
  // Accept either the S3 list shape ({ data, total }) or the dashboard eventList shape
95
97
  // ({ rows, total }) so a resource can be served by either kind of RPC.
96
- const result = await this.rpc<{ data?: T[]; rows?: T[]; total: number }>(listRpc, {
97
- limit: perPage,
98
- offset,
99
- sort: params.sort,
100
- dir: params.order ?? 'asc',
101
- filter,
102
- ...(params.deleted ? { deleted: params.deleted } : {}),
103
- });
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
+ }
104
114
  // Normalize row keys to camelCase so the list columns resolve regardless of whether
105
115
  // the RPC returns snake_case (e.g. jobsEventList over ops.runs) or camelCase (S3 lists,
106
116
  // already camelCase so this is a no-op). The REST path is camelCase too.
@@ -132,6 +142,9 @@ export class EverystackAdapter implements DataAdapter {
132
142
  const result = await builder.executeWithCount();
133
143
 
134
144
  if (result.error) {
145
+ if (params.sort && result.error.status === 400 && result.error.code === 'invalid_order') {
146
+ throw new CannotSortError(params.sort);
147
+ }
135
148
  throw new Error(result.error.message);
136
149
  }
137
150
 
@@ -146,7 +159,7 @@ export class EverystackAdapter implements DataAdapter {
146
159
  async fetchOne<T extends DataRecord = DataRecord>(
147
160
  resource: string,
148
161
  id: string | number,
149
- params?: { select?: string; deleted?: DeletedMode },
162
+ params?: { select?: string; deleted?: DeletedMode; filter?: Record<string, unknown> },
150
163
  ): Promise<T> {
151
164
  // RPC-backed single read (e.g. DB-free alert rules from the S3 config object).
152
165
  const crudRpc = this.crudRpcs[resource];
@@ -160,7 +173,7 @@ export class EverystackAdapter implements DataAdapter {
160
173
  return record;
161
174
  }
162
175
 
163
- let builder = this.client.from(resource).eq('id', String(id));
176
+ let builder = applyFilters(this.client.from(resource).eq('id', String(id)), params?.filter);
164
177
  if (params?.select) builder = builder.select(params.select);
165
178
  if (params?.deleted) builder = builder.deleted(params.deleted);
166
179
  const result = await builder.single();
@@ -186,7 +199,7 @@ export class EverystackAdapter implements DataAdapter {
186
199
  const result = await this.client.from(resource).insert(data as Record<string, unknown>);
187
200
 
188
201
  if (result.error) {
189
- throw new Error(result.error.message);
202
+ throw new EverystackApiError(result.error);
190
203
  }
191
204
 
192
205
  return result.data as T;
@@ -196,7 +209,7 @@ export class EverystackAdapter implements DataAdapter {
196
209
  resource: string,
197
210
  id: string | number,
198
211
  data: Partial<T>,
199
- params?: { deleted?: DeletedMode },
212
+ params?: { deleted?: DeletedMode; filter?: Record<string, unknown> },
200
213
  ): Promise<T> {
201
214
  const crudRpc = this.crudRpcs[resource];
202
215
  if (crudRpc) {
@@ -208,12 +221,12 @@ export class EverystackAdapter implements DataAdapter {
208
221
  }));
209
222
  }
210
223
 
211
- let builder = this.client.from(resource).eq('id', String(id));
224
+ let builder = applyFilters(this.client.from(resource).eq('id', String(id)), params?.filter);
212
225
  if (params?.deleted) builder = builder.deleted(params.deleted);
213
226
  const result = await builder.update(data as Record<string, unknown>);
214
227
 
215
228
  if (result.error) {
216
- throw new Error(result.error.message);
229
+ throw new EverystackApiError(result.error);
217
230
  }
218
231
 
219
232
  const rows = (result.data ?? []) as T[];
@@ -224,17 +237,17 @@ export class EverystackAdapter implements DataAdapter {
224
237
  return rows[0];
225
238
  }
226
239
 
227
- async delete(resource: string, id: string | number): Promise<void> {
240
+ async delete(resource: string, id: string | number, params?: { filter?: Record<string, unknown> }): Promise<void> {
228
241
  const crudRpc = this.crudRpcs[resource];
229
242
  if (crudRpc) {
230
243
  this.rpcResult(await this.rpc<{ error?: string }>(crudRpc, { action: 'delete', id: String(id) }));
231
244
  return;
232
245
  }
233
246
 
234
- 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();
235
248
 
236
249
  if (result.error) {
237
- throw new Error(result.error.message);
250
+ throw new EverystackApiError(result.error);
238
251
  }
239
252
 
240
253
  if ((result.data ?? []).length === 0) throw new Error(ZERO_ROW_DELETE);
@@ -252,7 +265,7 @@ export class EverystackAdapter implements DataAdapter {
252
265
  const result = await this.client.rpc<T>(fn, body);
253
266
 
254
267
  if (result.error) {
255
- throw new Error(result.error.message);
268
+ throw new EverystackApiError(result.error);
256
269
  }
257
270
 
258
271
  return result.data as T;
@@ -0,0 +1,18 @@
1
+ import type { ClientError } from '@everystack/api/client';
2
+
3
+ /** An API refusal with the server's message and optional integrity metadata. */
4
+ export class EverystackApiError extends Error {
5
+ readonly status: number;
6
+ readonly code?: string;
7
+ readonly constraint?: string;
8
+ readonly column?: string;
9
+
10
+ constructor(error: ClientError) {
11
+ super(error.message);
12
+ this.name = 'EverystackApiError';
13
+ this.status = error.status;
14
+ if (typeof error.code === 'string') this.code = error.code;
15
+ if (typeof error.constraint === 'string') this.constraint = error.constraint;
16
+ if (typeof error.column === 'string') this.column = error.column;
17
+ }
18
+ }
@@ -34,11 +34,18 @@ 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.
37
44
  */
38
45
  fetchOne<T extends DataRecord = DataRecord>(
39
46
  resource: string,
40
47
  id: string | number,
41
- params?: { select?: string; deleted?: DeletedMode }
48
+ params?: { select?: string; deleted?: DeletedMode; filter?: Record<string, unknown> }
42
49
  ): Promise<T>;
43
50
 
44
51
  /**
@@ -49,18 +56,20 @@ export interface DataAdapter {
49
56
  /**
50
57
  * Update an existing record. `params.deleted` lets the write match a
51
58
  * soft-deleted row: Restore sends `{ [column]: null }` with `{ deleted: 'only' }`.
59
+ * `params.filter` is ANDed with the id in the write itself (see fetchOne).
52
60
  */
53
61
  update<T extends DataRecord = DataRecord>(
54
62
  resource: string,
55
63
  id: string | number,
56
64
  data: Partial<T>,
57
- params?: { deleted?: DeletedMode }
65
+ params?: { deleted?: DeletedMode; filter?: Record<string, unknown> }
58
66
  ): Promise<T>;
59
67
 
60
68
  /**
61
- * Delete a record
69
+ * Delete a record. `params.filter` is ANDed with the id in the delete itself
70
+ * (see fetchOne).
62
71
  */
63
- delete(resource: string, id: string | number): Promise<void>;
72
+ delete(resource: string, id: string | number, params?: { filter?: Record<string, unknown> }): Promise<void>;
64
73
 
65
74
  /**
66
75
  * 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
+ }