@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 +34 -0
- package/package.json +12 -5
- package/src/adapters/CannotSortError.ts +19 -0
- package/src/adapters/EverystackAdapter.ts +28 -16
- package/src/adapters/types.ts +21 -6
- package/src/components/DateConditionInput.tsx +148 -0
- package/src/components/DateFilterBar.tsx +85 -0
- package/src/components/DateTimeValue.tsx +37 -0
- package/src/components/DeletePreview.tsx +15 -6
- package/src/hooks/useAdminData.ts +44 -3
- package/src/hooks/useDateRangeFilters.ts +47 -0
- package/src/hooks/useViewState.ts +41 -8
- package/src/index.ts +12 -0
- package/src/providers/AdminConfigProvider.tsx +7 -2
- package/src/providers/DataAdapterProvider.tsx +5 -3
- package/src/rpc/list-filter.ts +210 -0
- package/src/rpc/resource-dashboard-rpc.ts +62 -13
- package/src/screens/AdminRouter.tsx +19 -6
- package/src/screens/ResourceCreate.tsx +34 -5
- package/src/screens/ResourceEdit.tsx +34 -5
- package/src/screens/ResourceList.tsx +118 -17
- package/src/screens/ResourceListView.tsx +7 -3
- package/src/screens/ResourceShow.tsx +8 -4
- package/src/types.ts +107 -18
- package/src/utils/date-filter-fields.ts +32 -0
- package/src/utils/date-range.ts +146 -0
- package/src/utils/datetime-submission.ts +25 -0
- package/src/utils/field-sortable.ts +19 -0
- package/src/utils/format-cell-value.ts +23 -10
- package/src/utils/list-query.ts +3 -3
- package/src/utils/resource-query.ts +318 -0
- package/src/utils/validate-config.ts +85 -0
- package/src/utils/view-filters.ts +9 -3
- package/src/utils/wall-clock.ts +2 -1
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.
|
|
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.
|
|
64
|
-
"@everystack/ui": ">=0.4.
|
|
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/
|
|
131
|
-
"@everystack/
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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);
|
package/src/adapters/types.ts
CHANGED
|
@@ -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'
|
|
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
|
-
/**
|
|
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(
|
|
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(
|
|
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
|
))}
|