@entifix/next-shell 0.9.0 → 0.10.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/dist/lib/command-palette/use-records-source.d.ts.map +1 -1
- package/dist/lib/command-palette/use-records-source.js +5 -11
- package/dist/lib/command-palette/use-records-source.js.map +1 -1
- package/dist/lib/command-palette/use-use-cases-source.d.ts.map +1 -1
- package/dist/lib/command-palette/use-use-cases-source.js +2 -5
- package/dist/lib/command-palette/use-use-cases-source.js.map +1 -1
- package/dist/lib/config/problem-response.d.ts +12 -0
- package/dist/lib/config/problem-response.d.ts.map +1 -0
- package/dist/lib/config/problem-response.js +21 -0
- package/dist/lib/config/problem-response.js.map +1 -0
- package/dist/lib/config/service-proxy-route.d.ts +0 -10
- package/dist/lib/config/service-proxy-route.d.ts.map +1 -1
- package/dist/lib/config/service-proxy-route.js +39 -28
- package/dist/lib/config/service-proxy-route.js.map +1 -1
- package/dist/lib/crud/entity-crud-form.d.ts +2 -2
- package/dist/lib/crud/entity-crud-form.d.ts.map +1 -1
- package/dist/lib/crud/entity-crud-form.js.map +1 -1
- package/dist/lib/crud/make-entity-crud.d.ts.map +1 -1
- package/dist/lib/crud/make-entity-crud.js +16 -13
- package/dist/lib/crud/make-entity-crud.js.map +1 -1
- package/dist/lib/i18n/spec-catalog.js +2 -1
- package/dist/lib/i18n/spec-catalog.js.map +1 -1
- package/dist/lib/search/record-search-route.d.ts.map +1 -1
- package/dist/lib/search/record-search-route.js +22 -26
- package/dist/lib/search/record-search-route.js.map +1 -1
- package/dist/lib/search/record-search-source.d.ts.map +1 -1
- package/dist/lib/search/record-search-source.js +2 -5
- package/dist/lib/search/record-search-source.js.map +1 -1
- package/dist/lib/search/record-search.types.d.ts +11 -1
- package/dist/lib/search/record-search.types.d.ts.map +1 -1
- package/dist/lib/search/record-search.types.js +0 -19
- package/dist/lib/search/record-search.types.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/package.json +11 -11
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-records-source.d.ts","sourceRoot":"","sources":["../../../src/lib/command-palette/use-records-source.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,aAAa,
|
|
1
|
+
{"version":3,"file":"use-records-source.d.ts","sourceRoot":"","sources":["../../../src/lib/command-palette/use-records-source.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,aAAa,EAGd,MAAM,eAAe,CAAC;AA0CvB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,OAAO,GACf,aAAa,CAgJf"}
|
|
@@ -12,16 +12,10 @@ import { RECORD_SEARCH_DEBOUNCE_MS, RECORD_SEARCH_MIN_TERM } from './palette-com
|
|
|
12
12
|
* renders. An operator holds no membership, so every tenant-plane source answers
|
|
13
13
|
* `409` on every keystroke — painting that as an outage teaches them to ignore
|
|
14
14
|
* the row entirely, and then the `timeout` that does matter goes unread too.
|
|
15
|
-
*/ const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
timeout: 'reachability',
|
|
20
|
-
network: 'reachability',
|
|
21
|
-
invalidQuery: 'reachability',
|
|
22
|
-
notFound: 'reachability',
|
|
23
|
-
unexpected: 'reachability'
|
|
24
|
-
};
|
|
15
|
+
*/ const SCOPE_CODES = new Set([
|
|
16
|
+
'noActiveOrganization'
|
|
17
|
+
]);
|
|
18
|
+
const severityOf = (degraded)=>degraded.category === 'unauthenticated' || degraded.category === 'forbidden' || SCOPE_CODES.has(degraded.reason) ? 'scope' : 'reachability';
|
|
25
19
|
const NOTHING_YET = {
|
|
26
20
|
term: '',
|
|
27
21
|
failed: false
|
|
@@ -163,7 +157,7 @@ const NOTHING_YET = {
|
|
|
163
157
|
isLoading
|
|
164
158
|
}));
|
|
165
159
|
for (const degraded of state.response.unavailable){
|
|
166
|
-
const severity =
|
|
160
|
+
const severity = severityOf(degraded);
|
|
167
161
|
groups.push({
|
|
168
162
|
key: `record:${degraded.source}`,
|
|
169
163
|
// Derived from the entity rather than from the source key, which is an
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/command-palette/use-records-source.ts"],"sourcesContent":["'use client';\n\nimport type {\n CommandGroup,\n CommandSource,\n CommandUnavailableSeverity,\n} from '@entifix/core';\nimport { useT, useTranslateKey } from '@entifix/react-controls';\nimport { useEffect, useMemo, useRef, useState } from 'react';\n\nimport { useLocaleHref } from '../i18n/index.js';\nimport type {\n RecordSearchResponse,\n RecordSearchUnavailableReason,\n} from '../search/record-search.types.js';\nimport { searchRecords } from '../search/search-records.js';\nimport {\n RECORD_SEARCH_DEBOUNCE_MS,\n RECORD_SEARCH_MIN_TERM,\n} from './palette-command.js';\n\n/**\n * Which reasons are the caller's ordinary state, and which mean something broke.\n *\n * The split is the record search's, restated as severity because that is what the palette\n * renders. An operator holds no membership, so every tenant-plane source answers\n * `409` on every keystroke — painting that as an outage teaches them to ignore\n * the row entirely, and then the `timeout` that does matter goes unread too.\n */\nconst SEVERITY: Readonly<\n Record<RecordSearchUnavailableReason, CommandUnavailableSeverity>\n> = {\n forbidden: 'scope',\n noActiveOrganization: 'scope',\n unauthenticated: 'scope',\n timeout: 'reachability',\n network: 'reachability',\n invalidQuery: 'reachability',\n notFound: 'reachability',\n unexpected: 'reachability',\n};\n\n/** What the browser is currently showing for records. */\ninterface RecordsState {\n /** The term this answer is for, so a stale response cannot overwrite a newer one. */\n readonly term: string;\n readonly response?: RecordSearchResponse;\n readonly failed: boolean;\n}\n\nconst NOTHING_YET: RecordsState = { term: '', failed: false };\n\n/**\n * Records matching the term, one group per source, in the order the host\n * declared them.\n *\n * The whole of the *keystroke* policy lives here rather than in `searchRecords`,\n * which is deliberately a bare typed `fetch`: the palette is the thing that\n * knows a keystroke is not a query. So this owns the debounce, the abort and the\n * two-character floor, while the route owns the fan-out, the per-source timeout\n * and the authorization.\n *\n * **No index, ever.** Every option here came back from `/api/search` for this\n * principal and this organization, on this keystroke. A prefetched client index\n * is the shape that surfaces another organization's record the first time a\n * session's scope moves under it, so there is none, ever.\n *\n * Below the two-character floor the group is present and **says why** rather\n * than being empty or absent: empty would claim nothing matched a search that\n * never ran, and absent would leave someone wondering whether records are\n * searchable here at all.\n */\nexport function useRecordsSource(\n term: string,\n enabled: boolean,\n): CommandSource {\n const t = useT('shell');\n const translateKey = useTranslateKey();\n const withLocale = useLocaleHref();\n\n const [state, setState] = useState<RecordsState>(NOTHING_YET);\n // The term the request in flight is for. A ref, not state: it changes on every\n // keystroke and nothing renders from it.\n const pending = useRef<string | null>(null);\n\n const active = enabled && term.length >= RECORD_SEARCH_MIN_TERM;\n\n useEffect(() => {\n // Nothing is cleared here on purpose. A `setState` in an effect body is a\n // cascading render, and there is nothing to clear: the memo below returns no\n // groups while inactive, so a previous answer is unreachable rather than\n // stale — and the host empties the term when it opens the palette.\n if (!active) {\n pending.current = null;\n return;\n }\n\n const controller = new AbortController();\n pending.current = term;\n\n const timer = setTimeout(() => {\n void searchRecords(term, { signal: controller.signal })\n .then(response => {\n if (pending.current !== term) return;\n setState({ term, response, failed: false });\n })\n .catch(() => {\n // An abort lands here too and must not be shown: the term moved on,\n // so there is nothing to report about the request that did not finish.\n if (controller.signal.aborted || pending.current !== term) return;\n setState({ term, failed: true });\n });\n }, RECORD_SEARCH_DEBOUNCE_MS);\n\n return () => {\n clearTimeout(timer);\n controller.abort();\n };\n }, [active, term]);\n\n return useMemo(() => {\n const heading = t('commandPalette.groups.records');\n\n if (!active) {\n // Someone who has typed one character is mid-word, not looking at an\n // answer. Saying so is better than both alternatives: an empty group would\n // claim nothing matched a search that never ran, and an absent one would\n // leave them wondering whether records are searchable at all.\n if (!enabled || term.length === 0) return { key: 'records', groups: [] };\n return {\n key: 'records',\n groups: [\n {\n key: 'records',\n label: heading,\n options: [],\n isLoading: false,\n unavailable: {\n message: t('commandPalette.typeMore', {\n count: RECORD_SEARCH_MIN_TERM,\n }),\n severity: 'scope',\n },\n },\n ],\n };\n }\n // A newer term is in flight. The previous answer stays on screen with its\n // groups marked loading rather than blanking: a list that empties between\n // keystrokes flickers, and the rows it drops were correct a moment ago.\n const isLoading = state.term !== term;\n\n if (state.response === undefined) {\n return {\n key: 'records',\n groups: [\n {\n key: 'records',\n label: heading,\n options: [],\n isLoading: !state.failed,\n // The whole endpoint failed — a lapsed session or a `400`, both of\n // which arrive as a rejection rather than inside a `200`. It is\n // named for the same reason a single degraded source is.\n ...(state.failed\n ? {\n unavailable: {\n message: t('commandPalette.unavailableReach', {\n reason: translateKey('errors:unexpected'),\n }),\n severity: 'reachability' as const,\n },\n }\n : {}),\n },\n ],\n };\n }\n\n const groups: CommandGroup[] = state.response.groups.map(group => ({\n key: `record:${group.source}`,\n // The entity's own `pluralKey`, so record search introduces no copy of\n // its own and a group is titled exactly as its screen is.\n label: translateKey(group.labelKey),\n options: group.items.map(item => ({\n id: `record:${group.source}:${item.id}`,\n label: item.label,\n ...(item.sublabel === undefined ? {} : { sublabel: item.sublabel }),\n href: withLocale(item.href),\n })),\n total: group.total,\n isLoading,\n }));\n\n for (const degraded of state.response.unavailable) {\n const severity = SEVERITY[degraded.reason];\n groups.push({\n key: `record:${degraded.source}`,\n // Derived from the entity rather than from the source key, which is an\n // identifier and would render as `product-specification`. Every\n // searchable entity declares a `pluralKey` — `catalogSurface` throws\n // without one — so this always resolves.\n label: translateKey(`entity:${degraded.entity}.plural`),\n options: [],\n isLoading: false,\n unavailable: {\n message: t(\n severity === 'scope'\n ? 'commandPalette.unavailableScope'\n : 'commandPalette.unavailableReach',\n { reason: translateKey(`errors:${degraded.reason}`) },\n ),\n severity,\n },\n });\n }\n\n return { key: 'records', groups };\n }, [active, enabled, state, term, t, translateKey, withLocale]);\n}\n"],"names":["useT","useTranslateKey","useEffect","useMemo","useRef","useState","useLocaleHref","searchRecords","RECORD_SEARCH_DEBOUNCE_MS","RECORD_SEARCH_MIN_TERM","SEVERITY","forbidden","noActiveOrganization","unauthenticated","timeout","network","invalidQuery","notFound","unexpected","NOTHING_YET","term","failed","useRecordsSource","enabled","t","translateKey","withLocale","state","setState","pending","active","length","current","controller","AbortController","timer","setTimeout","signal","then","response","catch","aborted","clearTimeout","abort","heading","key","groups","label","options","isLoading","unavailable","message","count","severity","undefined","reason","map","group","source","labelKey","items","item","id","sublabel","href","total","degraded","push","entity"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/command-palette/use-records-source.ts"],"sourcesContent":["'use client';\n\nimport type {\n CommandGroup,\n CommandSource,\n CommandUnavailableSeverity,\n FailureCategory,\n} from '@entifix/core';\nimport { useT, useTranslateKey } from '@entifix/react-controls';\nimport { useEffect, useMemo, useRef, useState } from 'react';\n\nimport { useLocaleHref } from '../i18n/index.js';\nimport type { RecordSearchResponse } from '../search/record-search.types.js';\nimport { searchRecords } from '../search/search-records.js';\nimport {\n RECORD_SEARCH_DEBOUNCE_MS,\n RECORD_SEARCH_MIN_TERM,\n} from './palette-command.js';\n\n/**\n * Which reasons are the caller's ordinary state, and which mean something broke.\n *\n * The split is the record search's, restated as severity because that is what the palette\n * renders. An operator holds no membership, so every tenant-plane source answers\n * `409` on every keystroke — painting that as an outage teaches them to ignore\n * the row entirely, and then the `timeout` that does matter goes unread too.\n */\nconst SCOPE_CODES: ReadonlySet<string> = new Set(['noActiveOrganization']);\n\nconst severityOf = (degraded: {\n readonly reason: string;\n readonly category: FailureCategory;\n}): CommandUnavailableSeverity =>\n degraded.category === 'unauthenticated' ||\n degraded.category === 'forbidden' ||\n SCOPE_CODES.has(degraded.reason)\n ? 'scope'\n : 'reachability';\n\n/** What the browser is currently showing for records. */\ninterface RecordsState {\n /** The term this answer is for, so a stale response cannot overwrite a newer one. */\n readonly term: string;\n readonly response?: RecordSearchResponse;\n readonly failed: boolean;\n}\n\nconst NOTHING_YET: RecordsState = { term: '', failed: false };\n\n/**\n * Records matching the term, one group per source, in the order the host\n * declared them.\n *\n * The whole of the *keystroke* policy lives here rather than in `searchRecords`,\n * which is deliberately a bare typed `fetch`: the palette is the thing that\n * knows a keystroke is not a query. So this owns the debounce, the abort and the\n * two-character floor, while the route owns the fan-out, the per-source timeout\n * and the authorization.\n *\n * **No index, ever.** Every option here came back from `/api/search` for this\n * principal and this organization, on this keystroke. A prefetched client index\n * is the shape that surfaces another organization's record the first time a\n * session's scope moves under it, so there is none, ever.\n *\n * Below the two-character floor the group is present and **says why** rather\n * than being empty or absent: empty would claim nothing matched a search that\n * never ran, and absent would leave someone wondering whether records are\n * searchable here at all.\n */\nexport function useRecordsSource(\n term: string,\n enabled: boolean,\n): CommandSource {\n const t = useT('shell');\n const translateKey = useTranslateKey();\n const withLocale = useLocaleHref();\n\n const [state, setState] = useState<RecordsState>(NOTHING_YET);\n // The term the request in flight is for. A ref, not state: it changes on every\n // keystroke and nothing renders from it.\n const pending = useRef<string | null>(null);\n\n const active = enabled && term.length >= RECORD_SEARCH_MIN_TERM;\n\n useEffect(() => {\n // Nothing is cleared here on purpose. A `setState` in an effect body is a\n // cascading render, and there is nothing to clear: the memo below returns no\n // groups while inactive, so a previous answer is unreachable rather than\n // stale — and the host empties the term when it opens the palette.\n if (!active) {\n pending.current = null;\n return;\n }\n\n const controller = new AbortController();\n pending.current = term;\n\n const timer = setTimeout(() => {\n void searchRecords(term, { signal: controller.signal })\n .then(response => {\n if (pending.current !== term) return;\n setState({ term, response, failed: false });\n })\n .catch(() => {\n // An abort lands here too and must not be shown: the term moved on,\n // so there is nothing to report about the request that did not finish.\n if (controller.signal.aborted || pending.current !== term) return;\n setState({ term, failed: true });\n });\n }, RECORD_SEARCH_DEBOUNCE_MS);\n\n return () => {\n clearTimeout(timer);\n controller.abort();\n };\n }, [active, term]);\n\n return useMemo(() => {\n const heading = t('commandPalette.groups.records');\n\n if (!active) {\n // Someone who has typed one character is mid-word, not looking at an\n // answer. Saying so is better than both alternatives: an empty group would\n // claim nothing matched a search that never ran, and an absent one would\n // leave them wondering whether records are searchable at all.\n if (!enabled || term.length === 0) return { key: 'records', groups: [] };\n return {\n key: 'records',\n groups: [\n {\n key: 'records',\n label: heading,\n options: [],\n isLoading: false,\n unavailable: {\n message: t('commandPalette.typeMore', {\n count: RECORD_SEARCH_MIN_TERM,\n }),\n severity: 'scope',\n },\n },\n ],\n };\n }\n // A newer term is in flight. The previous answer stays on screen with its\n // groups marked loading rather than blanking: a list that empties between\n // keystrokes flickers, and the rows it drops were correct a moment ago.\n const isLoading = state.term !== term;\n\n if (state.response === undefined) {\n return {\n key: 'records',\n groups: [\n {\n key: 'records',\n label: heading,\n options: [],\n isLoading: !state.failed,\n // The whole endpoint failed — a lapsed session or a `400`, both of\n // which arrive as a rejection rather than inside a `200`. It is\n // named for the same reason a single degraded source is.\n ...(state.failed\n ? {\n unavailable: {\n message: t('commandPalette.unavailableReach', {\n reason: translateKey('errors:unexpected'),\n }),\n severity: 'reachability' as const,\n },\n }\n : {}),\n },\n ],\n };\n }\n\n const groups: CommandGroup[] = state.response.groups.map(group => ({\n key: `record:${group.source}`,\n // The entity's own `pluralKey`, so record search introduces no copy of\n // its own and a group is titled exactly as its screen is.\n label: translateKey(group.labelKey),\n options: group.items.map(item => ({\n id: `record:${group.source}:${item.id}`,\n label: item.label,\n ...(item.sublabel === undefined ? {} : { sublabel: item.sublabel }),\n href: withLocale(item.href),\n })),\n total: group.total,\n isLoading,\n }));\n\n for (const degraded of state.response.unavailable) {\n const severity = severityOf(degraded);\n groups.push({\n key: `record:${degraded.source}`,\n // Derived from the entity rather than from the source key, which is an\n // identifier and would render as `product-specification`. Every\n // searchable entity declares a `pluralKey` — `catalogSurface` throws\n // without one — so this always resolves.\n label: translateKey(`entity:${degraded.entity}.plural`),\n options: [],\n isLoading: false,\n unavailable: {\n message: t(\n severity === 'scope'\n ? 'commandPalette.unavailableScope'\n : 'commandPalette.unavailableReach',\n { reason: translateKey(`errors:${degraded.reason}`) },\n ),\n severity,\n },\n });\n }\n\n return { key: 'records', groups };\n }, [active, enabled, state, term, t, translateKey, withLocale]);\n}\n"],"names":["useT","useTranslateKey","useEffect","useMemo","useRef","useState","useLocaleHref","searchRecords","RECORD_SEARCH_DEBOUNCE_MS","RECORD_SEARCH_MIN_TERM","SCOPE_CODES","Set","severityOf","degraded","category","has","reason","NOTHING_YET","term","failed","useRecordsSource","enabled","t","translateKey","withLocale","state","setState","pending","active","length","current","controller","AbortController","timer","setTimeout","signal","then","response","catch","aborted","clearTimeout","abort","heading","key","groups","label","options","isLoading","unavailable","message","count","severity","undefined","map","group","source","labelKey","items","item","id","sublabel","href","total","push","entity"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-use-cases-source.d.ts","sourceRoot":"","sources":["../../../src/lib/command-palette/use-use-cases-source.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,aAAa,
|
|
1
|
+
{"version":3,"file":"use-use-cases-source.d.ts","sourceRoot":"","sources":["../../../src/lib/command-palette/use-use-cases-source.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,MAAM,EACX,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EAI1B,MAAM,eAAe,CAAC;AASvB,sEAAsE;AACtE,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAE5E;;;;;;;GAOG;AACH,MAAM,MAAM,oBAAoB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,IAAI;IAClE,QAAQ,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,CAAC;IACvD,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;CACpC,GAAG,CACA;IACE,sDAAsD;IACtD,QAAQ,CAAC,cAAc,EAAE,oBAAoB,CAAC;IAC9C,QAAQ,CAAC,iBAAiB,CAAC,EAAE,KAAK,CAAC;CACpC,GACD;IACE,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;IACvD,QAAQ,CAAC,cAAc,CAAC,EAAE,KAAK,CAAC;CACjC,CACJ,CAAC;AAsFF;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,SAAS,oBAAoB,EAAE,EACzC,IAAI,EAAE,MAAM,GACX,aAAa,EAAE,CAOjB"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
3
|
-
import {
|
|
3
|
+
import { envelopeEntityName, matchesCommand, parseKeywords } from '@entifix/core';
|
|
4
4
|
import { useCasesForSurface, useT, useTranslateKey } from '@entifix/react-controls';
|
|
5
5
|
import { useEntityUseCases } from '@entifix/react-integration';
|
|
6
6
|
import { useMemo } from 'react';
|
|
@@ -39,10 +39,7 @@ import { useMemo } from 'react';
|
|
|
39
39
|
const options = descriptors.map((descriptor)=>{
|
|
40
40
|
const run = entity.handlers[descriptor.key];
|
|
41
41
|
if (run === undefined) {
|
|
42
|
-
throw new
|
|
43
|
-
entity: name,
|
|
44
|
-
key: descriptor.key
|
|
45
|
-
});
|
|
42
|
+
throw new Error(`The use case "${descriptor.key}" on ${name} reaches the command palette but this host registered no handler for it. ` + 'Add one to the entity’s `handlers`, or stop declaring the verb `unbound`.');
|
|
46
43
|
}
|
|
47
44
|
return _extends({
|
|
48
45
|
id: `use-case:${name}:${descriptor.key}`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/command-palette/use-use-cases-source.ts"],"sourcesContent":["'use client';\n\nimport {\n type CommandOption,\n type CommandSource,\n
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/command-palette/use-use-cases-source.ts"],"sourcesContent":["'use client';\n\nimport {\n type CommandOption,\n type CommandSource,\n type Entity,\n type EntityConstructor,\n type EntityMetadataSource,\n envelopeEntityName,\n matchesCommand,\n parseKeywords,\n} from '@entifix/core';\nimport {\n useCasesForSurface,\n useT,\n useTranslateKey,\n} from '@entifix/react-controls';\nimport { useEntityUseCases } from '@entifix/react-integration';\nimport { useMemo } from 'react';\n\n/** How a declared verb is actually carried out, keyed by the verb. */\nexport type UseCaseHandlers = Readonly<Record<string, () => Promise<void>>>;\n\n/**\n * One entity whose served affordances contribute palette commands.\n *\n * The source comes one of two ways, and exactly one: built and handed over, or\n * read from the host's composition by a hook — typically\n * `() => useHostAdapters().userMetadata` — so the module declaring the entries\n * need not import the transport that fetches.\n */\nexport type UseCaseCommandEntity<TEntity extends Entity = Entity> = {\n readonly entityConstructor: EntityConstructor<TEntity>;\n readonly handlers: UseCaseHandlers;\n} & (\n | {\n /** Where `GET /api/<entity>/$metadata` is reached. */\n readonly metadataSource: EntityMetadataSource;\n readonly useMetadataSource?: never;\n }\n | {\n /** Resolves the source from composition. Called as a hook, once per render. */\n readonly useMetadataSource: () => EntityMetadataSource;\n readonly metadataSource?: never;\n }\n);\n\n/**\n * The `unbound` verbs this caller may invoke, as one group.\n *\n * This is the surface the action placement map named and nothing rendered: all three\n * `unbound:*` cells resolve to `'command-palette'`, and until now the palette\n * did not exist, so a verb declared there was granted, exported, passed every\n * check the host's register ran and simply never appeared.\n *\n * The descriptors are **served, not imported**: `$metadata` filters them through\n * `PolicyDecisionTag` against the verified principal, which is a stronger check\n * than anything the browser could do — and it keeps every `@useCase()` class's\n * `Effect` body, repository tags and import closure out of the client bundle.\n *\n * ⚠️ A descriptor with **no handler throws**, at the first render of the\n * palette rather than on the click. A verb that appears and does nothing is\n * worse than one that is absent: it reads as a broken feature rather than a\n * missing wire, and it is the same posture `assertSearchable` and `surfaceFor`\n * already take.\n */\nfunction useUseCasesSource(\n entity: UseCaseCommandEntity,\n term: string,\n): CommandSource {\n const t = useT('shell');\n const translateKey = useTranslateKey();\n // The entries are module-scope, so which branch an entity takes never changes\n // between renders and the hook count stays fixed.\n const useSource = entity.useMetadataSource ?? (() => entity.metadataSource);\n const { metadata, isLoading } = useEntityUseCases(\n entity.entityConstructor,\n useSource(),\n );\n\n // Outside the memo: `useCasesForSurface` is a plain filter, but the name reads\n // as a hook to `react-hooks/rules-of-hooks`, which then refuses it inside a\n // callback. Hoisting is free — it is pure and cheap.\n const descriptors = useCasesForSurface('command-palette', metadata?.useCases);\n\n return useMemo(() => {\n const name = envelopeEntityName(entity.entityConstructor);\n\n const options: CommandOption[] = descriptors.map(descriptor => {\n const run = entity.handlers[descriptor.key];\n if (run === undefined) {\n throw new Error(\n `The use case \"${descriptor.key}\" on ${name} reaches the command palette but this host registered no handler for it. ` +\n 'Add one to the entity’s `handlers`, or stop declaring the verb `unbound`.',\n );\n }\n\n return {\n id: `use-case:${name}:${descriptor.key}`,\n label: translateKey(descriptor.labelKey),\n keywords: parseKeywords(\n descriptor.keywordsKey === undefined\n ? undefined\n : translateKey(descriptor.keywordsKey),\n ),\n run,\n ...(descriptor.confirm === undefined\n ? {}\n : {\n confirm: {\n tone: descriptor.confirm.tone,\n message: translateKey(descriptor.confirm.messageKey),\n },\n }),\n } satisfies CommandOption;\n });\n\n return {\n key: `use-cases:${name}`,\n groups: [\n {\n key: `use-cases:${name}`,\n label: t('commandPalette.groups.actions'),\n options: options.filter(option => matchesCommand(term, option)),\n isLoading,\n },\n ],\n } satisfies CommandSource;\n }, [entity, descriptors, isLoading, term, t, translateKey]);\n}\n\n/**\n * Every declared entity's palette verbs, as one source per entity.\n *\n * **Why the rule is disabled below.** `entities` is declared once at module\n * scope by the host and closed over, so it is the same array on every render and\n * the hook count is fixed — the invariant `react-hooks/rules-of-hooks` protects\n * and cannot see, because it reasons about the loop syntactically rather than\n * about the array's provenance. It is isolated here so the disable is written\n * once, with its reason, exactly as `useEntityLinkSources` does for pickers.\n */\nexport function useUseCaseSources(\n entities: readonly UseCaseCommandEntity[],\n term: string,\n): CommandSource[] {\n const sources: CommandSource[] = [];\n for (const entity of entities) {\n // eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length loop over a module-scope array; see the note above.\n sources.push(useUseCasesSource(entity, term));\n }\n return sources;\n}\n"],"names":["envelopeEntityName","matchesCommand","parseKeywords","useCasesForSurface","useT","useTranslateKey","useEntityUseCases","useMemo","useUseCasesSource","entity","term","t","translateKey","useSource","useMetadataSource","metadataSource","metadata","isLoading","entityConstructor","descriptors","useCases","name","options","map","descriptor","run","handlers","key","undefined","Error","id","label","labelKey","keywords","keywordsKey","confirm","tone","message","messageKey","groups","filter","option","useUseCaseSources","entities","sources","push"],"mappings":"AAAA;;AAEA,SAMEA,kBAAkB,EAClBC,cAAc,EACdC,aAAa,QACR,gBAAgB;AACvB,SACEC,kBAAkB,EAClBC,IAAI,EACJC,eAAe,QACV,0BAA0B;AACjC,SAASC,iBAAiB,QAAQ,6BAA6B;AAC/D,SAASC,OAAO,QAAQ,QAAQ;AA6BhC;;;;;;;;;;;;;;;;;;CAkBC,GACD,SAASC,kBACPC,MAA4B,EAC5BC,IAAY;QAMMD;IAJlB,MAAME,IAAIP,KAAK;IACf,MAAMQ,eAAeP;IACrB,8EAA8E;IAC9E,kDAAkD;IAClD,MAAMQ,aAAYJ,4BAAAA,OAAOK,iBAAiB,YAAxBL,4BAA6B,IAAMA,OAAOM,cAAc;IAC1E,MAAM,EAAEC,QAAQ,EAAEC,SAAS,EAAE,GAAGX,kBAC9BG,OAAOS,iBAAiB,EACxBL;IAGF,+EAA+E;IAC/E,4EAA4E;IAC5E,qDAAqD;IACrD,MAAMM,cAAchB,mBAAmB,mBAAmBa,4BAAAA,SAAUI,QAAQ;IAE5E,OAAOb,QAAQ;QACb,MAAMc,OAAOrB,mBAAmBS,OAAOS,iBAAiB;QAExD,MAAMI,UAA2BH,YAAYI,GAAG,CAACC,CAAAA;YAC/C,MAAMC,MAAMhB,OAAOiB,QAAQ,CAACF,WAAWG,GAAG,CAAC;YAC3C,IAAIF,QAAQG,WAAW;gBACrB,MAAM,IAAIC,MACR,CAAC,cAAc,EAAEL,WAAWG,GAAG,CAAC,KAAK,EAAEN,KAAK,yEAAyE,CAAC,GACpH;YAEN;YAEA,OAAO;gBACLS,IAAI,CAAC,SAAS,EAAET,KAAK,CAAC,EAAEG,WAAWG,GAAG,EAAE;gBACxCI,OAAOnB,aAAaY,WAAWQ,QAAQ;gBACvCC,UAAU/B,cACRsB,WAAWU,WAAW,KAAKN,YACvBA,YACAhB,aAAaY,WAAWU,WAAW;gBAEzCT;eACID,WAAWW,OAAO,KAAKP,YACvB,CAAC,IACD;gBACEO,SAAS;oBACPC,MAAMZ,WAAWW,OAAO,CAACC,IAAI;oBAC7BC,SAASzB,aAAaY,WAAWW,OAAO,CAACG,UAAU;gBACrD;YACF;QAER;QAEA,OAAO;YACLX,KAAK,CAAC,UAAU,EAAEN,MAAM;YACxBkB,QAAQ;gBACN;oBACEZ,KAAK,CAAC,UAAU,EAAEN,MAAM;oBACxBU,OAAOpB,EAAE;oBACTW,SAASA,QAAQkB,MAAM,CAACC,CAAAA,SAAUxC,eAAeS,MAAM+B;oBACvDxB;gBACF;aACD;QACH;IACF,GAAG;QAACR;QAAQU;QAAaF;QAAWP;QAAMC;QAAGC;KAAa;AAC5D;AAEA;;;;;;;;;CASC,GACD,OAAO,SAAS8B,kBACdC,QAAyC,EACzCjC,IAAY;IAEZ,MAAMkC,UAA2B,EAAE;IACnC,KAAK,MAAMnC,UAAUkC,SAAU;QAC7B,0HAA0H;QAC1HC,QAAQC,IAAI,CAACrC,kBAAkBC,QAAQC;IACzC;IACA,OAAOkC;AACT"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type EntifixFailure, type ProblemOptions } from '@entifix/core';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
/**
|
|
4
|
+
* A failure answered from a Next route handler that is not a proxy, as
|
|
5
|
+
* `application/problem+json` with its category's status unless one is given
|
|
6
|
+
* ([ADR 0013](../../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).
|
|
7
|
+
*
|
|
8
|
+
* `no-store`, because a refusal is about one caller and must never be served
|
|
9
|
+
* to the next one from a cache.
|
|
10
|
+
*/
|
|
11
|
+
export declare const problemResponse: (failure: EntifixFailure, options?: ProblemOptions) => NextResponse;
|
|
12
|
+
//# sourceMappingURL=problem-response.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem-response.d.ts","sourceRoot":"","sources":["../../../src/lib/config/problem-response.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,cAAc,EAGnB,KAAK,cAAc,EACpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,cAAc,EACvB,UAAS,cAAmB,KAC3B,YASF,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PROBLEM_CONTENT_TYPE, problemOf } from '@entifix/core';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
/**
|
|
4
|
+
* A failure answered from a Next route handler that is not a proxy, as
|
|
5
|
+
* `application/problem+json` with its category's status unless one is given
|
|
6
|
+
* ([ADR 0013](../../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).
|
|
7
|
+
*
|
|
8
|
+
* `no-store`, because a refusal is about one caller and must never be served
|
|
9
|
+
* to the next one from a cache.
|
|
10
|
+
*/ export const problemResponse = (failure, options = {})=>{
|
|
11
|
+
const body = problemOf(failure, options);
|
|
12
|
+
return new NextResponse(JSON.stringify(body), {
|
|
13
|
+
status: body.status,
|
|
14
|
+
headers: {
|
|
15
|
+
'content-type': PROBLEM_CONTENT_TYPE,
|
|
16
|
+
'cache-control': 'no-store'
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=problem-response.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/config/problem-response.ts"],"sourcesContent":["import {\n type EntifixFailure,\n PROBLEM_CONTENT_TYPE,\n problemOf,\n type ProblemOptions,\n} from '@entifix/core';\nimport { NextResponse } from 'next/server';\n\n/**\n * A failure answered from a Next route handler that is not a proxy, as\n * `application/problem+json` with its category's status unless one is given\n * ([ADR 0013](../../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).\n *\n * `no-store`, because a refusal is about one caller and must never be served\n * to the next one from a cache.\n */\nexport const problemResponse = (\n failure: EntifixFailure,\n options: ProblemOptions = {},\n): NextResponse => {\n const body = problemOf(failure, options);\n return new NextResponse(JSON.stringify(body), {\n status: body.status,\n headers: {\n 'content-type': PROBLEM_CONTENT_TYPE,\n 'cache-control': 'no-store',\n },\n });\n};\n"],"names":["PROBLEM_CONTENT_TYPE","problemOf","NextResponse","problemResponse","failure","options","body","JSON","stringify","status","headers"],"mappings":"AAAA,SAEEA,oBAAoB,EACpBC,SAAS,QAEJ,gBAAgB;AACvB,SAASC,YAAY,QAAQ,cAAc;AAE3C;;;;;;;CAOC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,SACAC,UAA0B,CAAC,CAAC;IAE5B,MAAMC,OAAOL,UAAUG,SAASC;IAChC,OAAO,IAAIH,aAAaK,KAAKC,SAAS,CAACF,OAAO;QAC5CG,QAAQH,KAAKG,MAAM;QACnBC,SAAS;YACP,gBAAgBV;YAChB,iBAAiB;QACnB;IACF;AACF,EAAE"}
|
|
@@ -22,16 +22,6 @@ export interface ServiceProxyRouteOptions {
|
|
|
22
22
|
*/
|
|
23
23
|
readonly pathPrefix?: string;
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
26
|
-
* The code a proxy answers when the service behind it could not be reached.
|
|
27
|
-
*
|
|
28
|
-
* `502`, never the `500` an unhandled rejection becomes: the host did nothing
|
|
29
|
-
* wrong, and a `500` sends whoever reads it looking for a bug in the app rather
|
|
30
|
-
* than for a process that is not running. A **code, not a sentence**, so the
|
|
31
|
-
* browser can render it through the host's own `errors` catalog like every
|
|
32
|
-
* other refusal in the framework.
|
|
33
|
-
*/
|
|
34
|
-
export declare const UPSTREAM_UNAVAILABLE = "upstreamUnavailable";
|
|
35
25
|
export declare const createServiceProxyRoute: ({ baseUrl, pathPrefix, }: ServiceProxyRouteOptions) => (request: Request, { params }: Params) => Promise<Response>;
|
|
36
26
|
export {};
|
|
37
27
|
//# sourceMappingURL=service-proxy-route.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-proxy-route.d.ts","sourceRoot":"","sources":["../../../src/lib/config/service-proxy-route.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"service-proxy-route.d.ts","sourceRoot":"","sources":["../../../src/lib/config/service-proxy-route.ts"],"names":[],"mappings":"AAMA,KAAK,MAAM,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC;AAEtD,MAAM,WAAW,wBAAwB;IACvC,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAwFD,eAAO,MAAM,uBAAuB,GAAI,0BAGrC,wBAAwB,eAEd,OAAO,cACJ,MAAM,KACjB,OAAO,CAAC,QAAQ,CA6EpB,CAAC"}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { DEADLINE_HEADER, UpstreamUnavailable } from '@entifix/core';
|
|
2
3
|
import { NextResponse } from 'next/server';
|
|
3
4
|
import { bearerHeader, sessionToken } from '../session/bearer.js';
|
|
5
|
+
import { problemResponse } from './problem-response.js';
|
|
4
6
|
/**
|
|
5
7
|
* Builds a same-origin proxy for one backend service, mounted as a catch-all
|
|
6
8
|
* route handler — e.g. `app/api/admin/[...path]/route.ts`.
|
|
@@ -36,11 +38,29 @@ import { bearerHeader, sessionToken } from '../session/bearer.js';
|
|
|
36
38
|
* Deliberately a short allow-list rather than copying every header: the
|
|
37
39
|
* upstream's `content-length` and `content-encoding` describe *its* body, and
|
|
38
40
|
* carrying them onto a response this function reconstructs is how a proxy
|
|
39
|
-
* serves a truncated payload.
|
|
40
|
-
|
|
41
|
+
* serves a truncated payload. Beside the cache validators: `retry-after`,
|
|
42
|
+
* which says when an overloaded service wants to be asked again; `location`,
|
|
43
|
+
* which a `202` points its tracker at; and `traceparent`, so the browser's
|
|
44
|
+
* failure toast can name the trace a service logged its side under.
|
|
45
|
+
*/ const PASSED_RESPONSE_HEADERS = [
|
|
41
46
|
'etag',
|
|
42
47
|
'cache-control',
|
|
43
|
-
'vary'
|
|
48
|
+
'vary',
|
|
49
|
+
'retry-after',
|
|
50
|
+
'location',
|
|
51
|
+
'traceparent'
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* Request headers forwarded to the service, beside the bearer it gets from
|
|
55
|
+
* the session: the validator, so `$metadata` can answer `304`; the
|
|
56
|
+
* idempotency key, so a retried write is recognised; the trace, so one
|
|
57
|
+
* request is one trace across the hop; and the caller's remaining budget
|
|
58
|
+
* ([ADR 0014](../../../../../../docs/adr/0014-retry-once-at-the-layer-above-under-a-budget.md)).
|
|
59
|
+
*/ const FORWARDED_REQUEST_HEADERS = [
|
|
60
|
+
'if-none-match',
|
|
61
|
+
'idempotency-key',
|
|
62
|
+
'traceparent',
|
|
63
|
+
DEADLINE_HEADER
|
|
44
64
|
];
|
|
45
65
|
/**
|
|
46
66
|
* Whether the upstream answer must be piped rather than rebuilt.
|
|
@@ -54,18 +74,9 @@ import { bearerHeader, sessionToken } from '../session/bearer.js';
|
|
|
54
74
|
var _upstream_headers_get;
|
|
55
75
|
return ((_upstream_headers_get = upstream.headers.get('content-type')) == null ? void 0 : _upstream_headers_get.startsWith('text/event-stream')) === true;
|
|
56
76
|
};
|
|
57
|
-
/**
|
|
58
|
-
* The code a proxy answers when the service behind it could not be reached.
|
|
59
|
-
*
|
|
60
|
-
* `502`, never the `500` an unhandled rejection becomes: the host did nothing
|
|
61
|
-
* wrong, and a `500` sends whoever reads it looking for a bug in the app rather
|
|
62
|
-
* than for a process that is not running. A **code, not a sentence**, so the
|
|
63
|
-
* browser can render it through the host's own `errors` catalog like every
|
|
64
|
-
* other refusal in the framework.
|
|
65
|
-
*/ export const UPSTREAM_UNAVAILABLE = 'upstreamUnavailable';
|
|
66
77
|
const passThrough = (upstream)=>{
|
|
67
78
|
const headers = {};
|
|
68
|
-
for (const name of
|
|
79
|
+
for (const name of PASSED_RESPONSE_HEADERS){
|
|
69
80
|
const value = upstream.headers.get(name);
|
|
70
81
|
if (value !== null) headers[name] = value;
|
|
71
82
|
}
|
|
@@ -73,16 +84,15 @@ const passThrough = (upstream)=>{
|
|
|
73
84
|
};
|
|
74
85
|
export const createServiceProxyRoute = ({ baseUrl, pathPrefix })=>{
|
|
75
86
|
const forward = async (request, { params })=>{
|
|
87
|
+
var _upstream_headers_get;
|
|
76
88
|
const { path } = await params;
|
|
77
89
|
const search = new URL(request.url).search;
|
|
78
90
|
const headers = _extends({
|
|
79
91
|
'content-type': 'application/json'
|
|
80
92
|
}, bearerHeader(await sessionToken()));
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (ifNoneMatch !== null) {
|
|
85
|
-
headers['if-none-match'] = ifNoneMatch;
|
|
93
|
+
for (const name of FORWARDED_REQUEST_HEADERS){
|
|
94
|
+
const value = request.headers.get(name);
|
|
95
|
+
if (value !== null) headers[name] = value;
|
|
86
96
|
}
|
|
87
97
|
const hasBody = request.method !== 'GET' && request.method !== 'DELETE';
|
|
88
98
|
const body = hasBody ? await request.text() : undefined;
|
|
@@ -97,15 +107,14 @@ export const createServiceProxyRoute = ({ baseUrl, pathPrefix })=>{
|
|
|
97
107
|
body,
|
|
98
108
|
cache: 'no-store'
|
|
99
109
|
}).catch(()=>undefined);
|
|
110
|
+
// `502`, never the `500` an unhandled rejection becomes: the host did
|
|
111
|
+
// nothing wrong, and a `500` sends whoever reads it looking for a bug in
|
|
112
|
+
// the app rather than for a process that is not running.
|
|
100
113
|
if (upstream === undefined) {
|
|
101
|
-
return
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
status: 502,
|
|
106
|
-
headers: {
|
|
107
|
-
'cache-control': 'no-store'
|
|
108
|
-
}
|
|
114
|
+
return problemResponse(new UpstreamUnavailable(pathPrefix === undefined ? {} : {
|
|
115
|
+
upstream: pathPrefix
|
|
116
|
+
}), {
|
|
117
|
+
status: 502
|
|
109
118
|
});
|
|
110
119
|
}
|
|
111
120
|
const passed = passThrough(upstream);
|
|
@@ -130,7 +139,9 @@ export const createServiceProxyRoute = ({ baseUrl, pathPrefix })=>{
|
|
|
130
139
|
})
|
|
131
140
|
});
|
|
132
141
|
}
|
|
133
|
-
// 204s and empty bodies must not be
|
|
142
|
+
// 204s and empty bodies must not be given a body. The upstream's content
|
|
143
|
+
// type is kept, never re-typed: a problem body re-typed as plain JSON is
|
|
144
|
+
// one the browser can no longer recognise as a failure it can decode.
|
|
134
145
|
const text = await upstream.text();
|
|
135
146
|
return text === '' ? new NextResponse(null, {
|
|
136
147
|
status: upstream.status,
|
|
@@ -138,7 +149,7 @@ export const createServiceProxyRoute = ({ baseUrl, pathPrefix })=>{
|
|
|
138
149
|
}) : new NextResponse(text, {
|
|
139
150
|
status: upstream.status,
|
|
140
151
|
headers: _extends({}, passed, {
|
|
141
|
-
'content-type': 'application/json'
|
|
152
|
+
'content-type': (_upstream_headers_get = upstream.headers.get('content-type')) != null ? _upstream_headers_get : 'application/json'
|
|
142
153
|
})
|
|
143
154
|
});
|
|
144
155
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/config/service-proxy-route.ts"],"sourcesContent":["import { NextResponse } from 'next/server';\n\nimport { bearerHeader, sessionToken } from '../session/bearer.js';\n\ntype Params = { params: Promise<{ path: string[] }> };\n\nexport interface ServiceProxyRouteOptions {\n /** Where the service listens, without the `/api` suffix. */\n readonly baseUrl: string;\n /**\n * A segment to put back in front of the forwarded path.\n *\n * ⚠️ **Needed when the proxy's namespace *is* the upstream's route name.**\n * `/api/stock/stock-item` forwards to `/api/stock-item` because `stock` names\n * the service and `stock-item` names the entity — two different words. But\n * `/api/transaction/events` forwards to `/api/events`, which exists nowhere:\n * there the namespace and the route are the same word, and stripping it\n * strips half the address.\n *\n * Caught on the live lab rather than by any check, and the symptom is exactly\n * the one this proxy exists to prevent — the SSE stream 404s, a pending write\n * never settles, and every probe stays green.\n */\n readonly pathPrefix?: string;\n}\n\n/**\n * Builds a same-origin proxy for one backend service, mounted as a catch-all\n * route handler — e.g. `app/api/admin/[...path]/route.ts`.\n *\n * The browser can never call `:310N` directly: `entifix_at` is httpOnly and\n * same-origin, so a cross-origin request carries no cookie and a guarded route\n * answers `401`. Host-scoping the cookie does not help — that governs which host\n * *stores* it, not which cross-origin requests send it. Routing through the app's\n * own origin makes the cookie automatic and needs no CORS, which is the same\n * reason the credential routes are shaped this way.\n *\n * The cookie is forwarded upstream as a bearer token. **This proxy grants\n * nothing** — the service still verifies the token and applies its own\n * `requirePermission`. It only carries.\n *\n * It also carries the **caching contract** in both directions, which it did not\n * always: rebuilding every response as a fresh body dropped `ETag`,\n * `Cache-Control` and `Vary`, and forwarded no `If-None-Match`. The effect was\n * invisible and entirely wasteful — `GET /api/<entity>/$metadata` computes and\n * hashes a permission-filtered document per request, and a validator that never\n * reaches the service means it can never answer `304`. Worse, `Vary` is a\n * *correctness* header here: the document differs per caller, and one stripped\n * of `Vary: Cookie, Authorization` may be cached and served to a different\n * principal.\n *\n * A factory, because a host mounts one of these per backend it talks to, and\n * back-office-app now talks to two: marketplace-admin-service for the catalog a\n * vendor authors, marketplace-service for the platform vocabulary it is\n * classified in.\n */\n/**\n * Response headers that must survive the rebuild.\n *\n * Deliberately a short allow-list rather than copying every header: the\n * upstream's `content-length` and `content-encoding` describe *its* body, and\n * carrying them onto a response this function reconstructs is how a proxy\n * serves a truncated payload.\n */\nconst CACHE_HEADERS = ['etag', 'cache-control', 'vary'] as const;\n\n/**\n * Whether the upstream answer must be piped rather than rebuilt.\n *\n * Reading a `text/event-stream` body to completion holds the request open\n * forever and delivers nothing — no error, no timeout, and the browser's\n * `EventSource` sits in `CONNECTING` while the service is happily emitting.\n * It is the most likely way to build the reactive stream, see silence, and go\n * looking in the wrong service.\n */\nconst isStreamed = (upstream: Response): boolean =>\n upstream.headers.get('content-type')?.startsWith('text/event-stream') ===\n true;\n\n/**\n * The code a proxy answers when the service behind it could not be reached.\n *\n * `502`, never the `500` an unhandled rejection becomes: the host did nothing\n * wrong, and a `500` sends whoever reads it looking for a bug in the app rather\n * than for a process that is not running. A **code, not a sentence**, so the\n * browser can render it through the host's own `errors` catalog like every\n * other refusal in the framework.\n */\nexport const UPSTREAM_UNAVAILABLE = 'upstreamUnavailable';\n\nconst passThrough = (upstream: Response): Record<string, string> => {\n const headers: Record<string, string> = {};\n for (const name of CACHE_HEADERS) {\n const value = upstream.headers.get(name);\n if (value !== null) headers[name] = value;\n }\n return headers;\n};\n\nexport const createServiceProxyRoute = ({\n baseUrl,\n pathPrefix,\n}: ServiceProxyRouteOptions) => {\n const forward = async (\n request: Request,\n { params }: Params,\n ): Promise<Response> => {\n const { path } = await params;\n const search = new URL(request.url).search;\n\n const headers: Record<string, string> = {\n 'content-type': 'application/json',\n ...bearerHeader(await sessionToken()),\n };\n\n // Forwarded, or the validator never reaches the service and every\n // `$metadata` read is a full document — see {@link CACHE_HEADERS}.\n const ifNoneMatch = request.headers.get('if-none-match');\n if (ifNoneMatch !== null) {\n headers['if-none-match'] = ifNoneMatch;\n }\n\n const hasBody = request.method !== 'GET' && request.method !== 'DELETE';\n const body = hasBody ? await request.text() : undefined;\n // Only the connection is caught. A service that answers — with anything,\n // a `500` included — is passed through below exactly as it answered.\n const upstream = await fetch(\n `${baseUrl}/api/${[pathPrefix, ...path].filter(Boolean).join('/')}${search}`,\n { method: request.method, headers, body, cache: 'no-store' },\n ).catch(() => undefined);\n\n if (upstream === undefined) {\n return NextResponse.json(\n {\n error: 'the service behind this route could not be reached',\n code: UPSTREAM_UNAVAILABLE,\n },\n { status: 502, headers: { 'cache-control': 'no-store' } },\n );\n }\n\n const passed = passThrough(upstream);\n\n // A `304` carries no body by definition, and neither does a `204`. Running\n // either through a body read is not merely wasteful — a `304` reconstructed\n // with a body is no longer a `304`.\n if (upstream.status === 304) {\n return new NextResponse(null, { status: 304, headers: passed });\n }\n\n // Piped, never buffered, and never re-typed as JSON: the body is an open\n // stream of frames, and `no-store` is correctness rather than politeness —\n // this response is scoped to one principal.\n if (isStreamed(upstream)) {\n return new NextResponse(upstream.body, {\n status: upstream.status,\n headers: {\n ...passed,\n 'content-type': 'text/event-stream',\n 'cache-control': 'no-store',\n },\n });\n }\n\n // 204s and empty bodies must not be run through `json()`.\n const text = await upstream.text();\n return text === ''\n ? new NextResponse(null, { status: upstream.status, headers: passed })\n : new NextResponse(text, {\n status: upstream.status,\n headers: { ...passed, 'content-type': 'application/json' },\n });\n };\n\n return forward;\n};\n"],"names":["NextResponse","bearerHeader","sessionToken","CACHE_HEADERS","isStreamed","upstream","headers","get","startsWith","UPSTREAM_UNAVAILABLE","passThrough","name","value","createServiceProxyRoute","baseUrl","pathPrefix","forward","request","params","path","search","URL","url","ifNoneMatch","hasBody","method","body","text","undefined","fetch","filter","Boolean","join","cache","catch","json","error","code","status","passed"],"mappings":";AAAA,SAASA,YAAY,QAAQ,cAAc;AAE3C,SAASC,YAAY,EAAEC,YAAY,QAAQ,uBAAuB;AAwBlE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BC,GACD;;;;;;;CAOC,GACD,MAAMC,gBAAgB;IAAC;IAAQ;IAAiB;CAAO;AAEvD;;;;;;;;CAQC,GACD,MAAMC,aAAa,CAACC;QAClBA;WAAAA,EAAAA,wBAAAA,SAASC,OAAO,CAACC,GAAG,CAAC,oCAArBF,sBAAsCG,UAAU,CAAC,0BACjD;;AAEF;;;;;;;;CAQC,GACD,OAAO,MAAMC,uBAAuB,sBAAsB;AAE1D,MAAMC,cAAc,CAACL;IACnB,MAAMC,UAAkC,CAAC;IACzC,KAAK,MAAMK,QAAQR,cAAe;QAChC,MAAMS,QAAQP,SAASC,OAAO,CAACC,GAAG,CAACI;QACnC,IAAIC,UAAU,MAAMN,OAAO,CAACK,KAAK,GAAGC;IACtC;IACA,OAAON;AACT;AAEA,OAAO,MAAMO,0BAA0B,CAAC,EACtCC,OAAO,EACPC,UAAU,EACe;IACzB,MAAMC,UAAU,OACdC,SACA,EAAEC,MAAM,EAAU;QAElB,MAAM,EAAEC,IAAI,EAAE,GAAG,MAAMD;QACvB,MAAME,SAAS,IAAIC,IAAIJ,QAAQK,GAAG,EAAEF,MAAM;QAE1C,MAAMd,UAAkC;YACtC,gBAAgB;WACbL,aAAa,MAAMC;QAGxB,kEAAkE;QAClE,mEAAmE;QACnE,MAAMqB,cAAcN,QAAQX,OAAO,CAACC,GAAG,CAAC;QACxC,IAAIgB,gBAAgB,MAAM;YACxBjB,OAAO,CAAC,gBAAgB,GAAGiB;QAC7B;QAEA,MAAMC,UAAUP,QAAQQ,MAAM,KAAK,SAASR,QAAQQ,MAAM,KAAK;QAC/D,MAAMC,OAAOF,UAAU,MAAMP,QAAQU,IAAI,KAAKC;QAC9C,yEAAyE;QACzE,qEAAqE;QACrE,MAAMvB,WAAW,MAAMwB,MACrB,GAAGf,QAAQ,KAAK,EAAE;YAACC;eAAeI;SAAK,CAACW,MAAM,CAACC,SAASC,IAAI,CAAC,OAAOZ,QAAQ,EAC5E;YAAEK,QAAQR,QAAQQ,MAAM;YAAEnB;YAASoB;YAAMO,OAAO;QAAW,GAC3DC,KAAK,CAAC,IAAMN;QAEd,IAAIvB,aAAauB,WAAW;YAC1B,OAAO5B,aAAamC,IAAI,CACtB;gBACEC,OAAO;gBACPC,MAAM5B;YACR,GACA;gBAAE6B,QAAQ;gBAAKhC,SAAS;oBAAE,iBAAiB;gBAAW;YAAE;QAE5D;QAEA,MAAMiC,SAAS7B,YAAYL;QAE3B,2EAA2E;QAC3E,4EAA4E;QAC5E,oCAAoC;QACpC,IAAIA,SAASiC,MAAM,KAAK,KAAK;YAC3B,OAAO,IAAItC,aAAa,MAAM;gBAAEsC,QAAQ;gBAAKhC,SAASiC;YAAO;QAC/D;QAEA,yEAAyE;QACzE,2EAA2E;QAC3E,4CAA4C;QAC5C,IAAInC,WAAWC,WAAW;YACxB,OAAO,IAAIL,aAAaK,SAASqB,IAAI,EAAE;gBACrCY,QAAQjC,SAASiC,MAAM;gBACvBhC,SAAS,aACJiC;oBACH,gBAAgB;oBAChB,iBAAiB;;YAErB;QACF;QAEA,0DAA0D;QAC1D,MAAMZ,OAAO,MAAMtB,SAASsB,IAAI;QAChC,OAAOA,SAAS,KACZ,IAAI3B,aAAa,MAAM;YAAEsC,QAAQjC,SAASiC,MAAM;YAAEhC,SAASiC;QAAO,KAClE,IAAIvC,aAAa2B,MAAM;YACrBW,QAAQjC,SAASiC,MAAM;YACvBhC,SAAS,aAAKiC;gBAAQ,gBAAgB;;QACxC;IACN;IAEA,OAAOvB;AACT,EAAE"}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/config/service-proxy-route.ts"],"sourcesContent":["import { DEADLINE_HEADER, UpstreamUnavailable } from '@entifix/core';\nimport { NextResponse } from 'next/server';\n\nimport { bearerHeader, sessionToken } from '../session/bearer.js';\nimport { problemResponse } from './problem-response.js';\n\ntype Params = { params: Promise<{ path: string[] }> };\n\nexport interface ServiceProxyRouteOptions {\n /** Where the service listens, without the `/api` suffix. */\n readonly baseUrl: string;\n /**\n * A segment to put back in front of the forwarded path.\n *\n * ⚠️ **Needed when the proxy's namespace *is* the upstream's route name.**\n * `/api/stock/stock-item` forwards to `/api/stock-item` because `stock` names\n * the service and `stock-item` names the entity — two different words. But\n * `/api/transaction/events` forwards to `/api/events`, which exists nowhere:\n * there the namespace and the route are the same word, and stripping it\n * strips half the address.\n *\n * Caught on the live lab rather than by any check, and the symptom is exactly\n * the one this proxy exists to prevent — the SSE stream 404s, a pending write\n * never settles, and every probe stays green.\n */\n readonly pathPrefix?: string;\n}\n\n/**\n * Builds a same-origin proxy for one backend service, mounted as a catch-all\n * route handler — e.g. `app/api/admin/[...path]/route.ts`.\n *\n * The browser can never call `:310N` directly: `entifix_at` is httpOnly and\n * same-origin, so a cross-origin request carries no cookie and a guarded route\n * answers `401`. Host-scoping the cookie does not help — that governs which host\n * *stores* it, not which cross-origin requests send it. Routing through the app's\n * own origin makes the cookie automatic and needs no CORS, which is the same\n * reason the credential routes are shaped this way.\n *\n * The cookie is forwarded upstream as a bearer token. **This proxy grants\n * nothing** — the service still verifies the token and applies its own\n * `requirePermission`. It only carries.\n *\n * It also carries the **caching contract** in both directions, which it did not\n * always: rebuilding every response as a fresh body dropped `ETag`,\n * `Cache-Control` and `Vary`, and forwarded no `If-None-Match`. The effect was\n * invisible and entirely wasteful — `GET /api/<entity>/$metadata` computes and\n * hashes a permission-filtered document per request, and a validator that never\n * reaches the service means it can never answer `304`. Worse, `Vary` is a\n * *correctness* header here: the document differs per caller, and one stripped\n * of `Vary: Cookie, Authorization` may be cached and served to a different\n * principal.\n *\n * A factory, because a host mounts one of these per backend it talks to, and\n * back-office-app now talks to two: marketplace-admin-service for the catalog a\n * vendor authors, marketplace-service for the platform vocabulary it is\n * classified in.\n */\n/**\n * Response headers that must survive the rebuild.\n *\n * Deliberately a short allow-list rather than copying every header: the\n * upstream's `content-length` and `content-encoding` describe *its* body, and\n * carrying them onto a response this function reconstructs is how a proxy\n * serves a truncated payload. Beside the cache validators: `retry-after`,\n * which says when an overloaded service wants to be asked again; `location`,\n * which a `202` points its tracker at; and `traceparent`, so the browser's\n * failure toast can name the trace a service logged its side under.\n */\nconst PASSED_RESPONSE_HEADERS = [\n 'etag',\n 'cache-control',\n 'vary',\n 'retry-after',\n 'location',\n 'traceparent',\n] as const;\n\n/**\n * Request headers forwarded to the service, beside the bearer it gets from\n * the session: the validator, so `$metadata` can answer `304`; the\n * idempotency key, so a retried write is recognised; the trace, so one\n * request is one trace across the hop; and the caller's remaining budget\n * ([ADR 0014](../../../../../../docs/adr/0014-retry-once-at-the-layer-above-under-a-budget.md)).\n */\nconst FORWARDED_REQUEST_HEADERS = [\n 'if-none-match',\n 'idempotency-key',\n 'traceparent',\n DEADLINE_HEADER,\n] as const;\n\n/**\n * Whether the upstream answer must be piped rather than rebuilt.\n *\n * Reading a `text/event-stream` body to completion holds the request open\n * forever and delivers nothing — no error, no timeout, and the browser's\n * `EventSource` sits in `CONNECTING` while the service is happily emitting.\n * It is the most likely way to build the reactive stream, see silence, and go\n * looking in the wrong service.\n */\nconst isStreamed = (upstream: Response): boolean =>\n upstream.headers.get('content-type')?.startsWith('text/event-stream') ===\n true;\n\nconst passThrough = (upstream: Response): Record<string, string> => {\n const headers: Record<string, string> = {};\n for (const name of PASSED_RESPONSE_HEADERS) {\n const value = upstream.headers.get(name);\n if (value !== null) headers[name] = value;\n }\n return headers;\n};\n\nexport const createServiceProxyRoute = ({\n baseUrl,\n pathPrefix,\n}: ServiceProxyRouteOptions) => {\n const forward = async (\n request: Request,\n { params }: Params,\n ): Promise<Response> => {\n const { path } = await params;\n const search = new URL(request.url).search;\n\n const headers: Record<string, string> = {\n 'content-type': 'application/json',\n ...bearerHeader(await sessionToken()),\n };\n\n for (const name of FORWARDED_REQUEST_HEADERS) {\n const value = request.headers.get(name);\n if (value !== null) headers[name] = value;\n }\n\n const hasBody = request.method !== 'GET' && request.method !== 'DELETE';\n const body = hasBody ? await request.text() : undefined;\n // Only the connection is caught. A service that answers — with anything,\n // a `500` included — is passed through below exactly as it answered.\n const upstream = await fetch(\n `${baseUrl}/api/${[pathPrefix, ...path].filter(Boolean).join('/')}${search}`,\n { method: request.method, headers, body, cache: 'no-store' },\n ).catch(() => undefined);\n\n // `502`, never the `500` an unhandled rejection becomes: the host did\n // nothing wrong, and a `500` sends whoever reads it looking for a bug in\n // the app rather than for a process that is not running.\n if (upstream === undefined) {\n return problemResponse(\n new UpstreamUnavailable(\n pathPrefix === undefined ? {} : { upstream: pathPrefix },\n ),\n {\n status: 502,\n },\n );\n }\n\n const passed = passThrough(upstream);\n\n // A `304` carries no body by definition, and neither does a `204`. Running\n // either through a body read is not merely wasteful — a `304` reconstructed\n // with a body is no longer a `304`.\n if (upstream.status === 304) {\n return new NextResponse(null, { status: 304, headers: passed });\n }\n\n // Piped, never buffered, and never re-typed as JSON: the body is an open\n // stream of frames, and `no-store` is correctness rather than politeness —\n // this response is scoped to one principal.\n if (isStreamed(upstream)) {\n return new NextResponse(upstream.body, {\n status: upstream.status,\n headers: {\n ...passed,\n 'content-type': 'text/event-stream',\n 'cache-control': 'no-store',\n },\n });\n }\n\n // 204s and empty bodies must not be given a body. The upstream's content\n // type is kept, never re-typed: a problem body re-typed as plain JSON is\n // one the browser can no longer recognise as a failure it can decode.\n const text = await upstream.text();\n return text === ''\n ? new NextResponse(null, { status: upstream.status, headers: passed })\n : new NextResponse(text, {\n status: upstream.status,\n headers: {\n ...passed,\n 'content-type':\n upstream.headers.get('content-type') ?? 'application/json',\n },\n });\n };\n\n return forward;\n};\n"],"names":["DEADLINE_HEADER","UpstreamUnavailable","NextResponse","bearerHeader","sessionToken","problemResponse","PASSED_RESPONSE_HEADERS","FORWARDED_REQUEST_HEADERS","isStreamed","upstream","headers","get","startsWith","passThrough","name","value","createServiceProxyRoute","baseUrl","pathPrefix","forward","request","params","path","search","URL","url","hasBody","method","body","text","undefined","fetch","filter","Boolean","join","cache","catch","status","passed"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Entity, type EntityConstructor, type EntityMetadataSource, type TransitionalFailure } from '@entifix/core';
|
|
2
2
|
import { type RenderLink } from '@entifix/react-controls';
|
|
3
3
|
import { type EntityDraftStore } from '@entifix/react-integration';
|
|
4
4
|
import type { EntityCatalogKey, EntityCrudLinkSource } from './make-entity-crud.types.js';
|
|
@@ -12,7 +12,7 @@ export interface EntityCrudFormProps<TEntity extends Entity> {
|
|
|
12
12
|
readonly isLoading?: boolean;
|
|
13
13
|
readonly isSaving?: boolean;
|
|
14
14
|
readonly isDeleting?: boolean;
|
|
15
|
-
readonly error?:
|
|
15
|
+
readonly error?: TransitionalFailure;
|
|
16
16
|
readonly onSave: (entity: TEntity) => void;
|
|
17
17
|
/** Omitted for a create — there is nothing to delete yet. */
|
|
18
18
|
readonly onDelete?: () => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-crud-form.d.ts","sourceRoot":"","sources":["../../../src/lib/crud/entity-crud-form.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,
|
|
1
|
+
{"version":3,"file":"entity-crud-form.d.ts","sourceRoot":"","sources":["../../../src/lib/crud/entity-crud-form.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,MAAM,EACX,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EAEzB,KAAK,mBAAmB,EACzB,MAAM,eAAe,CAAC;AACvB,OAAO,EAGL,KAAK,UAAU,EAGhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,4BAA4B,CAAC;AAGpC,OAAO,KAAK,EACV,gBAAgB,EAChB,oBAAoB,EACrB,MAAM,6BAA6B,CAAC;AAKrC,MAAM,WAAW,mBAAmB,CAAC,OAAO,SAAS,MAAM;IACzD,QAAQ,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,CAAC;IACvD,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;IACtC,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,QAAQ,CAAC,KAAK,EAAE,SAAS,oBAAoB,EAAE,CAAC;IAChD,mEAAmE;IACnE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,mBAAmB,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAC3C,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,oBAAoB,CAAC;IAC/C,sEAAsE;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,OAAO,SAAS,MAAM,EAAE,EACrD,iBAAiB,EACjB,UAAU,EACV,YAAY,EACZ,KAAK,EACL,MAAM,EACN,SAAiB,EACjB,QAAgB,EAChB,UAAkB,EAClB,KAAK,EACL,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,UAAU,EACV,KAAK,EACL,cAAc,EACd,SAAS,GACV,EAAE,mBAAmB,CAAC,OAAO,CAAC,+BAiH9B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/crud/entity-crud-form.tsx"],"sourcesContent":["'use client';\n\nimport {\n type EntifixError,\n type Entity,\n type EntityConstructor,\n type EntityMetadataSource,\n reconstructEntity,\n} from '@entifix/core';\nimport {\n EntityField,\n EntityForm,\n type RenderLink,\n useT,\n useTranslateKey,\n} from '@entifix/react-controls';\nimport {\n type EntityDraftStore,\n useEntityForm,\n} from '@entifix/react-integration';\nimport { useEffect, useMemo } from 'react';\n\nimport type {\n EntityCatalogKey,\n EntityCrudLinkSource,\n} from './make-entity-crud.types.js';\nimport { useEntityAffordances } from './use-entity-affordances.js';\nimport { useEntityLinkSources } from './use-entity-link-sources.js';\nimport { validationMessagesFor } from './validation-messages.js';\n\nexport interface EntityCrudFormProps<TEntity extends Entity> {\n readonly entityConstructor: EntityConstructor<TEntity>;\n readonly catalogKey: EntityCatalogKey;\n readonly hiddenFields: readonly string[];\n readonly links: readonly EntityCrudLinkSource[];\n /** The record being edited; `undefined` means this is a create. */\n readonly entity?: TEntity;\n readonly isLoading?: boolean;\n readonly isSaving?: boolean;\n readonly isDeleting?: boolean;\n readonly error?: EntifixError;\n readonly onSave: (entity: TEntity) => void;\n /** Omitted for a create — there is nothing to delete yet. */\n readonly onDelete?: () => void;\n readonly backHref: string;\n /** Renders the Back link; see `useCrudRenderLink`. */\n readonly renderLink?: RenderLink;\n /**\n * Where to autosave this form's draft. Omitted by a route host, which is what\n * keeps a plain route ephemeral; the workspace tab host supplies one.\n */\n readonly draft?: EntityDraftStore;\n /**\n * Where this caller's affordances come from. Absent keeps the pre-ADR-0026\n * behaviour: Save and Delete render unconditionally and no declared verb\n * appears — which is what every generated catalog did before it existed.\n */\n readonly metadataSource?: EntityMetadataSource;\n /** Runs a declared `entity`-bound verb on this record, by its key. */\n readonly onUseCase?: (key: string) => void;\n}\n\n/**\n * The generated create/update form. A thin wrapper over the agnostic\n * {@link EntityForm}: every field, label and validation rule comes from the\n * entity's own accessor metadata, and `reconstructEntity` derives the submit\n * rebuild from that same metadata — so nothing here is per-entity but the\n * constructor, the catalog key and the hidden-field list.\n *\n * A hidden member still round-trips. `code` is assigned by the create\n * transaction and never shown, but it is a plain writable member, so the rebuild\n * picks it out of the seeded draft; dropping it from the rendered fields is not\n * dropping it from the draft.\n *\n * `useEntityForm` owns the draft and seeds it once, so the page keys this\n * component by the record id to reseed when the record arrives.\n */\nexport function EntityCrudForm<TEntity extends Entity>({\n entityConstructor,\n catalogKey,\n hiddenFields,\n links,\n entity,\n isLoading = false,\n isSaving = false,\n isDeleting = false,\n error,\n onSave,\n onDelete,\n backHref,\n renderLink,\n draft,\n metadataSource,\n onUseCase,\n}: EntityCrudFormProps<TEntity>) {\n const et = useT('entity');\n const ct = useT('controls');\n\n // `controls` copy, resolved here and handed down; see validationMessagesFor.\n const validationMessages = useMemo(() => validationMessagesFor(ct), [ct]);\n\n // A runtime-key translator, so a message names the field by its catalog word\n // (`labelKey`) and a schema's keyed message resolves at all. Without it both\n // fell back to untranslated text inside an otherwise Spanish sentence.\n const translateKey = useTranslateKey();\n\n const form = useEntityForm<TEntity>({\n entityConstructor,\n entity,\n validationMessages,\n translateKey,\n draft,\n onSubmit: values =>\n onSave(\n reconstructEntity(entityConstructor, values, { existing: entity }),\n ),\n });\n\n const linkSources = useEntityLinkSources(links, {\n values: form.values,\n selection: form.links,\n });\n\n // Refill the relation sidecar from the ids the draft did keep.\n //\n // A draft is JSON, so it holds ids and nothing else; the instances behind them\n // die with the page. Each picker already resolves its id — that is how a\n // restored draft shows a brand's name instead of its key — so the instance is\n // in hand and only has to be handed back. Without this, a member declared\n // `linkSerialization: 'embedded'` reaches `applyEntityLinks` with an id and no\n // target, which it refuses rather than writing the wrong wire shape.\n //\n // `hydrateLink`, not `setLink`: this is a lookup landing, not a pick, so it\n // must leave the draft — and the form's dirty flag — alone.\n const { links: selection, hydrateLink } = form;\n useEffect(() => {\n for (const [field, source] of Object.entries(linkSources)) {\n const resolved = source.selected.entity;\n if (resolved !== undefined && selection[field] === undefined) {\n hydrateLink(field, resolved);\n }\n }\n }, [linkSources, selection, hydrateLink]);\n\n const affordances = useEntityAffordances(entityConstructor, metadataSource);\n\n /**\n * May this caller write the record in front of them?\n *\n * The **same authority that withholds Save**, applied one step earlier.\n * Absent metadata keeps the pre-ADR-0026 behaviour, so an un-migrated call\n * site is untouched; present metadata is authoritative, because it is what\n * the service already decided.\n *\n * ⚠️ Without this the form rendered inputs nobody could submit. Measured on\n * the stock surface: `stock-item` answers `[\"read\"]` — no role holds\n * `stock-item:write` and there is no save route — so Save was correctly\n * absent while `onHand` and `reserved` still rendered as **enabled number\n * inputs**. A vendor could type a new quantity into a field that is only ever\n * moved by a ledger, and nothing would happen and nothing would say why.\n *\n * A create is exempt: there is no record yet, so there is nothing to read and\n * a `read` mode would render an empty form with no way to fill it.\n */\n const mayWrite =\n entity === undefined ||\n affordances.metadata === undefined ||\n affordances.metadata.actions.includes('write');\n\n return (\n <EntityForm<TEntity>\n entityConstructor={entityConstructor}\n entity={entity}\n // Driven by the served descriptor rather than fixed: `edit` for a caller\n // who may write, `read` for one who may not. Passing it at all is what\n // suppresses the built-in Ver/Editar toggle, which would otherwise offer\n // a way back into inputs the service will refuse.\n mode={mayWrite ? 'edit' : 'read'}\n values={form.values}\n onFieldChange={form.setField}\n linkSources={linkSources}\n onLinkChange={form.setLink}\n errors={form.errors}\n formError={form.formError}\n onSubmit={form.submit}\n onDelete={onDelete}\n {...affordances}\n onUseCase={onUseCase}\n isLoading={isLoading}\n isSaving={isSaving}\n isDeleting={isDeleting}\n error={error}\n backHref={backHref}\n renderLink={renderLink}\n // `entity` is undefined until the record lands, so testing it alone\n // titled a loading edit form \"New\" and then relabelled it.\n title={et(\n entity || isLoading\n ? `${catalogKey}.form.editTitle`\n : `${catalogKey}.form.newTitle`,\n )}\n >\n {hiddenFields.map(field => (\n <EntityField<TEntity> key={field} field={field} hidden />\n ))}\n </EntityForm>\n );\n}\n"],"names":["reconstructEntity","EntityField","EntityForm","useT","useTranslateKey","useEntityForm","useEffect","useMemo","useEntityAffordances","useEntityLinkSources","validationMessagesFor","EntityCrudForm","entityConstructor","catalogKey","hiddenFields","links","entity","isLoading","isSaving","isDeleting","error","onSave","onDelete","backHref","renderLink","draft","metadataSource","onUseCase","et","ct","validationMessages","translateKey","form","onSubmit","values","existing","linkSources","selection","hydrateLink","field","source","Object","entries","resolved","selected","undefined","affordances","mayWrite","metadata","actions","includes","mode","onFieldChange","setField","onLinkChange","setLink","errors","formError","submit","title","map","hidden"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/crud/entity-crud-form.tsx"],"sourcesContent":["'use client';\n\nimport {\n type Entity,\n type EntityConstructor,\n type EntityMetadataSource,\n reconstructEntity,\n type TransitionalFailure,\n} from '@entifix/core';\nimport {\n EntityField,\n EntityForm,\n type RenderLink,\n useT,\n useTranslateKey,\n} from '@entifix/react-controls';\nimport {\n type EntityDraftStore,\n useEntityForm,\n} from '@entifix/react-integration';\nimport { useEffect, useMemo } from 'react';\n\nimport type {\n EntityCatalogKey,\n EntityCrudLinkSource,\n} from './make-entity-crud.types.js';\nimport { useEntityAffordances } from './use-entity-affordances.js';\nimport { useEntityLinkSources } from './use-entity-link-sources.js';\nimport { validationMessagesFor } from './validation-messages.js';\n\nexport interface EntityCrudFormProps<TEntity extends Entity> {\n readonly entityConstructor: EntityConstructor<TEntity>;\n readonly catalogKey: EntityCatalogKey;\n readonly hiddenFields: readonly string[];\n readonly links: readonly EntityCrudLinkSource[];\n /** The record being edited; `undefined` means this is a create. */\n readonly entity?: TEntity;\n readonly isLoading?: boolean;\n readonly isSaving?: boolean;\n readonly isDeleting?: boolean;\n readonly error?: TransitionalFailure;\n readonly onSave: (entity: TEntity) => void;\n /** Omitted for a create — there is nothing to delete yet. */\n readonly onDelete?: () => void;\n readonly backHref: string;\n /** Renders the Back link; see `useCrudRenderLink`. */\n readonly renderLink?: RenderLink;\n /**\n * Where to autosave this form's draft. Omitted by a route host, which is what\n * keeps a plain route ephemeral; the workspace tab host supplies one.\n */\n readonly draft?: EntityDraftStore;\n /**\n * Where this caller's affordances come from. Absent keeps the pre-ADR-0026\n * behaviour: Save and Delete render unconditionally and no declared verb\n * appears — which is what every generated catalog did before it existed.\n */\n readonly metadataSource?: EntityMetadataSource;\n /** Runs a declared `entity`-bound verb on this record, by its key. */\n readonly onUseCase?: (key: string) => void;\n}\n\n/**\n * The generated create/update form. A thin wrapper over the agnostic\n * {@link EntityForm}: every field, label and validation rule comes from the\n * entity's own accessor metadata, and `reconstructEntity` derives the submit\n * rebuild from that same metadata — so nothing here is per-entity but the\n * constructor, the catalog key and the hidden-field list.\n *\n * A hidden member still round-trips. `code` is assigned by the create\n * transaction and never shown, but it is a plain writable member, so the rebuild\n * picks it out of the seeded draft; dropping it from the rendered fields is not\n * dropping it from the draft.\n *\n * `useEntityForm` owns the draft and seeds it once, so the page keys this\n * component by the record id to reseed when the record arrives.\n */\nexport function EntityCrudForm<TEntity extends Entity>({\n entityConstructor,\n catalogKey,\n hiddenFields,\n links,\n entity,\n isLoading = false,\n isSaving = false,\n isDeleting = false,\n error,\n onSave,\n onDelete,\n backHref,\n renderLink,\n draft,\n metadataSource,\n onUseCase,\n}: EntityCrudFormProps<TEntity>) {\n const et = useT('entity');\n const ct = useT('controls');\n\n // `controls` copy, resolved here and handed down; see validationMessagesFor.\n const validationMessages = useMemo(() => validationMessagesFor(ct), [ct]);\n\n // A runtime-key translator, so a message names the field by its catalog word\n // (`labelKey`) and a schema's keyed message resolves at all. Without it both\n // fell back to untranslated text inside an otherwise Spanish sentence.\n const translateKey = useTranslateKey();\n\n const form = useEntityForm<TEntity>({\n entityConstructor,\n entity,\n validationMessages,\n translateKey,\n draft,\n onSubmit: values =>\n onSave(\n reconstructEntity(entityConstructor, values, { existing: entity }),\n ),\n });\n\n const linkSources = useEntityLinkSources(links, {\n values: form.values,\n selection: form.links,\n });\n\n // Refill the relation sidecar from the ids the draft did keep.\n //\n // A draft is JSON, so it holds ids and nothing else; the instances behind them\n // die with the page. Each picker already resolves its id — that is how a\n // restored draft shows a brand's name instead of its key — so the instance is\n // in hand and only has to be handed back. Without this, a member declared\n // `linkSerialization: 'embedded'` reaches `applyEntityLinks` with an id and no\n // target, which it refuses rather than writing the wrong wire shape.\n //\n // `hydrateLink`, not `setLink`: this is a lookup landing, not a pick, so it\n // must leave the draft — and the form's dirty flag — alone.\n const { links: selection, hydrateLink } = form;\n useEffect(() => {\n for (const [field, source] of Object.entries(linkSources)) {\n const resolved = source.selected.entity;\n if (resolved !== undefined && selection[field] === undefined) {\n hydrateLink(field, resolved);\n }\n }\n }, [linkSources, selection, hydrateLink]);\n\n const affordances = useEntityAffordances(entityConstructor, metadataSource);\n\n /**\n * May this caller write the record in front of them?\n *\n * The **same authority that withholds Save**, applied one step earlier.\n * Absent metadata keeps the pre-ADR-0026 behaviour, so an un-migrated call\n * site is untouched; present metadata is authoritative, because it is what\n * the service already decided.\n *\n * ⚠️ Without this the form rendered inputs nobody could submit. Measured on\n * the stock surface: `stock-item` answers `[\"read\"]` — no role holds\n * `stock-item:write` and there is no save route — so Save was correctly\n * absent while `onHand` and `reserved` still rendered as **enabled number\n * inputs**. A vendor could type a new quantity into a field that is only ever\n * moved by a ledger, and nothing would happen and nothing would say why.\n *\n * A create is exempt: there is no record yet, so there is nothing to read and\n * a `read` mode would render an empty form with no way to fill it.\n */\n const mayWrite =\n entity === undefined ||\n affordances.metadata === undefined ||\n affordances.metadata.actions.includes('write');\n\n return (\n <EntityForm<TEntity>\n entityConstructor={entityConstructor}\n entity={entity}\n // Driven by the served descriptor rather than fixed: `edit` for a caller\n // who may write, `read` for one who may not. Passing it at all is what\n // suppresses the built-in Ver/Editar toggle, which would otherwise offer\n // a way back into inputs the service will refuse.\n mode={mayWrite ? 'edit' : 'read'}\n values={form.values}\n onFieldChange={form.setField}\n linkSources={linkSources}\n onLinkChange={form.setLink}\n errors={form.errors}\n formError={form.formError}\n onSubmit={form.submit}\n onDelete={onDelete}\n {...affordances}\n onUseCase={onUseCase}\n isLoading={isLoading}\n isSaving={isSaving}\n isDeleting={isDeleting}\n error={error}\n backHref={backHref}\n renderLink={renderLink}\n // `entity` is undefined until the record lands, so testing it alone\n // titled a loading edit form \"New\" and then relabelled it.\n title={et(\n entity || isLoading\n ? `${catalogKey}.form.editTitle`\n : `${catalogKey}.form.newTitle`,\n )}\n >\n {hiddenFields.map(field => (\n <EntityField<TEntity> key={field} field={field} hidden />\n ))}\n </EntityForm>\n );\n}\n"],"names":["reconstructEntity","EntityField","EntityForm","useT","useTranslateKey","useEntityForm","useEffect","useMemo","useEntityAffordances","useEntityLinkSources","validationMessagesFor","EntityCrudForm","entityConstructor","catalogKey","hiddenFields","links","entity","isLoading","isSaving","isDeleting","error","onSave","onDelete","backHref","renderLink","draft","metadataSource","onUseCase","et","ct","validationMessages","translateKey","form","onSubmit","values","existing","linkSources","selection","hydrateLink","field","source","Object","entries","resolved","selected","undefined","affordances","mayWrite","metadata","actions","includes","mode","onFieldChange","setField","onLinkChange","setLink","errors","formError","submit","title","map","hidden"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"make-entity-crud.d.ts","sourceRoot":"","sources":["../../../src/lib/crud/make-entity-crud.tsx"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,mBAAmB,EAIzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,
|
|
1
|
+
{"version":3,"file":"make-entity-crud.d.ts","sourceRoot":"","sources":["../../../src/lib/crud/make-entity-crud.tsx"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,mBAAmB,EAIzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAEL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAOvB,MAAM,eAAe,CAAC;AAQvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAajC,OAAO,KAAK,EAEV,UAAU,EAEV,iBAAiB,EAElB,MAAM,6BAA6B,CAAC;AAUrC;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GAAG,mBAAmB,GAAG,kBAAkB,CAAC;AAEnE;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EACxC,QAAQ,EAAE,SAAS,EACnB,UAAU,EAAE,MAAM,SAAS,EAC3B,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAM9B;AA2DD,wBAAgB,cAAc,CAAC,OAAO,SAAS,MAAM,EAAE,SAAS,EAC9D,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,OAAO,EAAE,iBAAiB,CAAC,SAAS,EAAE,OAAO,CAAC,GAC7C,UAAU,CAAC,OAAO,CAAC,CAuXrB"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
3
3
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
4
|
import { deleteUCFactory, getUCFactory, loadUCFactory, saveUCFactory } from '@entifix/business';
|
|
5
|
-
import { describeEntityColumns,
|
|
5
|
+
import { describeEntityColumns, envelopeEntityName, extractMetaEntity, Internal } from '@entifix/core';
|
|
6
6
|
import { EntityTable, useCasesForSurface } from '@entifix/react-controls';
|
|
7
7
|
import { entityQueryScope, useDataLoading, useEntityMutation, useEntityRecord } from '@entifix/react-integration';
|
|
8
8
|
import { TransactionSinkTag } from '@entifix/transactions';
|
|
@@ -53,12 +53,15 @@ import { useEntityBulk } from './use-entity-bulk.js';
|
|
|
53
53
|
*/ /**
|
|
54
54
|
* A rejected verb, as something the form's error slot can render.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* keeps
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
* A typed failure is kept as it is: `useErrorMessage` translates its own code,
|
|
57
|
+
* a business refusal and an outage alike, and the failure toast that tells
|
|
58
|
+
* them apart arrives with the optimistic API. Anything else that carries a
|
|
59
|
+
* message keeps it rather than being relabelled with a code it never had; a
|
|
60
|
+
* thrown value with no message at all is `Internal`.
|
|
61
|
+
*/ function asFailure(failure) {
|
|
62
|
+
// A typed failure is an `Error` too — Effect's tagged errors extend it — so
|
|
63
|
+
// one check keeps both it and a legacy error carrying its own message.
|
|
64
|
+
return failure instanceof Error ? failure : new Internal();
|
|
62
65
|
}
|
|
63
66
|
/**
|
|
64
67
|
* Runs each link's `useBaseFiltering` against the record being edited, in link
|
|
@@ -74,14 +77,14 @@ import { useEntityBulk } from './use-entity-bulk.js';
|
|
|
74
77
|
export function makeEntityCrud(entityConstructor, options) {
|
|
75
78
|
const { useAdapters, basePath, catalogKey, repository, hiddenFields = [], columns, toolbar, pageSize, links = [], metadataSource: givenMetadataSource, metadata, runBulkUseCase, runUseCase } = options;
|
|
76
79
|
if (givenMetadataSource !== undefined && metadata !== undefined) {
|
|
77
|
-
throw new
|
|
80
|
+
throw new Error(`${entityConstructor.name} was given both metadataSource and metadata; pass one`);
|
|
78
81
|
}
|
|
79
82
|
// An adapter value, so it is resolved per render from the same `useAdapters()`
|
|
80
83
|
// the records come from — the host's composition, never the caller's module.
|
|
81
84
|
const metadataSourceOf = (adapters)=>metadata === undefined ? givenMetadataSource : adapters[metadata];
|
|
82
85
|
const meta = extractMetaEntity(entityConstructor);
|
|
83
86
|
if (meta.key !== catalogKey) {
|
|
84
|
-
throw new
|
|
87
|
+
throw new Error(`${entityConstructor.name} declares key "${String(meta.key)}" but was given catalog key "${catalogKey}"`);
|
|
85
88
|
}
|
|
86
89
|
// Read off `@entity()` rather than rebuilt from `catalogKey`. Both spellings
|
|
87
90
|
// produce the same string today, and that is exactly the problem: a second
|
|
@@ -91,7 +94,7 @@ export function makeEntityCrud(entityConstructor, options) {
|
|
|
91
94
|
// rather than on the render that first tries to title a tab with `undefined`.
|
|
92
95
|
const { labelKey: entityLabelKey, pluralKey: entityPluralKey } = meta;
|
|
93
96
|
if (entityLabelKey === undefined || entityPluralKey === undefined) {
|
|
94
|
-
throw new
|
|
97
|
+
throw new Error(`${entityConstructor.name} must declare labelKey and pluralKey on @entity() to be generated`);
|
|
95
98
|
}
|
|
96
99
|
// Computed once: a descriptor is a property of the class, so nothing about it
|
|
97
100
|
// depends on the record being edited. `linkLabelProperty`/`linkSearchProperty`
|
|
@@ -106,7 +109,7 @@ export function makeEntityCrud(entityConstructor, options) {
|
|
|
106
109
|
var _link_labelProperty, _link_searchProperty;
|
|
107
110
|
const descriptor = descriptors.find((entry)=>entry.name === link.field);
|
|
108
111
|
if (descriptor === undefined) {
|
|
109
|
-
throw new
|
|
112
|
+
throw new Error(`${entityConstructor.name} has no member "${link.field}" for a link source`);
|
|
110
113
|
}
|
|
111
114
|
return _extends({}, link, {
|
|
112
115
|
descriptor: _extends({}, descriptor, {
|
|
@@ -190,7 +193,7 @@ export function makeEntityCrud(entityConstructor, options) {
|
|
|
190
193
|
setUseCaseError(undefined);
|
|
191
194
|
void runUseCase(key, item.id).then(()=>queryClient.invalidateQueries({
|
|
192
195
|
queryKey: scope
|
|
193
|
-
}), (failure)=>setUseCaseError(
|
|
196
|
+
}), (failure)=>setUseCaseError(asFailure(failure)));
|
|
194
197
|
} : undefined;
|
|
195
198
|
return /*#__PURE__*/ _jsxs("div", {
|
|
196
199
|
className: "flex flex-col gap-s",
|
|
@@ -331,7 +334,7 @@ export function makeEntityCrud(entityConstructor, options) {
|
|
|
331
334
|
* appeared to do nothing at all.
|
|
332
335
|
*/ const handleUseCase = runUseCase === undefined || id == null ? undefined : (key)=>{
|
|
333
336
|
setUseCaseError(undefined);
|
|
334
|
-
void runUseCase(key, id).then(()=>reload(), (failure)=>setUseCaseError(
|
|
337
|
+
void runUseCase(key, id).then(()=>reload(), (failure)=>setUseCaseError(asFailure(failure)));
|
|
335
338
|
};
|
|
336
339
|
return /*#__PURE__*/ _jsx(EntityCrudForm, {
|
|
337
340
|
entityConstructor: entityConstructor,
|