@tristan2828/ui-foundation 4.8.0 → 4.9.1
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/conventions/docs/add-an-entity.md +6 -1
- package/dist/hooks/use-record-update.js +24 -4
- package/package.json +1 -1
- package/vite/index.d.ts +15 -3
- package/vite/index.js +67 -2
|
@@ -178,7 +178,12 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
178
178
|
a toast naming the row and the server's reason; the lists refetch
|
|
179
179
|
after). Its specs cover a flip that saves, the keyboard, and a failed
|
|
180
180
|
save putting the switch back. Copy `useSaveWidgetField` whenever the
|
|
181
|
-
plan has a `toggle` or any quick action, and only then.
|
|
181
|
+
plan has a `toggle` or any quick action, and only then. It finds the
|
|
182
|
+
record under the entity's own detail and list keys: on a screen that
|
|
183
|
+
holds it in a query of its own, a plain change (`{ field: value }`) is
|
|
184
|
+
still sent but only shows once that query refetches, and a change
|
|
185
|
+
built from the record (`(record) => …`) is refused, with a console
|
|
186
|
+
warning naming the keys searched.
|
|
182
187
|
- `src/routes/widgets/use-widgets.ts`, `use-widget-categories.ts` →
|
|
183
188
|
`src/routes/<entity>/use-<entity>.ts` (TanStack Query hooks over the
|
|
184
189
|
new gateway module)
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
// or the AppError's).
|
|
22
22
|
// - **The lists refetch once the last save settles**, so a filter on the
|
|
23
23
|
// field drops the row once the server agrees.
|
|
24
|
+
// - **A record in neither cache** (the screen holds it in a query of its
|
|
25
|
+
// own) still saves an object change, shown once that query refetches; a
|
|
26
|
+
// function change has nothing to build on and is refused like any other
|
|
27
|
+
// refusal. Both warn in the console, naming the keys searched.
|
|
24
28
|
//
|
|
25
29
|
// Editing in place uses the same save with `optimistic: false` and
|
|
26
30
|
// `toastOnError: false`: nothing changes on screen until the server
|
|
@@ -35,6 +39,11 @@ function recordStates(queryClient) {
|
|
|
35
39
|
states.set(queryClient, (map = new Map()));
|
|
36
40
|
return map;
|
|
37
41
|
}
|
|
42
|
+
// A function change with no record in either cache to build it from.
|
|
43
|
+
const notLoaded = {
|
|
44
|
+
kind: 'server',
|
|
45
|
+
message: "It isn't loaded on this page. Reload and try again",
|
|
46
|
+
};
|
|
38
47
|
function resolve(change, record) {
|
|
39
48
|
return typeof change === 'function' ? change(record) : change;
|
|
40
49
|
}
|
|
@@ -94,9 +103,19 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
|
|
|
94
103
|
scope: { id: `record-update:${key}` },
|
|
95
104
|
mutationFn: (change) => {
|
|
96
105
|
const confirmed = map.get(key)?.confirmed ?? cached();
|
|
97
|
-
if (
|
|
98
|
-
return
|
|
99
|
-
|
|
106
|
+
if (confirmed)
|
|
107
|
+
return update(id, resolve(change, confirmed));
|
|
108
|
+
// The screen holds the record somewhere these keys don't reach (a
|
|
109
|
+
// query gathering a related list's records, say). An object change
|
|
110
|
+
// needs no current record, so it's still sent; a function change
|
|
111
|
+
// can't be built, and says so rather than doing nothing. Warned in
|
|
112
|
+
// every build: the package has no dev flag every app's bundler sets.
|
|
113
|
+
console.warn(`useRecordUpdate: record ${String(id)} isn't cached under ${JSON.stringify(detailKey)} ` +
|
|
114
|
+
`or any list under ${JSON.stringify(listsKey)}, so the change isn't shown until the server answers` +
|
|
115
|
+
(typeof change === 'function' ? ', and a change built from the record can\'t be sent' : ''));
|
|
116
|
+
if (typeof change === 'function')
|
|
117
|
+
return Promise.reject(notLoaded);
|
|
118
|
+
return update(id, change);
|
|
100
119
|
},
|
|
101
120
|
onMutate: async (change) => {
|
|
102
121
|
let state = map.get(key);
|
|
@@ -120,7 +139,8 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
|
|
|
120
139
|
return;
|
|
121
140
|
// What was sent: the change built on the record it was sent against.
|
|
122
141
|
const record = state.confirmed;
|
|
123
|
-
const
|
|
142
|
+
const { change } = context.entry;
|
|
143
|
+
const sent = record ? resolve(change, record) : typeof change === 'function' ? undefined : change;
|
|
124
144
|
settle(state, context.entry);
|
|
125
145
|
if (toastOnError)
|
|
126
146
|
toast.error(`Couldn't update ${record ? name(record) : 'this record'}: ${refusalReason(error, sent)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.1",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/vite/index.d.ts
CHANGED
|
@@ -3,12 +3,24 @@ import type { Plugin } from 'vite'
|
|
|
3
3
|
export type BuildInfoOptions = {
|
|
4
4
|
/** The app's repository, e.g. `https://github.com/me/app`. `BUILD_REPO_URL` wins over it. */
|
|
5
5
|
repoUrl?: string
|
|
6
|
+
/**
|
|
7
|
+
* The Alembic versions folder, from the Vite root (`backend/migrations/versions`).
|
|
8
|
+
* Adds `schema`, the head revision, to `build-info.json`; no head or
|
|
9
|
+
* several fails the build.
|
|
10
|
+
*/
|
|
11
|
+
alembic?: string
|
|
12
|
+
/**
|
|
13
|
+
* More fields for `build-info.json`, merged in. `api`, `commit`, `pr`,
|
|
14
|
+
* `builtAt` and `schema` are the plugin's own: returning one fails the build.
|
|
15
|
+
*/
|
|
16
|
+
extra?: () => Record<string, unknown>
|
|
6
17
|
}
|
|
7
18
|
|
|
8
19
|
/**
|
|
9
20
|
* On `vite build`, writes `dist/build-info.json` (`api`: "real" or "mock",
|
|
10
|
-
* `commit`, `pr`, `builtAt`) and sets
|
|
11
|
-
* `VITE_BUILD_PR` and
|
|
12
|
-
* `BUILD_PR` and
|
|
21
|
+
* `commit`, `pr`, `builtAt`, and `schema` with `alembic`) and sets
|
|
22
|
+
* `import.meta.env.VITE_BUILD_COMMIT`, `VITE_BUILD_PR` and
|
|
23
|
+
* `VITE_BUILD_REPO_URL` from `BUILD_COMMIT`, `BUILD_PR` and
|
|
24
|
+
* `BUILD_REPO_URL`.
|
|
13
25
|
*/
|
|
14
26
|
export default function buildInfo(options?: BuildInfoOptions): Plugin
|
package/vite/index.js
CHANGED
|
@@ -13,21 +13,72 @@
|
|
|
13
13
|
// BUILD_REPO_URL (the repoUrl option when that's unset), for AppShell's
|
|
14
14
|
// `version`. Unset, they're empty strings and nothing is shown.
|
|
15
15
|
//
|
|
16
|
+
// - with `alembic` (the migrations' versions folder, from the Vite root),
|
|
17
|
+
// adds `schema`: the head revision the backend migrates to. Read from the
|
|
18
|
+
// files, so the build needs no Python. No head, or several, fails the
|
|
19
|
+
// build, as `alembic upgrade head` would refuse at start.
|
|
20
|
+
// - with `extra`, merges what it returns into the file. It can't replace the
|
|
21
|
+
// fields above: the backend's production check reads `api`.
|
|
22
|
+
//
|
|
16
23
|
// Build only: in development the three are undefined. Plain JavaScript, like
|
|
17
24
|
// the eslint entry: Vite loads the config before any TypeScript tooling runs.
|
|
18
25
|
|
|
26
|
+
import fs from 'node:fs'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
|
|
29
|
+
const OWN_FIELDS = ['api', 'commit', 'pr', 'builtAt', 'schema']
|
|
30
|
+
|
|
19
31
|
/** @param {string | undefined} value */
|
|
20
32
|
function clean(value) {
|
|
21
33
|
const trimmed = value?.trim()
|
|
22
34
|
return trimmed ? trimmed : null
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
|
|
37
|
+
// `revision = "abc"` or `revision: str = "abc"`, as Alembic writes them.
|
|
38
|
+
const REVISION = /^revision(?:\s*:[^=\n]+)?\s*=\s*(['"])([^'"]+)\1/m
|
|
39
|
+
// None, one revision, or a tuple of them (a merge), possibly over lines.
|
|
40
|
+
const DOWN_REVISION = /^down_revision(?:\s*:[^=\n]+)?\s*=\s*(\([^)]*\)|[^\n]*)/m
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The one revision no other migration names as its down_revision.
|
|
44
|
+
* @param {string} dir
|
|
45
|
+
*/
|
|
46
|
+
function alembicHead(dir) {
|
|
47
|
+
if (!fs.existsSync(dir)) throw new Error(`buildInfo: no Alembic versions folder at ${dir}.`)
|
|
48
|
+
const revisions = new Set()
|
|
49
|
+
const downs = new Set()
|
|
50
|
+
for (const file of fs.readdirSync(dir).filter((name) => name.endsWith('.py'))) {
|
|
51
|
+
const source = fs.readFileSync(path.join(dir, file), 'utf8')
|
|
52
|
+
const revision = REVISION.exec(source)
|
|
53
|
+
if (!revision) continue
|
|
54
|
+
revisions.add(revision[2])
|
|
55
|
+
const down = DOWN_REVISION.exec(source)?.[1] ?? ''
|
|
56
|
+
for (const [, , name] of down.matchAll(/(['"])([^'"]+)\1/g)) downs.add(name)
|
|
57
|
+
}
|
|
58
|
+
const heads = [...revisions].filter((revision) => !downs.has(revision)).sort()
|
|
59
|
+
if (heads.length === 1) return heads[0]
|
|
60
|
+
throw new Error(
|
|
61
|
+
heads.length === 0
|
|
62
|
+
? `buildInfo: no Alembic head in ${dir} (no migrations, or they form a loop).`
|
|
63
|
+
: `buildInfo: several Alembic heads in ${dir} (${heads.join(', ')}); alembic upgrade head would refuse. Merge them first.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{
|
|
69
|
+
* repoUrl?: string,
|
|
70
|
+
* alembic?: string,
|
|
71
|
+
* extra?: () => Record<string, unknown>,
|
|
72
|
+
* }} [options]
|
|
73
|
+
*/
|
|
26
74
|
export default function buildInfo(options = {}) {
|
|
27
75
|
const commit = clean(process.env.BUILD_COMMIT)
|
|
28
76
|
const pr = clean(process.env.BUILD_PR)
|
|
29
77
|
const repoUrl = clean(process.env.BUILD_REPO_URL) ?? clean(options.repoUrl)
|
|
30
78
|
let api = 'mock'
|
|
79
|
+
let root = process.cwd()
|
|
80
|
+
/** @type {Record<string, unknown>} */
|
|
81
|
+
let fields = {}
|
|
31
82
|
|
|
32
83
|
return {
|
|
33
84
|
name: 'ui-foundation:build-info',
|
|
@@ -44,12 +95,26 @@ export default function buildInfo(options = {}) {
|
|
|
44
95
|
configResolved(config) {
|
|
45
96
|
// The same test as the app's main.tsx: anything but "real" ships MSW.
|
|
46
97
|
api = config.env.VITE_API === 'real' ? 'real' : 'mock'
|
|
98
|
+
root = config.root
|
|
99
|
+
},
|
|
100
|
+
// Read before bundling, so a bad migration chain fails the build early.
|
|
101
|
+
buildStart() {
|
|
102
|
+
fields = {}
|
|
103
|
+
if (options.alembic) fields.schema = alembicHead(path.resolve(root, options.alembic))
|
|
104
|
+
if (options.extra) {
|
|
105
|
+
const extra = options.extra()
|
|
106
|
+
const taken = Object.keys(extra).filter((key) => OWN_FIELDS.includes(key))
|
|
107
|
+
if (taken.length > 0) {
|
|
108
|
+
throw new Error(`buildInfo: extra can't set ${taken.join(', ')}; build-info.json's own fields.`)
|
|
109
|
+
}
|
|
110
|
+
Object.assign(fields, extra)
|
|
111
|
+
}
|
|
47
112
|
},
|
|
48
113
|
generateBundle() {
|
|
49
114
|
this.emitFile({
|
|
50
115
|
type: 'asset',
|
|
51
116
|
fileName: 'build-info.json',
|
|
52
|
-
source: `${JSON.stringify({ api, commit, pr, builtAt: new Date().toISOString() }, null, 2)}\n`,
|
|
117
|
+
source: `${JSON.stringify({ api, commit, pr, builtAt: new Date().toISOString(), ...fields }, null, 2)}\n`,
|
|
53
118
|
})
|
|
54
119
|
},
|
|
55
120
|
}
|