@entifix/next-shell 0.5.0 → 0.7.0
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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/back-office/group-by-screen-type.d.ts +2 -2
- package/dist/lib/back-office/group-by-screen-type.js +2 -2
- package/dist/lib/back-office/group-by-screen-type.js.map +1 -1
- package/dist/lib/back-office/nav.d.ts +1 -2
- package/dist/lib/back-office/nav.d.ts.map +1 -1
- package/dist/lib/back-office/nav.js.map +1 -1
- package/dist/lib/back-office/sidebar-nav.d.ts +2 -2
- package/dist/lib/back-office/sidebar-nav.js +2 -2
- package/dist/lib/back-office/sidebar-nav.js.map +1 -1
- package/dist/lib/command-palette/command-palette-host.js +1 -1
- package/dist/lib/command-palette/command-palette-host.js.map +1 -1
- package/dist/lib/command-palette/use-command-recency.d.ts +1 -1
- package/dist/lib/command-palette/use-command-recency.js +1 -1
- package/dist/lib/command-palette/use-command-recency.js.map +1 -1
- package/dist/lib/command-palette/use-nav-source.d.ts +4 -3
- package/dist/lib/command-palette/use-nav-source.d.ts.map +1 -1
- package/dist/lib/command-palette/use-nav-source.js +4 -3
- package/dist/lib/command-palette/use-nav-source.js.map +1 -1
- package/dist/lib/command-palette/use-records-source.d.ts +1 -1
- package/dist/lib/command-palette/use-records-source.js +2 -2
- package/dist/lib/command-palette/use-records-source.js.map +1 -1
- package/dist/lib/command-palette/use-tabs-source.d.ts +1 -1
- package/dist/lib/command-palette/use-tabs-source.js +1 -1
- package/dist/lib/command-palette/use-tabs-source.js.map +1 -1
- package/dist/lib/command-palette/use-use-cases-source.js +2 -2
- package/dist/lib/command-palette/use-use-cases-source.js.map +1 -1
- package/dist/lib/config/service-proxy-route.d.ts +11 -2
- package/dist/lib/config/service-proxy-route.d.ts.map +1 -1
- package/dist/lib/config/service-proxy-route.js +27 -4
- package/dist/lib/config/service-proxy-route.js.map +1 -1
- package/dist/lib/crud/entity-crud-form.js +3 -3
- package/dist/lib/crud/entity-crud-form.js.map +1 -1
- package/dist/lib/crud/hand-off-write.d.ts +1 -1
- package/dist/lib/crud/hand-off-write.js +1 -1
- package/dist/lib/crud/hand-off-write.js.map +1 -1
- package/dist/lib/crud/make-entity-crud.d.ts.map +1 -1
- package/dist/lib/crud/make-entity-crud.js +5 -6
- package/dist/lib/crud/make-entity-crud.js.map +1 -1
- package/dist/lib/crud/make-entity-crud.types.d.ts +8 -10
- package/dist/lib/crud/make-entity-crud.types.d.ts.map +1 -1
- package/dist/lib/crud/make-entity-crud.types.js.map +1 -1
- package/dist/lib/crud/pending-notice.d.ts +2 -2
- package/dist/lib/crud/pending-notice.js +2 -2
- package/dist/lib/crud/pending-notice.js.map +1 -1
- package/dist/lib/crud/use-crud-render-link.d.ts.map +1 -1
- package/dist/lib/crud/use-crud-render-link.js +5 -1
- package/dist/lib/crud/use-crud-render-link.js.map +1 -1
- package/dist/lib/crud/use-entity-affordances.d.ts +1 -1
- package/dist/lib/crud/use-entity-affordances.js +1 -1
- package/dist/lib/crud/use-entity-affordances.js.map +1 -1
- package/dist/lib/crud/use-entity-bulk.d.ts +2 -2
- package/dist/lib/crud/use-entity-bulk.js +2 -2
- package/dist/lib/crud/use-entity-bulk.js.map +1 -1
- package/dist/lib/i18n/catalog/en.d.ts.map +1 -1
- package/dist/lib/i18n/catalog/en.js +1 -119
- package/dist/lib/i18n/catalog/en.js.map +1 -1
- package/dist/lib/i18n/catalog/es.d.ts +2 -131
- package/dist/lib/i18n/catalog/es.d.ts.map +1 -1
- package/dist/lib/i18n/catalog/es.js +2 -136
- package/dist/lib/i18n/catalog/es.js.map +1 -1
- package/dist/lib/i18n/catalog/index.d.ts +0 -226
- package/dist/lib/i18n/catalog/index.d.ts.map +1 -1
- package/dist/lib/i18n/spec-catalog.js +4 -4
- package/dist/lib/i18n/spec-catalog.js.map +1 -1
- package/dist/lib/search/record-search-route.d.ts +1 -1
- package/dist/lib/search/record-search-route.js +1 -1
- package/dist/lib/search/record-search-route.js.map +1 -1
- package/dist/lib/search/record-search-source.d.ts +1 -2
- package/dist/lib/search/record-search-source.d.ts.map +1 -1
- package/dist/lib/search/record-search-source.js.map +1 -1
- package/dist/lib/search/record-search.types.d.ts +1 -1
- package/dist/lib/search/record-search.types.js +1 -1
- package/dist/lib/search/record-search.types.js.map +1 -1
- package/dist/lib/session/account-links.js +1 -1
- package/dist/lib/session/account-links.js.map +1 -1
- package/dist/lib/session/session-keepalive.d.ts +2 -4
- package/dist/lib/session/session-keepalive.d.ts.map +1 -1
- package/dist/lib/session/session-keepalive.js +2 -4
- package/dist/lib/session/session-keepalive.js.map +1 -1
- package/dist/lib/session/use-session-refresh.js +1 -1
- package/dist/lib/session/use-session-refresh.js.map +1 -1
- package/dist/lib/wizard/wizard-tab-kind.d.ts +1 -2
- package/dist/lib/wizard/wizard-tab-kind.d.ts.map +1 -1
- package/dist/lib/wizard/wizard-tab-kind.js +1 -2
- package/dist/lib/wizard/wizard-tab-kind.js.map +1 -1
- package/dist/lib/workspace/drafts-state.d.ts.map +1 -1
- package/dist/lib/workspace/drafts-state.js +3 -2
- package/dist/lib/workspace/drafts-state.js.map +1 -1
- package/dist/lib/workspace/entity-tab-kind.d.ts +1 -2
- package/dist/lib/workspace/entity-tab-kind.d.ts.map +1 -1
- package/dist/lib/workspace/entity-tab-kind.js +1 -2
- package/dist/lib/workspace/entity-tab-kind.js.map +1 -1
- package/dist/lib/workspace/pending-state.d.ts +5 -4
- package/dist/lib/workspace/pending-state.d.ts.map +1 -1
- package/dist/lib/workspace/pending-state.js +1 -1
- package/dist/lib/workspace/pending-state.js.map +1 -1
- package/dist/lib/workspace/tabs-state.js +1 -1
- package/dist/lib/workspace/tabs-state.js.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/package.json +9 -9
|
@@ -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 EntifixLogicError,\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
|
|
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 EntifixLogicError,\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 EntifixLogicError(\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 undefined,\n { entity: name, key: descriptor.key },\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":["EntifixLogicError","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","id","label","labelKey","keywords","keywordsKey","confirm","tone","message","messageKey","groups","filter","option","useUseCaseSources","entities","sources","push"],"mappings":"AAAA;;AAEA,SAGEA,iBAAiB,EAIjBC,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,IAAI7B,kBACR,CAAC,cAAc,EAAEyB,WAAWG,GAAG,CAAC,KAAK,EAAEN,KAAK,yEAAyE,CAAC,GACpH,6EACFO,WACA;oBAAEnB,QAAQY;oBAAMM,KAAKH,WAAWG,GAAG;gBAAC;YAExC;YAEA,OAAO;gBACLE,IAAI,CAAC,SAAS,EAAER,KAAK,CAAC,EAAEG,WAAWG,GAAG,EAAE;gBACxCG,OAAOlB,aAAaY,WAAWO,QAAQ;gBACvCC,UAAU9B,cACRsB,WAAWS,WAAW,KAAKL,YACvBA,YACAhB,aAAaY,WAAWS,WAAW;gBAEzCR;eACID,WAAWU,OAAO,KAAKN,YACvB,CAAC,IACD;gBACEM,SAAS;oBACPC,MAAMX,WAAWU,OAAO,CAACC,IAAI;oBAC7BC,SAASxB,aAAaY,WAAWU,OAAO,CAACG,UAAU;gBACrD;YACF;QAER;QAEA,OAAO;YACLV,KAAK,CAAC,UAAU,EAAEN,MAAM;YACxBiB,QAAQ;gBACN;oBACEX,KAAK,CAAC,UAAU,EAAEN,MAAM;oBACxBS,OAAOnB,EAAE;oBACTW,SAASA,QAAQiB,MAAM,CAACC,CAAAA,SAAUvC,eAAeS,MAAM8B;oBACvDvB;gBACF;aACD;QACH;IACF,GAAG;QAACR;QAAQU;QAAaF;QAAWP;QAAMC;QAAGC;KAAa;AAC5D;AAEA;;;;;;;;;CASC,GACD,OAAO,SAAS6B,kBACdC,QAAyC,EACzChC,IAAY;IAEZ,MAAMiC,UAA2B,EAAE;IACnC,KAAK,MAAMlC,UAAUiC,SAAU;QAC7B,0HAA0H;QAC1HC,QAAQC,IAAI,CAACpC,kBAAkBC,QAAQC;IACzC;IACA,OAAOiC;AACT"}
|
|
@@ -18,11 +18,20 @@ export interface ServiceProxyRouteOptions {
|
|
|
18
18
|
*
|
|
19
19
|
* Caught on the live lab rather than by any check, and the symptom is exactly
|
|
20
20
|
* the one this proxy exists to prevent — the SSE stream 404s, a pending write
|
|
21
|
-
* never settles, and every probe stays green
|
|
22
|
-
* ([ADR 0036](../../../../../../docs/adr/0036-the-reactive-stream-is-server-sent-and-same-origin.md)).
|
|
21
|
+
* never settles, and every probe stays green.
|
|
23
22
|
*/
|
|
24
23
|
readonly pathPrefix?: string;
|
|
25
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";
|
|
26
35
|
export declare const createServiceProxyRoute: ({ baseUrl, pathPrefix, }: ServiceProxyRouteOptions) => (request: Request, { params }: Params) => Promise<Response>;
|
|
27
36
|
export {};
|
|
28
37
|
//# 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":"AAIA,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
|
|
1
|
+
{"version":3,"file":"service-proxy-route.d.ts","sourceRoot":"","sources":["../../../src/lib/config/service-proxy-route.ts"],"names":[],"mappings":"AAIA,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;AAuDD;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,wBAAwB,CAAC;AAW1D,eAAO,MAAM,uBAAuB,GAAI,0BAGrC,wBAAwB,eAEd,OAAO,cACJ,MAAM,KACjB,OAAO,CAAC,QAAQ,CAqEpB,CAAC"}
|
|
@@ -29,7 +29,7 @@ import { bearerHeader, sessionToken } from '../session/bearer.js';
|
|
|
29
29
|
* A factory, because a host mounts one of these per backend it talks to, and
|
|
30
30
|
* back-office-app now talks to two: marketplace-admin-service for the catalog a
|
|
31
31
|
* vendor authors, marketplace-service for the platform vocabulary it is
|
|
32
|
-
* classified in
|
|
32
|
+
* classified in.
|
|
33
33
|
*/ /**
|
|
34
34
|
* Response headers that must survive the rebuild.
|
|
35
35
|
*
|
|
@@ -49,11 +49,20 @@ import { bearerHeader, sessionToken } from '../session/bearer.js';
|
|
|
49
49
|
* forever and delivers nothing — no error, no timeout, and the browser's
|
|
50
50
|
* `EventSource` sits in `CONNECTING` while the service is happily emitting.
|
|
51
51
|
* It is the most likely way to build the reactive stream, see silence, and go
|
|
52
|
-
* looking in the wrong service
|
|
52
|
+
* looking in the wrong service.
|
|
53
53
|
*/ const isStreamed = (upstream)=>{
|
|
54
54
|
var _upstream_headers_get;
|
|
55
55
|
return ((_upstream_headers_get = upstream.headers.get('content-type')) == null ? void 0 : _upstream_headers_get.startsWith('text/event-stream')) === true;
|
|
56
56
|
};
|
|
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';
|
|
57
66
|
const passThrough = (upstream)=>{
|
|
58
67
|
const headers = {};
|
|
59
68
|
for (const name of CACHE_HEADERS){
|
|
@@ -76,15 +85,29 @@ export const createServiceProxyRoute = ({ baseUrl, pathPrefix })=>{
|
|
|
76
85
|
headers['if-none-match'] = ifNoneMatch;
|
|
77
86
|
}
|
|
78
87
|
const hasBody = request.method !== 'GET' && request.method !== 'DELETE';
|
|
88
|
+
const body = hasBody ? await request.text() : undefined;
|
|
89
|
+
// Only the connection is caught. A service that answers — with anything,
|
|
90
|
+
// a `500` included — is passed through below exactly as it answered.
|
|
79
91
|
const upstream = await fetch(`${baseUrl}/api/${[
|
|
80
92
|
pathPrefix,
|
|
81
93
|
...path
|
|
82
94
|
].filter(Boolean).join('/')}${search}`, {
|
|
83
95
|
method: request.method,
|
|
84
96
|
headers,
|
|
85
|
-
body
|
|
97
|
+
body,
|
|
86
98
|
cache: 'no-store'
|
|
87
|
-
});
|
|
99
|
+
}).catch(()=>undefined);
|
|
100
|
+
if (upstream === undefined) {
|
|
101
|
+
return NextResponse.json({
|
|
102
|
+
error: 'the service behind this route could not be reached',
|
|
103
|
+
code: UPSTREAM_UNAVAILABLE
|
|
104
|
+
}, {
|
|
105
|
+
status: 502,
|
|
106
|
+
headers: {
|
|
107
|
+
'cache-control': 'no-store'
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
88
111
|
const passed = passThrough(upstream);
|
|
89
112
|
// A `304` carries no body by definition, and neither does a `204`. Running
|
|
90
113
|
// either through a body read is not merely wasteful — a `304` reconstructed
|
|
@@ -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
|
|
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"}
|
|
@@ -84,8 +84,8 @@ import { validationMessagesFor } from './validation-messages.js';
|
|
|
84
84
|
* the stock surface: `stock-item` answers `["read"]` — no role holds
|
|
85
85
|
* `stock-item:write` and there is no save route — so Save was correctly
|
|
86
86
|
* absent while `onHand` and `reserved` still rendered as **enabled number
|
|
87
|
-
* inputs**. A vendor could type a new quantity into
|
|
88
|
-
*
|
|
87
|
+
* inputs**. A vendor could type a new quantity into a field that is only ever
|
|
88
|
+
* moved by a ledger, and nothing would happen and nothing would say why.
|
|
89
89
|
*
|
|
90
90
|
* A create is exempt: there is no record yet, so there is nothing to read and
|
|
91
91
|
* a `read` mode would render an empty form with no way to fill it.
|
|
@@ -115,7 +115,7 @@ import { validationMessagesFor } from './validation-messages.js';
|
|
|
115
115
|
backHref: backHref,
|
|
116
116
|
renderLink: renderLink,
|
|
117
117
|
// `entity` is undefined until the record lands, so testing it alone
|
|
118
|
-
// titled a loading edit form "New" and then relabelled it
|
|
118
|
+
// titled a loading edit form "New" and then relabelled it.
|
|
119
119
|
title: et(entity || isLoading ? `${catalogKey}.form.editTitle` : `${catalogKey}.form.newTitle`),
|
|
120
120
|
children: hiddenFields.map((field)=>/*#__PURE__*/ _jsx(EntityField, {
|
|
121
121
|
field: field,
|
|
@@ -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 the very field ADR 0010\n * forbids writing, 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 (#139).\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 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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"}
|
|
@@ -15,7 +15,7 @@ export interface HandOffWriteOptions {
|
|
|
15
15
|
*
|
|
16
16
|
* A transactional create resolves at the `202`, **before** the write is
|
|
17
17
|
* durable, so the save adapter has already announced it and the id is in the
|
|
18
|
-
* pending set
|
|
18
|
+
* pending set.
|
|
19
19
|
* Handing the record over *is* the question: the returned entity is otherwise
|
|
20
20
|
* indistinguishable from a plain REST create, and asking `entries.some(...)`
|
|
21
21
|
* beforehand would read a set captured before the `await` that announced it.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A transactional create resolves at the `202`, **before** the write is
|
|
5
5
|
* durable, so the save adapter has already announced it and the id is in the
|
|
6
|
-
* pending set
|
|
6
|
+
* pending set.
|
|
7
7
|
* Handing the record over *is* the question: the returned entity is otherwise
|
|
8
8
|
* indistinguishable from a plain REST create, and asking `entries.some(...)`
|
|
9
9
|
* beforehand would read a set captured before the `await` that announced it.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/crud/hand-off-write.ts"],"sourcesContent":["import type { PendingTransactionStore } from '@entifix/transactions';\n\nexport interface HandOffWriteOptions {\n /** The saved record's id — the transaction id, for a tracked create. */\n readonly id: string;\n /** The record as the browser built it, shown until the service confirms. */\n readonly record: unknown;\n readonly pending: PendingTransactionStore;\n /** The draft the write was composed in, if it had one. */\n readonly draft?: { clear(): void };\n}\n\n/**\n * What happens to a draft once its write has been handed over.\n *\n * A transactional create resolves at the `202`, **before** the write is\n * durable, so the save adapter has already announced it and the id is in the\n * pending set
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/crud/hand-off-write.ts"],"sourcesContent":["import type { PendingTransactionStore } from '@entifix/transactions';\n\nexport interface HandOffWriteOptions {\n /** The saved record's id — the transaction id, for a tracked create. */\n readonly id: string;\n /** The record as the browser built it, shown until the service confirms. */\n readonly record: unknown;\n readonly pending: PendingTransactionStore;\n /** The draft the write was composed in, if it had one. */\n readonly draft?: { clear(): void };\n}\n\n/**\n * What happens to a draft once its write has been handed over.\n *\n * A transactional create resolves at the `202`, **before** the write is\n * durable, so the save adapter has already announced it and the id is in the\n * pending set.\n * Handing the record over *is* the question: the returned entity is otherwise\n * indistinguishable from a plain REST create, and asking `entries.some(...)`\n * beforehand would read a set captured before the `await` that announced it.\n *\n * ⚠️ A **still-pending write keeps its draft**. The write has not committed, and\n * a failure minutes from now would otherwise have destroyed the operator's only\n * copy of what they typed. Only a plain, committed write spends it.\n *\n * It lives here rather than inside `makeEntityCrud` because it is not the\n * generator's rule — it is the rule for every screen that writes. A wizard's\n * Finalizar performs the same three steps, and a second copy of them is where\n * one of the two would eventually stop keeping the draft.\n *\n * @returns whether the write is still in flight.\n */\nexport function handOffWrite({\n id,\n record,\n pending,\n draft,\n}: HandOffWriteOptions): boolean {\n if (pending.attach(id, record)) return true;\n\n draft?.clear();\n return false;\n}\n"],"names":["handOffWrite","id","record","pending","draft","attach","clear"],"mappings":"AAYA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASA,aAAa,EAC3BC,EAAE,EACFC,MAAM,EACNC,OAAO,EACPC,KAAK,EACe;IACpB,IAAID,QAAQE,MAAM,CAACJ,IAAIC,SAAS,OAAO;IAEvCE,yBAAAA,MAAOE,KAAK;IACZ,OAAO;AACT"}
|
|
@@ -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,EAKL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAIvB,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,EACV,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;
|
|
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,EAKL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAIvB,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,EACV,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;AAuCD,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,CA4WrB"}
|
|
@@ -49,8 +49,7 @@ import { useEntityBulk } from './use-entity-bulk.js';
|
|
|
49
49
|
*
|
|
50
50
|
* **Serves compile-time `@entity()` classes only.** Operator reference data ships
|
|
51
51
|
* with the code, so making this spec-driven would trade type safety for nothing;
|
|
52
|
-
* a vendor-authored `EntitySpecification` renders through a separate path
|
|
53
|
-
* (ADR 0014).
|
|
52
|
+
* a vendor-authored `EntitySpecification` renders through a separate path.
|
|
54
53
|
*/ /**
|
|
55
54
|
* A rejected verb, as something the form's error slot can render.
|
|
56
55
|
*
|
|
@@ -144,13 +143,13 @@ export function makeEntityCrud(entityConstructor, options) {
|
|
|
144
143
|
// Decided from the served document rather than from the runner, because
|
|
145
144
|
// the runner is a property of the *shell* (it knows the route) and the
|
|
146
145
|
// grant is a property of the *caller*. The column therefore appears when
|
|
147
|
-
// the document lands, which is the asynchrony
|
|
148
|
-
// every action surface.
|
|
146
|
+
// the document lands, which is the asynchrony serving affordances accepts
|
|
147
|
+
// for every action surface.
|
|
149
148
|
const canRunBulk = useCasesForSurface('bulk-bar', (_affordances_metadata = affordances.metadata) == null ? void 0 : _affordances_metadata.useCases).length > 0;
|
|
150
149
|
const bulk = useEntityBulk({
|
|
151
150
|
run: canRunBulk ? runBulkUseCase : undefined,
|
|
152
151
|
// A bulk write changed the rows underneath the listing, so the page is
|
|
153
|
-
// re-read; the selection deliberately survives it (
|
|
152
|
+
// re-read; the selection deliberately survives it ("the selection
|
|
154
153
|
// is still there afterwards"), because the operator's next act is usually
|
|
155
154
|
// to retry the failures or run a second verb on the same rows.
|
|
156
155
|
onCompleted: ()=>{
|
|
@@ -165,7 +164,7 @@ export function makeEntityCrud(entityConstructor, options) {
|
|
|
165
164
|
// ⚠️ **A row verb and the form's are the same runner with different
|
|
166
165
|
// arguments.** `EntityTable` hands back the whole record, because that is
|
|
167
166
|
// what it has; `runUseCase` takes an id, because that is what a route needs
|
|
168
|
-
//
|
|
167
|
+
// The list has no error slot of its own, so a failure lands in the
|
|
169
168
|
// table's own error region — the same one a failed page read uses.
|
|
170
169
|
const [useCaseError, setUseCaseError] = useState(undefined);
|
|
171
170
|
// Called unconditionally and the *result* gated, the way the surfaces are
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/crud/make-entity-crud.tsx"],"sourcesContent":["'use client';\n\nimport {\n deleteUCFactory,\n type EntityRepositoryTag,\n getUCFactory,\n loadUCFactory,\n saveUCFactory,\n} from '@entifix/business';\nimport {\n describeEntityColumns,\n EntifixBuildError,\n type EntifixError,\n EntifixLogicError,\n type Entity,\n type EntityConstructor,\n type EntityMetadataSource,\n envelopeEntityName,\n extractMetaEntity,\n} from '@entifix/core';\nimport { EntityTable, useCasesForSurface } from '@entifix/react-controls';\nimport {\n entityQueryScope,\n useDataLoading,\n useEntityMutation,\n useEntityRecord,\n} from '@entifix/react-integration';\nimport type { TransactionSink } from '@entifix/transactions';\nimport { TransactionSinkTag } from '@entifix/transactions';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { Context } from 'effect';\nimport { useParams, useRouter } from 'next/navigation';\nimport { useState } from 'react';\n\nimport { useLocaleHref } from '../i18n/index.js';\nimport { useEntityNavHost } from '../workspace/entity-nav.js';\nimport {\n pendingFor,\n pendingRecordsFor,\n usePendingTransactions,\n} from '../workspace/pending-transactions.js';\nimport { EntityCrudForm } from './entity-crud-form.js';\nimport { handOffWrite } from './hand-off-write.js';\nimport type {\n EntityCrud,\n EntityCrudLinkSource,\n EntityCrudOptions,\n EntityCrudSingleViewProps,\n} from './make-entity-crud.types.js';\nimport { PendingNotice } from './pending-notice.js';\nimport { CATALOG_NEW_SLUG, slugToEntityId } from './slug.js';\nimport { useCrudRenderLink } from './use-crud-render-link.js';\nimport { useEntityAffordances } from './use-entity-affordances.js';\nimport { useEntityBulk } from './use-entity-bulk.js';\n\n/** What a picker defaults to reading off its target. */\nconst TARGET_NAME_PROPERTY = 'name';\n\n/**\n * What the generated pages' use-cases run against.\n *\n * Exported because a hand-built screen needs the same three: a wizard reads\n * records, resolves pickers and writes through exactly these ports, and\n * rebuilding the union at each such call site is how one of them ends up\n * missing `TransactionSinkTag` and quietly losing its optimistic treatment.\n */\nexport type CrudContext = EntityRepositoryTag | TransactionSinkTag;\n\n/**\n * Adds the pending-write sink to one repository adapter, giving the context the\n * use-cases run against.\n *\n * The cast is the price of naming adapters by key: `AdapterKey` narrows which\n * names may be *offered*, but TypeScript will not carry that narrowing through\n * an index access on a generic record.\n */\nexport function mergeCrudContext<TAdapters>(\n adapters: TAdapters,\n repository: keyof TAdapters,\n sink: TransactionSink,\n): Context.Context<CrudContext> {\n return Context.add(\n adapters[repository] as Context.Context<EntityRepositoryTag>,\n TransactionSinkTag,\n sink,\n );\n}\n\n/**\n * Builds the list page and the single-record page for one entity.\n *\n * Every catalog entity used to cost ~10 files and ~300 lines whose only variable\n * was a class name: two organisms with a `.types.ts` each, two client pages, two\n * route files. The route files stay (Next needs a module per path); everything\n * between them is derived from the entity's own metadata, so a new reference\n * entity is this call plus a nav entry.\n *\n * Two things are checked here rather than at the call site, because both fail\n * silently otherwise. The catalog key must be the entity's own `@entity({ key })`\n * — they are the same string by convention, and a drifted one titles the form\n * after a different entity in a way only a reader of both catalogs would catch.\n * And a `links` entry must name a real member — a picker aimed at nothing renders\n * identically to a read-only field.\n *\n * **Serves compile-time `@entity()` classes only.** Operator reference data ships\n * with the code, so making this spec-driven would trade type safety for nothing;\n * a vendor-authored `EntitySpecification` renders through a separate path\n * (ADR 0014).\n */\n/**\n * A rejected verb, as something the form's error slot can render.\n *\n * `useErrorMessage` resolves `details.code` through the shared `errors`\n * catalog, so a shell that throws an `EntifixError` carrying one gets its\n * translated sentence; anything else — a network failure, a thrown string —\n * keeps its message rather than being relabelled with a code it never had.\n */\nfunction asEntifixError(failure: unknown): EntifixError {\n return failure instanceof EntifixLogicError ||\n failure instanceof EntifixBuildError\n ? failure\n : new EntifixLogicError(\n failure instanceof Error ? failure.message : String(failure),\n );\n}\n\nexport function makeEntityCrud<TEntity extends Entity, TAdapters>(\n entityConstructor: EntityConstructor<TEntity>,\n options: EntityCrudOptions<TAdapters, TEntity>,\n): EntityCrud<TEntity> {\n const {\n useAdapters,\n basePath,\n catalogKey,\n repository,\n hiddenFields = [],\n columns,\n toolbar,\n links = [],\n metadataSource: givenMetadataSource,\n metadata,\n runBulkUseCase,\n runUseCase,\n } = options;\n\n if (givenMetadataSource !== undefined && metadata !== undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} was given both metadataSource and metadata; pass one`,\n );\n }\n // An adapter value, so it is resolved per render from the same `useAdapters()`\n // the records come from — the host's composition, never the caller's module.\n const metadataSourceOf = (adapters: TAdapters) =>\n metadata === undefined\n ? givenMetadataSource\n : (adapters[metadata] as EntityMetadataSource);\n\n const meta = extractMetaEntity(entityConstructor);\n if (meta.key !== catalogKey) {\n throw new EntifixBuildError(\n `${entityConstructor.name} declares key \"${String(meta.key)}\" but was given catalog key \"${catalogKey}\"`,\n );\n }\n // Read off `@entity()` rather than rebuilt from `catalogKey`. Both spellings\n // produce the same string today, and that is exactly the problem: a second\n // place that knows how an entity's catalog subtree is laid out is a second\n // place to fix when one moves. `MetaEntityOptions` makes them optional, so an\n // entity that never declared them fails here — where a screen is generated —\n // rather than on the render that first tries to title a tab with `undefined`.\n const { labelKey: entityLabelKey, pluralKey: entityPluralKey } = meta;\n if (entityLabelKey === undefined || entityPluralKey === undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} must declare labelKey and pluralKey on @entity() to be generated`,\n );\n }\n\n // Computed once: a descriptor is a property of the class, so nothing about it\n // depends on the record being edited. `linkLabelProperty`/`linkSearchProperty`\n // are overridden here because a scalar foreign key's `@accessor()` cannot name\n // the target's members — it may not import the target at all.\n // The wire name, which is also what a pending entry records and what\n // `entityQueryScopeFor` keys on — one derivation, so a notice and an\n // invalidation can never disagree about which entity they mean.\n const entityName = envelopeEntityName(entityConstructor);\n const descriptors = describeEntityColumns(entityConstructor);\n const linkPlans = links.map(link => {\n const descriptor = descriptors.find(entry => entry.name === link.field);\n if (descriptor === undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} has no member \"${link.field}\" for a link source`,\n );\n }\n return {\n ...link,\n descriptor: {\n ...descriptor,\n linkLabelProperty: link.labelProperty ?? TARGET_NAME_PROPERTY,\n linkSearchProperty: link.searchProperty ?? TARGET_NAME_PROPERTY,\n },\n };\n });\n\n function ListPage() {\n const adapters = useAdapters();\n // Outside a `PendingTransactionsProvider` this watches nothing, so a host\n // that has not mounted one behaves exactly as it did before.\n const pending = usePendingTransactions();\n // Every internal href carries the locale. An unprefixed one still resolves —\n // the middleware redirects it — but the visitor pays a round trip per click.\n const withLocale = useLocaleHref();\n\n const queryClient = useQueryClient();\n const scope = entityQueryScope(entityConstructor);\n const metadataSource = metadataSourceOf(adapters);\n const affordances = useEntityAffordances(entityConstructor, metadataSource);\n const renderLink = useCrudRenderLink(catalogKey);\n\n // Records this browser has created but the service has not finished\n // writing. Prepended below rather than patched into the cache, which a\n // refetch would undo.\n const optimistic = pendingRecordsFor<TEntity>(pending, entityName);\n\n const pager = useDataLoading<TEntity, CrudContext>({\n uc: loadUCFactory<TEntity>(),\n ctx: mergeCrudContext(adapters, repository, pending),\n // Scoped rather than left to the per-instance fallback, which is correct\n // but unshared: with the entity's own scope one invalidation refreshes\n // every page and filter of it, which is what a bulk run needs — and it is\n // the same prefix `useReactiveInvalidation`already targets, so a generated\n // list now also refreshes on a reactive change event.\n queryKey: scope,\n });\n\n // Only when this caller may actually run something over a selection.\n //\n // Measured live: an `admin` holds `catalog-reference:*:read` and no\n // `retire`, so the service filters the verb out of `$metadata` — but the\n // selection column still rendered, offering a set that no action could be\n // taken on. A checkbox that can lead nowhere is worse than no checkbox: it\n // reads as a permission the user does not have.\n //\n // Decided from the served document rather than from the runner, because\n // the runner is a property of the *shell* (it knows the route) and the\n // grant is a property of the *caller*. The column therefore appears when\n // the document lands, which is the asynchrony ADR 0026 already accepts for\n // every action surface.\n const canRunBulk =\n useCasesForSurface('bulk-bar', affordances.metadata?.useCases).length > 0;\n\n const bulk = useEntityBulk<TEntity>({\n run: canRunBulk ? runBulkUseCase : undefined,\n // A bulk write changed the rows underneath the listing, so the page is\n // re-read; the selection deliberately survives it (#121 — \"the selection\n // is still there afterwards\"), because the operator's next act is usually\n // to retry the failures or run a second verb on the same rows.\n onCompleted: () => {\n void queryClient.invalidateQueries({ queryKey: scope });\n },\n });\n\n // What a row's overflow menu runs, and the one place the list reports a\n // verb that failed.\n //\n // ⚠️ **A row verb and the form's are the same runner with different\n // arguments.** `EntityTable` hands back the whole record, because that is\n // what it has; `runUseCase` takes an id, because that is what a route needs\n // (#216). The list has no error slot of its own, so a failure lands in the\n // table's own error region — the same one a failed page read uses.\n const [useCaseError, setUseCaseError] = useState<EntifixError | undefined>(\n undefined,\n );\n // Called unconditionally and the *result* gated, the way the surfaces are\n // read in `EntityForm`: the name begins with `use`, so short-circuiting it\n // reads to `react-hooks/rules-of-hooks` as a conditional hook.\n const rowUseCases = useCasesForSurface(\n 'row-menu',\n affordances.metadata?.useCases,\n );\n const canRunRowUseCase = runUseCase !== undefined && rowUseCases.length > 0;\n\n const handleRowUseCase = canRunRowUseCase\n ? (key: string, item: TEntity) => {\n if (item.id == null) return;\n setUseCaseError(undefined);\n void runUseCase(key, item.id).then(\n () => queryClient.invalidateQueries({ queryKey: scope }),\n (failure: unknown) => setUseCaseError(asEntifixError(failure)),\n );\n }\n : undefined;\n\n return (\n <div className=\"flex flex-col gap-s\">\n {/* Above the table, because this is where `afterSave()` leaves the\n operator after a create — and a create is the only transactional\n write there is, so this is the surface the pending state has. */}\n <PendingNotice\n entries={pendingFor(pending, entityName)}\n onDismiss={pending.dismiss}\n />\n <EntityTable\n entityConstructor={entityConstructor}\n {...pager}\n items={[...optimistic, ...pager.items]}\n totalItems={pager.totalItems + optimistic.length}\n hrefFor={id => withLocale(`${basePath}/${String(id)}`)}\n newHref={withLocale(`${basePath}/${CATALOG_NEW_SLUG}`)}\n renderLink={renderLink}\n {...affordances}\n {...bulk.tableProps}\n onUseCase={handleRowUseCase}\n error={useCaseError ?? pager.error}\n >\n {columns}\n {toolbar}\n </EntityTable>\n </div>\n );\n }\n\n /**\n * Dual-host: as a route it reads its slug from the URL and returns to the list\n * on save; in a workspace tab it takes the slug and the post-save action as\n * props.\n */\n function SingleViewPage({\n slug,\n onSaved,\n onDeleted,\n draft,\n }: EntityCrudSingleViewProps = {}) {\n const adapters = useAdapters();\n const metadataSource = metadataSourceOf(adapters);\n const pending = usePendingTransactions();\n const router = useRouter();\n const withLocale = useLocaleHref();\n const params = useParams<{ slug: string }>();\n // A verb's failure is this page's, not the record fetch's or the mutation's,\n // so it needs a slot of its own to reach the form's error area.\n const [useCaseError, setUseCaseError] = useState<EntifixError | undefined>(\n undefined,\n );\n const id = slugToEntityId(slug ?? params.slug);\n\n const ctx = mergeCrudContext(adapters, repository, pending);\n\n const {\n entity,\n isLoading,\n error: loadError,\n reload,\n } = useEntityRecord<TEntity, CrudContext>({\n uc: getUCFactory<TEntity>(),\n ctx,\n id,\n });\n\n const {\n save,\n remove,\n isSaving,\n isDeleting,\n error: writeError,\n } = useEntityMutation<TEntity, CrudContext>({\n saveUc: saveUCFactory<TEntity>(),\n deleteUc: deleteUCFactory<TEntity>(),\n ctx,\n });\n\n // Rebuilt inline on every render on purpose: `useEntityLinkSource` holds\n // these in a ref and keeps them out of its query keys, precisely so a caller\n // does not have to memoise them.\n const linkSources: EntityCrudLinkSource[] = linkPlans.map(plan => ({\n field: plan.field,\n descriptor: plan.descriptor,\n config: {\n entityConstructor: plan.entityConstructor,\n loadUc: loadUCFactory(),\n getUc: getUCFactory(),\n ctx: mergeCrudContext(adapters, plan.repository, pending),\n },\n }));\n\n // Inside a workspace tab, \"back to the list\" is the list's tab, not a URL\n // that would leave the workspace. A host's own callbacks still win.\n const host = useEntityNavHost();\n const renderLink = useCrudRenderLink(catalogKey);\n const queryClient = useQueryClient();\n // The list this page returns to has its page cached, and it is no longer\n // true. Invalidated rather than patched, for the reason `handOffWrite`\n // gives: a transactional create is not in the service yet, and the pending\n // set, not the cache, is what keeps it visible until it is.\n //\n // ⚠️ Invisible while every Open was a document load, which threw the cache\n // away anyway (#20): the first client-side navigation back to the list\n // showed the record as it was before the save.\n const refreshList = () =>\n queryClient.invalidateQueries({\n queryKey: entityQueryScope(entityConstructor),\n });\n const toList = () =>\n host === undefined\n ? router.push(withLocale(basePath))\n : host.toList(catalogKey);\n const afterSave = onSaved ?? toList;\n const afterDelete = onDeleted ?? toList;\n\n // The draft is spent once the write *commits*, and only here is that known:\n // `useEntityForm` neither fetches nor saves, so it cannot clear its own.\n // A failed mutation deliberately keeps the draft — the edit is still the\n // user's only copy of what they typed.\n const handleSave = async (next: TEntity) => {\n const saved = await save(next);\n if (saved === undefined) return;\n\n // ⚠️ Deliberately *not* a `setQueriesData` patch. The list refetches on\n // mount — precisely when the operator arrives, having just been navigated\n // here — and the server legitimately does not hold the record yet, so the\n // refetch would replace the patched page and the row would vanish a\n // moment after appearing. The pending set outlives refetches.\n handOffWrite({ id: String(saved.id), record: saved, pending, draft });\n void refreshList();\n afterSave();\n };\n\n const handleDelete = async () => {\n if (await remove(id)) {\n draft?.clear();\n void refreshList();\n afterDelete();\n }\n };\n\n /**\n * An `entity`-bound verb, on the record being shown.\n *\n * It **reloads** rather than patching what it has: the service decides the\n * resulting state — a `publish` may land on `published` from four different\n * places — and a client that assumed the new value would show the wrong one\n * the first time the rule changed. `id == null` means the create form,\n * where there is no record to act on and the form renders no verbs anyway.\n *\n * ⚠️ The rejection is **caught and shown**, never left to the promise.\n * `EntityForm`'s `onUseCase` returns `void`, so an uncaught rejection here\n * reaches the console and nothing else — measured on an offering refusing\n * `unpublish` from `draft`: the service answered `409` with a coded body,\n * the record correctly did not move, and the operator saw a button that\n * appeared to do nothing at all.\n */\n const handleUseCase =\n runUseCase === undefined || id == null\n ? undefined\n : (key: string) => {\n setUseCaseError(undefined);\n void runUseCase(key, id).then(\n () => reload(),\n (failure: unknown) => setUseCaseError(asEntifixError(failure)),\n );\n };\n\n return (\n <EntityCrudForm<TEntity>\n // Remounts (and reseeds the fields) once the record arrives.\n key={String(entity?.id ?? CATALOG_NEW_SLUG)}\n entityConstructor={entityConstructor}\n catalogKey={catalogKey}\n metadataSource={metadataSource}\n hiddenFields={hiddenFields}\n links={linkSources}\n entity={entity}\n isLoading={isLoading}\n isSaving={isSaving}\n isDeleting={isDeleting}\n error={loadError ?? writeError ?? useCaseError}\n onSave={handleSave}\n onDelete={id == null ? undefined : handleDelete}\n onUseCase={handleUseCase}\n backHref={withLocale(basePath)}\n renderLink={renderLink}\n draft={draft}\n />\n );\n }\n\n return {\n entityConstructor,\n entityKey: catalogKey,\n entityLabelKey,\n entityPluralKey,\n basePath,\n ListPage,\n SingleViewPage,\n };\n}\n"],"names":["deleteUCFactory","getUCFactory","loadUCFactory","saveUCFactory","describeEntityColumns","EntifixBuildError","EntifixLogicError","envelopeEntityName","extractMetaEntity","EntityTable","useCasesForSurface","entityQueryScope","useDataLoading","useEntityMutation","useEntityRecord","TransactionSinkTag","useQueryClient","Context","useParams","useRouter","useState","useLocaleHref","useEntityNavHost","pendingFor","pendingRecordsFor","usePendingTransactions","EntityCrudForm","handOffWrite","PendingNotice","CATALOG_NEW_SLUG","slugToEntityId","useCrudRenderLink","useEntityAffordances","useEntityBulk","TARGET_NAME_PROPERTY","mergeCrudContext","adapters","repository","sink","add","asEntifixError","failure","Error","message","String","makeEntityCrud","entityConstructor","options","useAdapters","basePath","catalogKey","hiddenFields","columns","toolbar","links","metadataSource","givenMetadataSource","metadata","runBulkUseCase","runUseCase","undefined","name","metadataSourceOf","meta","key","labelKey","entityLabelKey","pluralKey","entityPluralKey","entityName","descriptors","linkPlans","map","link","descriptor","find","entry","field","linkLabelProperty","labelProperty","linkSearchProperty","searchProperty","ListPage","affordances","pending","withLocale","queryClient","scope","renderLink","optimistic","pager","uc","ctx","queryKey","canRunBulk","useCases","length","bulk","run","onCompleted","invalidateQueries","useCaseError","setUseCaseError","rowUseCases","canRunRowUseCase","handleRowUseCase","item","id","then","div","className","entries","onDismiss","dismiss","items","totalItems","hrefFor","newHref","tableProps","onUseCase","error","SingleViewPage","slug","onSaved","onDeleted","draft","loadError","router","params","entity","isLoading","reload","save","remove","isSaving","isDeleting","writeError","saveUc","deleteUc","linkSources","plan","config","loadUc","getUc","host","refreshList","toList","push","afterSave","afterDelete","handleSave","next","saved","record","handleDelete","clear","handleUseCase","onSave","onDelete","backHref","entityKey"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/crud/make-entity-crud.tsx"],"sourcesContent":["'use client';\n\nimport {\n deleteUCFactory,\n type EntityRepositoryTag,\n getUCFactory,\n loadUCFactory,\n saveUCFactory,\n} from '@entifix/business';\nimport {\n describeEntityColumns,\n EntifixBuildError,\n type EntifixError,\n EntifixLogicError,\n type Entity,\n type EntityConstructor,\n type EntityMetadataSource,\n envelopeEntityName,\n extractMetaEntity,\n} from '@entifix/core';\nimport { EntityTable, useCasesForSurface } from '@entifix/react-controls';\nimport {\n entityQueryScope,\n useDataLoading,\n useEntityMutation,\n useEntityRecord,\n} from '@entifix/react-integration';\nimport type { TransactionSink } from '@entifix/transactions';\nimport { TransactionSinkTag } from '@entifix/transactions';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { Context } from 'effect';\nimport { useParams, useRouter } from 'next/navigation';\nimport { useState } from 'react';\n\nimport { useLocaleHref } from '../i18n/index.js';\nimport { useEntityNavHost } from '../workspace/entity-nav.js';\nimport {\n pendingFor,\n pendingRecordsFor,\n usePendingTransactions,\n} from '../workspace/pending-transactions.js';\nimport { EntityCrudForm } from './entity-crud-form.js';\nimport { handOffWrite } from './hand-off-write.js';\nimport type {\n EntityCrud,\n EntityCrudLinkSource,\n EntityCrudOptions,\n EntityCrudSingleViewProps,\n} from './make-entity-crud.types.js';\nimport { PendingNotice } from './pending-notice.js';\nimport { CATALOG_NEW_SLUG, slugToEntityId } from './slug.js';\nimport { useCrudRenderLink } from './use-crud-render-link.js';\nimport { useEntityAffordances } from './use-entity-affordances.js';\nimport { useEntityBulk } from './use-entity-bulk.js';\n\n/** What a picker defaults to reading off its target. */\nconst TARGET_NAME_PROPERTY = 'name';\n\n/**\n * What the generated pages' use-cases run against.\n *\n * Exported because a hand-built screen needs the same three: a wizard reads\n * records, resolves pickers and writes through exactly these ports, and\n * rebuilding the union at each such call site is how one of them ends up\n * missing `TransactionSinkTag` and quietly losing its optimistic treatment.\n */\nexport type CrudContext = EntityRepositoryTag | TransactionSinkTag;\n\n/**\n * Adds the pending-write sink to one repository adapter, giving the context the\n * use-cases run against.\n *\n * The cast is the price of naming adapters by key: `AdapterKey` narrows which\n * names may be *offered*, but TypeScript will not carry that narrowing through\n * an index access on a generic record.\n */\nexport function mergeCrudContext<TAdapters>(\n adapters: TAdapters,\n repository: keyof TAdapters,\n sink: TransactionSink,\n): Context.Context<CrudContext> {\n return Context.add(\n adapters[repository] as Context.Context<EntityRepositoryTag>,\n TransactionSinkTag,\n sink,\n );\n}\n\n/**\n * Builds the list page and the single-record page for one entity.\n *\n * Every catalog entity used to cost ~10 files and ~300 lines whose only variable\n * was a class name: two organisms with a `.types.ts` each, two client pages, two\n * route files. The route files stay (Next needs a module per path); everything\n * between them is derived from the entity's own metadata, so a new reference\n * entity is this call plus a nav entry.\n *\n * Two things are checked here rather than at the call site, because both fail\n * silently otherwise. The catalog key must be the entity's own `@entity({ key })`\n * — they are the same string by convention, and a drifted one titles the form\n * after a different entity in a way only a reader of both catalogs would catch.\n * And a `links` entry must name a real member — a picker aimed at nothing renders\n * identically to a read-only field.\n *\n * **Serves compile-time `@entity()` classes only.** Operator reference data ships\n * with the code, so making this spec-driven would trade type safety for nothing;\n * a vendor-authored `EntitySpecification` renders through a separate path.\n */\n/**\n * A rejected verb, as something the form's error slot can render.\n *\n * `useErrorMessage` resolves `details.code` through the shared `errors`\n * catalog, so a shell that throws an `EntifixError` carrying one gets its\n * translated sentence; anything else — a network failure, a thrown string —\n * keeps its message rather than being relabelled with a code it never had.\n */\nfunction asEntifixError(failure: unknown): EntifixError {\n return failure instanceof EntifixLogicError ||\n failure instanceof EntifixBuildError\n ? failure\n : new EntifixLogicError(\n failure instanceof Error ? failure.message : String(failure),\n );\n}\n\nexport function makeEntityCrud<TEntity extends Entity, TAdapters>(\n entityConstructor: EntityConstructor<TEntity>,\n options: EntityCrudOptions<TAdapters, TEntity>,\n): EntityCrud<TEntity> {\n const {\n useAdapters,\n basePath,\n catalogKey,\n repository,\n hiddenFields = [],\n columns,\n toolbar,\n links = [],\n metadataSource: givenMetadataSource,\n metadata,\n runBulkUseCase,\n runUseCase,\n } = options;\n\n if (givenMetadataSource !== undefined && metadata !== undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} was given both metadataSource and metadata; pass one`,\n );\n }\n // An adapter value, so it is resolved per render from the same `useAdapters()`\n // the records come from — the host's composition, never the caller's module.\n const metadataSourceOf = (adapters: TAdapters) =>\n metadata === undefined\n ? givenMetadataSource\n : (adapters[metadata] as EntityMetadataSource);\n\n const meta = extractMetaEntity(entityConstructor);\n if (meta.key !== catalogKey) {\n throw new EntifixBuildError(\n `${entityConstructor.name} declares key \"${String(meta.key)}\" but was given catalog key \"${catalogKey}\"`,\n );\n }\n // Read off `@entity()` rather than rebuilt from `catalogKey`. Both spellings\n // produce the same string today, and that is exactly the problem: a second\n // place that knows how an entity's catalog subtree is laid out is a second\n // place to fix when one moves. `MetaEntityOptions` makes them optional, so an\n // entity that never declared them fails here — where a screen is generated —\n // rather than on the render that first tries to title a tab with `undefined`.\n const { labelKey: entityLabelKey, pluralKey: entityPluralKey } = meta;\n if (entityLabelKey === undefined || entityPluralKey === undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} must declare labelKey and pluralKey on @entity() to be generated`,\n );\n }\n\n // Computed once: a descriptor is a property of the class, so nothing about it\n // depends on the record being edited. `linkLabelProperty`/`linkSearchProperty`\n // are overridden here because a scalar foreign key's `@accessor()` cannot name\n // the target's members — it may not import the target at all.\n // The wire name, which is also what a pending entry records and what\n // `entityQueryScopeFor` keys on — one derivation, so a notice and an\n // invalidation can never disagree about which entity they mean.\n const entityName = envelopeEntityName(entityConstructor);\n const descriptors = describeEntityColumns(entityConstructor);\n const linkPlans = links.map(link => {\n const descriptor = descriptors.find(entry => entry.name === link.field);\n if (descriptor === undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} has no member \"${link.field}\" for a link source`,\n );\n }\n return {\n ...link,\n descriptor: {\n ...descriptor,\n linkLabelProperty: link.labelProperty ?? TARGET_NAME_PROPERTY,\n linkSearchProperty: link.searchProperty ?? TARGET_NAME_PROPERTY,\n },\n };\n });\n\n function ListPage() {\n const adapters = useAdapters();\n // Outside a `PendingTransactionsProvider` this watches nothing, so a host\n // that has not mounted one behaves exactly as it did before.\n const pending = usePendingTransactions();\n // Every internal href carries the locale. An unprefixed one still resolves —\n // the middleware redirects it — but the visitor pays a round trip per click.\n const withLocale = useLocaleHref();\n\n const queryClient = useQueryClient();\n const scope = entityQueryScope(entityConstructor);\n const metadataSource = metadataSourceOf(adapters);\n const affordances = useEntityAffordances(entityConstructor, metadataSource);\n const renderLink = useCrudRenderLink(catalogKey);\n\n // Records this browser has created but the service has not finished\n // writing. Prepended below rather than patched into the cache, which a\n // refetch would undo.\n const optimistic = pendingRecordsFor<TEntity>(pending, entityName);\n\n const pager = useDataLoading<TEntity, CrudContext>({\n uc: loadUCFactory<TEntity>(),\n ctx: mergeCrudContext(adapters, repository, pending),\n // Scoped rather than left to the per-instance fallback, which is correct\n // but unshared: with the entity's own scope one invalidation refreshes\n // every page and filter of it, which is what a bulk run needs — and it is\n // the same prefix `useReactiveInvalidation`already targets, so a generated\n // list now also refreshes on a reactive change event.\n queryKey: scope,\n });\n\n // Only when this caller may actually run something over a selection.\n //\n // Measured live: an `admin` holds `catalog-reference:*:read` and no\n // `retire`, so the service filters the verb out of `$metadata` — but the\n // selection column still rendered, offering a set that no action could be\n // taken on. A checkbox that can lead nowhere is worse than no checkbox: it\n // reads as a permission the user does not have.\n //\n // Decided from the served document rather than from the runner, because\n // the runner is a property of the *shell* (it knows the route) and the\n // grant is a property of the *caller*. The column therefore appears when\n // the document lands, which is the asynchrony serving affordances accepts\n // for every action surface.\n const canRunBulk =\n useCasesForSurface('bulk-bar', affordances.metadata?.useCases).length > 0;\n\n const bulk = useEntityBulk<TEntity>({\n run: canRunBulk ? runBulkUseCase : undefined,\n // A bulk write changed the rows underneath the listing, so the page is\n // re-read; the selection deliberately survives it (\"the selection\n // is still there afterwards\"), because the operator's next act is usually\n // to retry the failures or run a second verb on the same rows.\n onCompleted: () => {\n void queryClient.invalidateQueries({ queryKey: scope });\n },\n });\n\n // What a row's overflow menu runs, and the one place the list reports a\n // verb that failed.\n //\n // ⚠️ **A row verb and the form's are the same runner with different\n // arguments.** `EntityTable` hands back the whole record, because that is\n // what it has; `runUseCase` takes an id, because that is what a route needs\n // The list has no error slot of its own, so a failure lands in the\n // table's own error region — the same one a failed page read uses.\n const [useCaseError, setUseCaseError] = useState<EntifixError | undefined>(\n undefined,\n );\n // Called unconditionally and the *result* gated, the way the surfaces are\n // read in `EntityForm`: the name begins with `use`, so short-circuiting it\n // reads to `react-hooks/rules-of-hooks` as a conditional hook.\n const rowUseCases = useCasesForSurface(\n 'row-menu',\n affordances.metadata?.useCases,\n );\n const canRunRowUseCase = runUseCase !== undefined && rowUseCases.length > 0;\n\n const handleRowUseCase = canRunRowUseCase\n ? (key: string, item: TEntity) => {\n if (item.id == null) return;\n setUseCaseError(undefined);\n void runUseCase(key, item.id).then(\n () => queryClient.invalidateQueries({ queryKey: scope }),\n (failure: unknown) => setUseCaseError(asEntifixError(failure)),\n );\n }\n : undefined;\n\n return (\n <div className=\"flex flex-col gap-s\">\n {/* Above the table, because this is where `afterSave()` leaves the\n operator after a create — and a create is the only transactional\n write there is, so this is the surface the pending state has. */}\n <PendingNotice\n entries={pendingFor(pending, entityName)}\n onDismiss={pending.dismiss}\n />\n <EntityTable\n entityConstructor={entityConstructor}\n {...pager}\n items={[...optimistic, ...pager.items]}\n totalItems={pager.totalItems + optimistic.length}\n hrefFor={id => withLocale(`${basePath}/${String(id)}`)}\n newHref={withLocale(`${basePath}/${CATALOG_NEW_SLUG}`)}\n renderLink={renderLink}\n {...affordances}\n {...bulk.tableProps}\n onUseCase={handleRowUseCase}\n error={useCaseError ?? pager.error}\n >\n {columns}\n {toolbar}\n </EntityTable>\n </div>\n );\n }\n\n /**\n * Dual-host: as a route it reads its slug from the URL and returns to the list\n * on save; in a workspace tab it takes the slug and the post-save action as\n * props.\n */\n function SingleViewPage({\n slug,\n onSaved,\n onDeleted,\n draft,\n }: EntityCrudSingleViewProps = {}) {\n const adapters = useAdapters();\n const metadataSource = metadataSourceOf(adapters);\n const pending = usePendingTransactions();\n const router = useRouter();\n const withLocale = useLocaleHref();\n const params = useParams<{ slug: string }>();\n // A verb's failure is this page's, not the record fetch's or the mutation's,\n // so it needs a slot of its own to reach the form's error area.\n const [useCaseError, setUseCaseError] = useState<EntifixError | undefined>(\n undefined,\n );\n const id = slugToEntityId(slug ?? params.slug);\n\n const ctx = mergeCrudContext(adapters, repository, pending);\n\n const {\n entity,\n isLoading,\n error: loadError,\n reload,\n } = useEntityRecord<TEntity, CrudContext>({\n uc: getUCFactory<TEntity>(),\n ctx,\n id,\n });\n\n const {\n save,\n remove,\n isSaving,\n isDeleting,\n error: writeError,\n } = useEntityMutation<TEntity, CrudContext>({\n saveUc: saveUCFactory<TEntity>(),\n deleteUc: deleteUCFactory<TEntity>(),\n ctx,\n });\n\n // Rebuilt inline on every render on purpose: `useEntityLinkSource` holds\n // these in a ref and keeps them out of its query keys, precisely so a caller\n // does not have to memoise them.\n const linkSources: EntityCrudLinkSource[] = linkPlans.map(plan => ({\n field: plan.field,\n descriptor: plan.descriptor,\n config: {\n entityConstructor: plan.entityConstructor,\n loadUc: loadUCFactory(),\n getUc: getUCFactory(),\n ctx: mergeCrudContext(adapters, plan.repository, pending),\n },\n }));\n\n // Inside a workspace tab, \"back to the list\" is the list's tab, not a URL\n // that would leave the workspace. A host's own callbacks still win.\n const host = useEntityNavHost();\n const renderLink = useCrudRenderLink(catalogKey);\n const queryClient = useQueryClient();\n // The list this page returns to has its page cached, and it is no longer\n // true. Invalidated rather than patched, for the reason `handOffWrite`\n // gives: a transactional create is not in the service yet, and the pending\n // set, not the cache, is what keeps it visible until it is.\n //\n // ⚠️ Invisible while every Open was a document load, which threw the cache\n // away anyway (#20): the first client-side navigation back to the list\n // showed the record as it was before the save.\n const refreshList = () =>\n queryClient.invalidateQueries({\n queryKey: entityQueryScope(entityConstructor),\n });\n const toList = () =>\n host === undefined\n ? router.push(withLocale(basePath))\n : host.toList(catalogKey);\n const afterSave = onSaved ?? toList;\n const afterDelete = onDeleted ?? toList;\n\n // The draft is spent once the write *commits*, and only here is that known:\n // `useEntityForm` neither fetches nor saves, so it cannot clear its own.\n // A failed mutation deliberately keeps the draft — the edit is still the\n // user's only copy of what they typed.\n const handleSave = async (next: TEntity) => {\n const saved = await save(next);\n if (saved === undefined) return;\n\n // ⚠️ Deliberately *not* a `setQueriesData` patch. The list refetches on\n // mount — precisely when the operator arrives, having just been navigated\n // here — and the server legitimately does not hold the record yet, so the\n // refetch would replace the patched page and the row would vanish a\n // moment after appearing. The pending set outlives refetches.\n handOffWrite({ id: String(saved.id), record: saved, pending, draft });\n void refreshList();\n afterSave();\n };\n\n const handleDelete = async () => {\n if (await remove(id)) {\n draft?.clear();\n void refreshList();\n afterDelete();\n }\n };\n\n /**\n * An `entity`-bound verb, on the record being shown.\n *\n * It **reloads** rather than patching what it has: the service decides the\n * resulting state — a `publish` may land on `published` from four different\n * places — and a client that assumed the new value would show the wrong one\n * the first time the rule changed. `id == null` means the create form,\n * where there is no record to act on and the form renders no verbs anyway.\n *\n * ⚠️ The rejection is **caught and shown**, never left to the promise.\n * `EntityForm`'s `onUseCase` returns `void`, so an uncaught rejection here\n * reaches the console and nothing else — measured on an offering refusing\n * `unpublish` from `draft`: the service answered `409` with a coded body,\n * the record correctly did not move, and the operator saw a button that\n * appeared to do nothing at all.\n */\n const handleUseCase =\n runUseCase === undefined || id == null\n ? undefined\n : (key: string) => {\n setUseCaseError(undefined);\n void runUseCase(key, id).then(\n () => reload(),\n (failure: unknown) => setUseCaseError(asEntifixError(failure)),\n );\n };\n\n return (\n <EntityCrudForm<TEntity>\n // Remounts (and reseeds the fields) once the record arrives.\n key={String(entity?.id ?? CATALOG_NEW_SLUG)}\n entityConstructor={entityConstructor}\n catalogKey={catalogKey}\n metadataSource={metadataSource}\n hiddenFields={hiddenFields}\n links={linkSources}\n entity={entity}\n isLoading={isLoading}\n isSaving={isSaving}\n isDeleting={isDeleting}\n error={loadError ?? writeError ?? useCaseError}\n onSave={handleSave}\n onDelete={id == null ? undefined : handleDelete}\n onUseCase={handleUseCase}\n backHref={withLocale(basePath)}\n renderLink={renderLink}\n draft={draft}\n />\n );\n }\n\n return {\n entityConstructor,\n entityKey: catalogKey,\n entityLabelKey,\n entityPluralKey,\n basePath,\n ListPage,\n SingleViewPage,\n };\n}\n"],"names":["deleteUCFactory","getUCFactory","loadUCFactory","saveUCFactory","describeEntityColumns","EntifixBuildError","EntifixLogicError","envelopeEntityName","extractMetaEntity","EntityTable","useCasesForSurface","entityQueryScope","useDataLoading","useEntityMutation","useEntityRecord","TransactionSinkTag","useQueryClient","Context","useParams","useRouter","useState","useLocaleHref","useEntityNavHost","pendingFor","pendingRecordsFor","usePendingTransactions","EntityCrudForm","handOffWrite","PendingNotice","CATALOG_NEW_SLUG","slugToEntityId","useCrudRenderLink","useEntityAffordances","useEntityBulk","TARGET_NAME_PROPERTY","mergeCrudContext","adapters","repository","sink","add","asEntifixError","failure","Error","message","String","makeEntityCrud","entityConstructor","options","useAdapters","basePath","catalogKey","hiddenFields","columns","toolbar","links","metadataSource","givenMetadataSource","metadata","runBulkUseCase","runUseCase","undefined","name","metadataSourceOf","meta","key","labelKey","entityLabelKey","pluralKey","entityPluralKey","entityName","descriptors","linkPlans","map","link","descriptor","find","entry","field","linkLabelProperty","labelProperty","linkSearchProperty","searchProperty","ListPage","affordances","pending","withLocale","queryClient","scope","renderLink","optimistic","pager","uc","ctx","queryKey","canRunBulk","useCases","length","bulk","run","onCompleted","invalidateQueries","useCaseError","setUseCaseError","rowUseCases","canRunRowUseCase","handleRowUseCase","item","id","then","div","className","entries","onDismiss","dismiss","items","totalItems","hrefFor","newHref","tableProps","onUseCase","error","SingleViewPage","slug","onSaved","onDeleted","draft","loadError","router","params","entity","isLoading","reload","save","remove","isSaving","isDeleting","writeError","saveUc","deleteUc","linkSources","plan","config","loadUc","getUc","host","refreshList","toList","push","afterSave","afterDelete","handleSave","next","saved","record","handleDelete","clear","handleUseCase","onSave","onDelete","backHref","entityKey"],"mappings":"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"}
|
|
@@ -12,9 +12,9 @@ import type { ReactElement, ReactNode } from 'react';
|
|
|
12
12
|
* catalog: an entity whose subtree has no `form.editTitle`/`form.newTitle`
|
|
13
13
|
* cannot be handed to {@link makeEntityCrud} at all, and adding a reference
|
|
14
14
|
* entity therefore starts by adding its copy to both locales. That is the whole
|
|
15
|
-
* i18n gate for this factory —
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* i18n gate for this factory — a host's catalog check typically scans its
|
|
16
|
+
* domain packages for `@useCase()` decorators and never `.tsx`, so a runtime
|
|
17
|
+
* key here would be checked by nothing.
|
|
18
18
|
*
|
|
19
19
|
* `label`/`plural` joined the condition when {@link EntityCrud} began carrying
|
|
20
20
|
* them, so that a workspace tab and a nav entry derived from this descriptor
|
|
@@ -121,8 +121,7 @@ export interface EntityCrudOptions<TAdapters, TEntity extends Entity> {
|
|
|
121
121
|
* Its own option rather than more `columns`, though both end up as
|
|
122
122
|
* `EntityTable` children and the slots are sorted by component identity: a
|
|
123
123
|
* toolbar filed under a columns prop is a name that lies, and the first thing
|
|
124
|
-
* to go here is a link into a wizard, which is not a column by any reading
|
|
125
|
-
* ([ADR 0045](../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)).
|
|
124
|
+
* to go here is a link into a wizard, which is not a column by any reading.
|
|
126
125
|
*/
|
|
127
126
|
readonly toolbar?: ReactNode;
|
|
128
127
|
/** Frozen at factory time — see `use-entity-link-sources.ts`. */
|
|
@@ -130,7 +129,7 @@ export interface EntityCrudOptions<TAdapters, TEntity extends Entity> {
|
|
|
130
129
|
/**
|
|
131
130
|
* Where the entity's served affordances come from — `GET /api/<entity>/$metadata`.
|
|
132
131
|
*
|
|
133
|
-
* Supplying it is what turns on
|
|
132
|
+
* Supplying it is what turns on every served affordance:
|
|
134
133
|
* Save and Delete filtered against the caller's real grants, the declared
|
|
135
134
|
* verbs on the form, the row overflow menu, and the bulk bar. Omitting it
|
|
136
135
|
* keeps the pre-0026 behaviour exactly, which is what every generated catalog
|
|
@@ -166,11 +165,11 @@ export interface EntityCrudOptions<TAdapters, TEntity extends Entity> {
|
|
|
166
165
|
* Runs an `entity`-bound verb on the record the single view is showing.
|
|
167
166
|
*
|
|
168
167
|
* The sibling of {@link runBulkUseCase}, and it had to exist for the same
|
|
169
|
-
* reason: `EntityForm` has rendered these buttons
|
|
168
|
+
* reason: `EntityForm` has rendered these buttons for a long time, and
|
|
170
169
|
* `EntityCrudForm` has accepted an `onUseCase` for as long — but nothing ever
|
|
171
170
|
* passed one, so a generated screen showed a declared, granted, served verb
|
|
172
171
|
* that did nothing when clicked. Measured on `ProductOffering`'s `publish`,
|
|
173
|
-
* which passed every
|
|
172
|
+
* which passed every check its host's register ran and appeared nowhere.
|
|
174
173
|
*
|
|
175
174
|
* Resolving means the record changed, and the page reloads it: a verb that
|
|
176
175
|
* moves a lifecycle leaves the form showing the old state otherwise, which
|
|
@@ -205,8 +204,7 @@ export interface EntityCrudSingleViewProps {
|
|
|
205
204
|
* What the factory returns: a **named descriptor**, not four loose components.
|
|
206
205
|
*
|
|
207
206
|
* The identity fields are here so the workspace registry and the nav are derived
|
|
208
|
-
* from a list of these rather than from the hand-written const maps they replace
|
|
209
|
-
* (#141).
|
|
207
|
+
* from a list of these rather than from the hand-written const maps they replace.
|
|
210
208
|
*/
|
|
211
209
|
export interface EntityCrud<TEntity extends Entity> {
|
|
212
210
|
readonly entityConstructor: EntityConstructor<TEntity>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"make-entity-crud.types.d.ts","sourceRoot":"","sources":["../../../src/lib/crud/make-entity-crud.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,KAAK,EACV,WAAW,EACX,MAAM,EACN,iBAAiB,EACjB,qBAAqB,EACrB,QAAQ,EACR,oBAAoB,EACpB,eAAe,EAChB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,gBAAgB,EAChB,sBAAsB,EACvB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,MAAM,gBAAgB,CAC1B,cAAc,GAAG,eAAe,CAAC,oBAAoB,CAAC,IACpD,CAAC,cAAc,CAAC,SAAS,CAAC,KAAK,CAAC,GAIhC,MAAM,GACN;KACG,CAAC,IAAI,MAAM,cAAc,GAAG,cAAc,CAAC,CAAC,CAAC,SAAS;QACrD,IAAI,EAAE;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAA;SAAE,CAAC;QAC9C,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;KAChB,GACG,CAAC,GACD,KAAK;CACV,CAAC,MAAM,cAAc,CAAC,GACrB,MAAM,CAAC;AAEb;;;;;;;;;;;GAWG;AACH,KAAK,oBAAoB,GAAG,iBAAiB,SAAS;IAAE,SAAS,EAAE,MAAM,CAAC,CAAA;CAAE,GACxE,CAAC,GACD,KAAK,CAAC;AAEV,KAAK,eAAe,CAAC,UAAU,IAAI,UAAU,SAAS;IAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACrE,CAAC,GACD,KAAK,CAAC;AAEV;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,cAAc,CAAC,SAAS;IACvC,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,MAAM,CAAC,CAAC;IACtD;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC,SAAS,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACzE,4BAA4B;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,gEAAgE;IAChE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CAClC;AAED,qEAAqE;AACrE,MAAM,MAAM,UAAU,CAAC,SAAS,EAAE,MAAM,IAAI;KACzC,CAAC,IAAI,MAAM,SAAS,GAAG,SAAS,CAAC,CAAC,CAAC,SAAS,MAAM,GAAG,CAAC,GAAG,KAAK;CAChE,CAAC,MAAM,SAAS,CAAC,CAAC;AAEnB,MAAM,WAAW,iBAAiB,CAAC,SAAS,EAAE,OAAO,SAAS,MAAM;IAClE,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,SAAS,CAAC;IACtC,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;IACtC,wDAAwD;IACxD,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC,SAAS,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACzE;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,yFAAyF;IACzF,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;IAC7B
|
|
1
|
+
{"version":3,"file":"make-entity-crud.types.d.ts","sourceRoot":"","sources":["../../../src/lib/crud/make-entity-crud.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,KAAK,EACV,WAAW,EACX,MAAM,EACN,iBAAiB,EACjB,qBAAqB,EACrB,QAAQ,EACR,oBAAoB,EACpB,eAAe,EAChB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,gBAAgB,EAChB,sBAAsB,EACvB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,MAAM,gBAAgB,CAC1B,cAAc,GAAG,eAAe,CAAC,oBAAoB,CAAC,IACpD,CAAC,cAAc,CAAC,SAAS,CAAC,KAAK,CAAC,GAIhC,MAAM,GACN;KACG,CAAC,IAAI,MAAM,cAAc,GAAG,cAAc,CAAC,CAAC,CAAC,SAAS;QACrD,IAAI,EAAE;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAA;SAAE,CAAC;QAC9C,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;KAChB,GACG,CAAC,GACD,KAAK;CACV,CAAC,MAAM,cAAc,CAAC,GACrB,MAAM,CAAC;AAEb;;;;;;;;;;;GAWG;AACH,KAAK,oBAAoB,GAAG,iBAAiB,SAAS;IAAE,SAAS,EAAE,MAAM,CAAC,CAAA;CAAE,GACxE,CAAC,GACD,KAAK,CAAC;AAEV,KAAK,eAAe,CAAC,UAAU,IAAI,UAAU,SAAS;IAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACrE,CAAC,GACD,KAAK,CAAC;AAEV;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,cAAc,CAAC,SAAS;IACvC,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,MAAM,CAAC,CAAC;IACtD;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC,SAAS,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACzE,4BAA4B;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,gEAAgE;IAChE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CAClC;AAED,qEAAqE;AACrE,MAAM,MAAM,UAAU,CAAC,SAAS,EAAE,MAAM,IAAI;KACzC,CAAC,IAAI,MAAM,SAAS,GAAG,SAAS,CAAC,CAAC,CAAC,SAAS,MAAM,GAAG,CAAC,GAAG,KAAK;CAChE,CAAC,MAAM,SAAS,CAAC,CAAC;AAEnB,MAAM,WAAW,iBAAiB,CAAC,SAAS,EAAE,OAAO,SAAS,MAAM;IAClE,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,SAAS,CAAC;IACtC,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;IACtC,wDAAwD;IACxD,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC,SAAS,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACzE;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,yFAAyF;IACzF,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;IAC7B;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;IAC7B,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,cAAc,CAAC,SAAS,CAAC,EAAE,CAAC;IACtD;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,oBAAoB,CAAC;IAC/C;;;;;;;;;OASG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC,SAAS,EAAE,oBAAoB,CAAC,CAAC;IAChE;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CACxB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,eAAe,CAAC,OAAO,CAAC,KAChC,OAAO,CAAC,SAAS,WAAW,EAAE,CAAC,CAAC;IACrC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACpE;AAED;;;GAGG;AACH,MAAM,WAAW,yBAAyB;IACxC,4CAA4C;IAC5C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,+CAA+C;IAC/C,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IAChC;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU,CAAC,OAAO,SAAS,MAAM;IAChD,QAAQ,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,CAAC;IACvD,kFAAkF;IAClF,QAAQ,CAAC,SAAS,EAAE,gBAAgB,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,kFAAkF;IAClF,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,YAAY,CAAC;IACtC,QAAQ,CAAC,cAAc,EAAE,CAAC,KAAK,CAAC,EAAE,yBAAyB,KAAK,YAAY,CAAC;CAC9E;AAED;;;;;;;GAOG;AAEH,MAAM,MAAM,yBAAyB,GAAG,sBAAsB,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAE5E;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,UAAU,EAAE,qBAAqB,CAAC;IAC3C,QAAQ,CAAC,MAAM,EAAE,yBAAyB,CAAC;CAC5C"}
|