@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.
- package/CHANGELOG.md +38 -0
- package/package.json +22 -5
- package/src/adapters/CannotSortError.ts +19 -0
- package/src/adapters/EverystackAdapter.ts +33 -20
- package/src/adapters/EverystackApiError.ts +18 -0
- package/src/adapters/types.ts +13 -4
- 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 +16 -0
- package/src/providers/AdminConfigProvider.tsx +11 -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 +51 -6
- package/src/screens/ResourceEdit.tsx +71 -8
- package/src/screens/ResourceList.tsx +118 -17
- package/src/screens/ResourceListView.tsx +7 -3
- package/src/screens/ResourceShow.tsx +14 -9
- package/src/types.ts +114 -11
- package/src/utils/changed-fields.ts +54 -0
- 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 +28 -5
- package/src/utils/integrity-error.ts +16 -0
- package/src/utils/list-query.ts +3 -3
- package/src/utils/resource-query.ts +303 -0
- package/src/utils/validate-config.ts +85 -0
- package/src/utils/view-filters.ts +9 -3
- 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.
|
|
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.
|
|
54
|
-
"@everystack/ui": ">=0.4.
|
|
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/
|
|
121
|
-
"@everystack/
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/adapters/types.ts
CHANGED
|
@@ -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
|
+
}
|