@antelopejs/dms 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/config.d.ts +0 -2
  3. package/dist/config.js +0 -4
  4. package/dist/config.js.map +1 -1
  5. package/dist/implementations/dms/frontend-modules.js +17 -6
  6. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  7. package/dist/implementations/dms/manifest.d.ts +6 -0
  8. package/dist/implementations/dms/manifest.js +10 -7
  9. package/dist/implementations/dms/manifest.js.map +1 -1
  10. package/dist/implementations/dms/page.js +45 -16
  11. package/dist/implementations/dms/page.js.map +1 -1
  12. package/dist/index.js +4 -7
  13. package/dist/index.js.map +1 -1
  14. package/dist/store/listing.js +1 -2
  15. package/dist/store/listing.js.map +1 -1
  16. package/dist/store/operations.js +1 -2
  17. package/dist/store/operations.js.map +1 -1
  18. package/dist/utils/ensure-default-tenant.d.ts +2 -1
  19. package/dist/utils/ensure-default-tenant.js +13 -1
  20. package/dist/utils/ensure-default-tenant.js.map +1 -1
  21. package/dist/utils/export-jobs.js +2 -1
  22. package/dist/utils/export-jobs.js.map +1 -1
  23. package/frontend-vue/layers/dms-core/app/build/composables/renderNow.ts +1 -2
  24. package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +3 -0
  25. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -5
  26. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +61 -26
  27. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +5 -37
  28. package/frontend-vue/layers/dms-core/app/utils/menu.ts +66 -70
  29. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  30. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  31. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +0 -2
  32. package/frontend-vue/layers/dms-layout/app/app.config.ts +0 -1
  33. package/frontend-vue/layers/dms-layout/app/assets/css/main.css +11 -1
  34. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Italic-Variable.woff2 +0 -0
  35. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Variable.woff2 +0 -0
  36. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Italic-Variable.woff2 +0 -0
  37. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Variable.woff2 +0 -0
  38. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +2 -7
  39. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +3 -11
  40. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +2 -106
  41. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +0 -8
  42. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +0 -10
  43. package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +2 -2
  44. package/frontend-vue/layers/dms-layout/app/components/navigation/NavigationMenu.vue +18 -3
  45. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +24 -4
  46. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +10 -2
  47. package/frontend-vue/layers/dms-layout/app/composables/page/usePageRecordDeletion.ts +162 -0
  48. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +2 -7
  49. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +2 -7
  50. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -0
  51. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-navigation.client.ts +9 -9
  52. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-personal-pages.client.ts +9 -9
  53. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-quick-actions.client.ts +7 -7
  54. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +11 -10
  55. package/frontend-vue/layers/dms-layout/app/theme/actions.ts +5 -4
  56. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +6 -4
  57. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +6 -4
  58. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +5 -1
  59. package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +11 -1
  60. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +6 -1
  61. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/numberSuffix.ts +2 -5
  62. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +26 -5
  63. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationRows.ts +22 -1
  64. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +11 -5
  65. package/frontend-vue/layers/dms-ui/app/build/composables/form/formValidation.ts +36 -4
  66. package/frontend-vue/layers/dms-ui/app/build/composables/form/repeaterTable.ts +17 -4
  67. package/frontend-vue/layers/dms-ui/app/build/composables/table/useRelationFilterLabels.ts +30 -6
  68. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +13 -3
  69. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +21 -0
  70. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +67 -29
  71. package/frontend-vue/layers/dms-ui/app/build/utils/htmlGrammar.ts +79 -0
  72. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +1 -1
  73. package/frontend-vue/layers/dms-ui/app/build/utils/streamHighlight.ts +90 -0
  74. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +23 -12
  75. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -0
  76. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +53 -22
  77. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +27 -6
  78. package/frontend-vue/layers/dms-ui/app/components/form/components/RepeaterTable.vue +14 -7
  79. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +74 -25
  80. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +3 -9
  81. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +16 -1
  82. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +12 -0
  83. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +47 -7
  84. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +19 -9
  85. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +94 -3
  86. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -1
  87. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -1
  88. package/frontend-vue/package.json +6 -2
  89. package/package.json +8 -11
  90. package/skills/dms-module/SKILL.md +1 -1
  91. package/skills/dms-pages/REFERENCE.md +7 -3
  92. package/dist/hooks/tenant-instances.d.ts +0 -1
  93. package/dist/hooks/tenant-instances.js +0 -13
  94. package/dist/hooks/tenant-instances.js.map +0 -1
  95. package/frontend-vue/layers/dms-core/app/composables/hydration/isHydratingComponent.ts +0 -29
  96. package/frontend-vue/layers/dms-layout/app/composables/layout/usePageRecord.ts +0 -50
  97. package/frontend-vue/layers/dms-layout/app/utils/pageRecord.ts +0 -71
@@ -2,6 +2,7 @@ import { onMounted, shallowRef, watch } from "vue";
2
2
  import type { DataTypeSelectSource } from "#dms-core/app/composables/data-types/useDataType";
3
3
  import { unlabeledRelationIds } from "../data-types/relationValue";
4
4
  import {
5
+ notFoundRelationIds,
5
6
  relationRowLabel,
6
7
  relationRowValue,
7
8
  useRelationRows,
@@ -18,15 +19,24 @@ const labelKey = (searchUrl: string, id: string) => `${searchUrl}\n${id}`;
18
19
  const NO_LABELS: ReadonlySet<string> = new Set();
19
20
  const idsOf = (value: unknown) => unlabeledRelationIds(value, NO_LABELS);
20
21
 
22
+ /** How a chip names one id of a relation filter. */
23
+ export interface RelationFilterLabel {
24
+ label: string;
25
+ /** The id of a row the route did not answer, shown on hover. */
26
+ notFoundId?: string;
27
+ }
28
+
21
29
  /**
22
30
  * The labels of the ids a table's relation filters hold: a chip names the
23
31
  * related row ("Acme"), not the raw id it filters on ("cust-060"). Each id is
24
32
  * read once from the relation's `select` route, as its picker does; an id the
25
- * route does not answer (deleted, out of reach) keeps showing as is.
33
+ * route does not answer (deleted, out of reach) is named "Not found".
26
34
  */
27
35
  export function useRelationFilterLabels(sources: () => RelationFilterSource[]) {
28
36
  const { readRelationRows } = useRelationRows();
37
+ const { t } = useI18n();
29
38
  const labels = shallowRef<Record<string, string>>({});
39
+ const notFound = shallowRef<ReadonlySet<string>>(new Set());
30
40
  const requested = new Set<string>();
31
41
 
32
42
  async function loadLabels(source: RelationFilterSource, ids: string[]) {
@@ -42,6 +52,13 @@ export function useRelationFilterLabels(sources: () => RelationFilterSource[]) {
42
52
  if (Object.keys(found).length) {
43
53
  labels.value = { ...labels.value, ...found };
44
54
  }
55
+ const missing = notFoundRelationIds(source, ids, rows);
56
+ if (missing.length) {
57
+ notFound.value = new Set([
58
+ ...notFound.value,
59
+ ...missing.map((id) => labelKey(source.searchUrl, id)),
60
+ ]);
61
+ }
45
62
  }
46
63
 
47
64
  const pending = () =>
@@ -63,11 +80,18 @@ export function useRelationFilterLabels(sources: () => RelationFilterSource[]) {
63
80
  );
64
81
  });
65
82
 
66
- /** The labels of a relation filter's ids, in order; an unknown id as is. */
67
- const labelsOf = (source: RelationFilterSource): string[] =>
68
- idsOf(source.value).map(
69
- (id) => labels.value[labelKey(source.searchUrl, id)] ?? id,
70
- );
83
+ /**
84
+ * The labels of a relation filter's ids, in order: an id not read yet as
85
+ * is, one the route did not answer as "Not found".
86
+ */
87
+ const labelsOf = (source: RelationFilterSource): RelationFilterLabel[] =>
88
+ idsOf(source.value).map((id) => {
89
+ const key = labelKey(source.searchUrl, id);
90
+ if (notFound.value.has(key)) {
91
+ return { label: t("dms.form.relation.not_found"), notFoundId: id };
92
+ }
93
+ return { label: labels.value[key] ?? id };
94
+ });
71
95
 
72
96
  return { labelsOf };
73
97
  }
@@ -5,7 +5,10 @@ import {
5
5
  type FormPageUrls,
6
6
  } from "./useTableViewConfig";
7
7
  import type { FormProps } from "../../../composables/form/types";
8
- import type { QueryParamFilters } from "../../../composables/table-view/types";
8
+ import type {
9
+ QueryParamFilters,
10
+ TableViewColumn,
11
+ } from "../../../composables/table-view/types";
9
12
  import type { LocationQuery, LocationQueryValue } from "#dms/frontend-module";
10
13
  import {
11
14
  TableRowAction,
@@ -33,7 +36,7 @@ import {
33
36
  type BulkActionOutcome,
34
37
  type ConfirmedBulkAction,
35
38
  } from "./utils/bulkActions";
36
- import { resolveActionError } from "../confirm/actionError";
39
+ import { describeTableWriteError } from "./utils/writeError";
37
40
  import {
38
41
  ConfirmActionError,
39
42
  type ConfirmOptions,
@@ -59,6 +62,8 @@ const getItemId = (
59
62
  interface TableRowActionsConfig {
60
63
  api: ReturnType<typeof $fetch.create>;
61
64
  location: string;
65
+ /** The columns, whose labels name the fields a refused write lacks. */
66
+ columns: ReadonlyArray<Pick<TableViewColumn, "accessorKey" | "header">>;
62
67
  caption?: string;
63
68
  labelKey?: string;
64
69
  rowIdKey?: string;
@@ -146,7 +151,12 @@ export const useTableRowActions = <T extends Data>(
146
151
  color: Color.error,
147
152
  icon: "i-ph-warning-circle",
148
153
  title: title || t("dms.form.error_title"),
149
- description: resolveActionError(error, t),
154
+ description: describeTableWriteError(
155
+ error,
156
+ t,
157
+ config.columns,
158
+ processI18n,
159
+ ),
150
160
  });
151
161
  };
152
162
 
@@ -0,0 +1,21 @@
1
+ import type { I18nTranslate } from "#dms-core/app/composables/translation/useTranslation";
2
+ import type { TableViewColumn } from "../../../../composables/table-view/types/column";
3
+ import { resolveActionError } from "../../confirm/actionError";
4
+
5
+ /**
6
+ * Why a table view's write failed (a card moved on the board, a row moved
7
+ * by hand, a row action), for its toast: the server's reason, a field the
8
+ * data API refused named by its column's label ("Loss reason is
9
+ * required."). Never the raw ofetch error line.
10
+ */
11
+ export function describeTableWriteError(
12
+ error: unknown,
13
+ translate: I18nTranslate,
14
+ columns: ReadonlyArray<Pick<TableViewColumn, "accessorKey" | "header">>,
15
+ processI18n: (text: string) => string,
16
+ ): string {
17
+ return resolveActionError(error, translate, (field) => {
18
+ const column = columns.find((entry) => entry.accessorKey === field);
19
+ return column ? processI18n(column.header) : undefined;
20
+ });
21
+ }
@@ -1,19 +1,17 @@
1
- // Read-only highlighting: the code parsed by the editor's own CodeMirror
2
- // languages and cut into styled tokens, without mounting an editor. Loaded on
3
- // demand, like the editor: a page only pays for the parsers once it shows
4
- // code.
5
- import { htmlLanguage } from "@codemirror/lang-html";
6
- import {
7
- javascriptLanguage,
8
- typescriptLanguage,
9
- } from "@codemirror/lang-javascript";
10
- import { jsonLanguage } from "@codemirror/lang-json";
11
- import { StandardSQL } from "@codemirror/lang-sql";
12
- import { StreamLanguage } from "@codemirror/language";
1
+ // Read-only highlighting: the code parsed by the grammars the editor's
2
+ // CodeMirror languages are built on, and cut into styled tokens, without
3
+ // mounting an editor. Each language's grammar loads on demand and alone: a
4
+ // snippet needs neither the editor's view nor its state, which would triple
5
+ // what a page showing a JSON payload downloads. SQL is the exception: its
6
+ // grammar only ships inside `@codemirror/lang-sql`.
13
7
  import type { Parser } from "@lezer/common";
14
- import { highlightTree, tagHighlighter } from "@lezer/highlight";
8
+ import {
9
+ type Highlighter,
10
+ highlightTree,
11
+ tagHighlighter,
12
+ } from "@lezer/highlight";
15
13
  import { CODE_PALETTE, type CodeTokenStyle } from "./codePalette";
16
- import { shellParser } from "./shellParser";
14
+ import type { HighlightedRange } from "./streamHighlight";
17
15
 
18
16
  /** A run of code drawn in one style; unstyled text has no `style`. */
19
17
  export interface CodeToken {
@@ -21,13 +19,42 @@ export interface CodeToken {
21
19
  style?: CodeTokenStyle;
22
20
  }
23
21
 
24
- const PARSERS: Record<string, Parser> = {
25
- json: jsonLanguage.parser,
26
- javascript: javascriptLanguage.parser,
27
- typescript: typescriptLanguage.parser,
28
- html: htmlLanguage.parser,
29
- sql: StandardSQL.language.parser,
30
- shell: StreamLanguage.define(shellParser).parser,
22
+ /** Cuts code of the language it was loaded for into tokens. */
23
+ export type CodeHighlighter = (code: string) => CodeToken[];
24
+
25
+ /** Calls `put` for each styled range of `code`, in order. */
26
+ type RangeReader = (
27
+ code: string,
28
+ highlighter: Highlighter,
29
+ put: HighlightedRange,
30
+ ) => void;
31
+
32
+ const readWith =
33
+ (parser: Parser): RangeReader =>
34
+ (code, highlighter, put) =>
35
+ highlightTree(parser.parse(code), highlighter, put);
36
+
37
+ // One dynamic import per language, so a page loads the grammar it shows.
38
+ const READERS: Record<string, () => Promise<RangeReader>> = {
39
+ json: async () => readWith((await import("@lezer/json")).parser),
40
+ javascript: async () => readWith((await import("@lezer/javascript")).parser),
41
+ typescript: async () =>
42
+ readWith(
43
+ (await import("@lezer/javascript")).parser.configure({ dialect: "ts" }),
44
+ ),
45
+ html: async () => readWith((await import("./htmlGrammar")).htmlParser),
46
+ sql: async () =>
47
+ readWith(
48
+ (await import("@codemirror/lang-sql")).StandardSQL.language.parser,
49
+ ),
50
+ shell: async () => {
51
+ const [{ highlightStream }, { shellParser }] = await Promise.all([
52
+ import("./streamHighlight"),
53
+ import("./shellParser"),
54
+ ]);
55
+ return (code, highlighter, put) =>
56
+ highlightStream(shellParser, code, highlighter, put);
57
+ },
31
58
  };
32
59
 
33
60
  // Each palette entry's position is its class, read back into its style.
@@ -40,16 +67,11 @@ function styleOf(classes: string): CodeTokenStyle {
40
67
  return { color, fontStyle };
41
68
  }
42
69
 
43
- /**
44
- * Cuts `code` into tokens styled for `language` (a resolved name, see
45
- * `resolveCodeLanguage`); a language without a parser is one plain token.
46
- */
47
- export function highlightCode(code: string, language: string): CodeToken[] {
48
- const parser = PARSERS[language];
49
- if (!parser || !code) return [{ text: code }];
70
+ function tokensOf(code: string, read: RangeReader): CodeToken[] {
71
+ if (!code) return [{ text: code }];
50
72
  const tokens: CodeToken[] = [];
51
73
  let cursor = 0;
52
- highlightTree(parser.parse(code), HIGHLIGHTER, (from, to, classes) => {
74
+ read(code, HIGHLIGHTER, (from, to, classes) => {
53
75
  if (from > cursor) tokens.push({ text: code.slice(cursor, from) });
54
76
  tokens.push({ text: code.slice(from, to), style: styleOf(classes) });
55
77
  cursor = to;
@@ -57,3 +79,19 @@ export function highlightCode(code: string, language: string): CodeToken[] {
57
79
  if (cursor < code.length) tokens.push({ text: code.slice(cursor) });
58
80
  return tokens;
59
81
  }
82
+
83
+ const plain: CodeHighlighter = (code) => [{ text: code }];
84
+
85
+ /**
86
+ * Loads the grammar of `language` (a resolved name, see
87
+ * `resolveCodeLanguage`) and resolves to what cuts its code into styled
88
+ * tokens; a language without a grammar shows each code as one plain token.
89
+ */
90
+ export async function loadCodeHighlighter(
91
+ language: string,
92
+ ): Promise<CodeHighlighter> {
93
+ const load = READERS[language];
94
+ if (!load) return plain;
95
+ const read = await load();
96
+ return (code) => tokensOf(code, read);
97
+ }
@@ -0,0 +1,79 @@
1
+ // HTML as the editor's language reads it (`@codemirror/lang-html`): scripts,
2
+ // styles, `style` attributes and event handlers parsed in their own
3
+ // languages, from the lezer grammars alone. The nesting mirrors lang-html's
4
+ // defaults, so a snippet colours HTML exactly like the editor. A module of its
5
+ // own because HTML is the one grammar composed of others.
6
+ import { parser as cssParser } from "@lezer/css";
7
+ import { configureNesting, parser } from "@lezer/html";
8
+ import { parser as javascriptParser } from "@lezer/javascript";
9
+
10
+ // What lezer read of a tag's attributes; an absent one is undefined.
11
+ type Attributes = Readonly<Record<string, string | undefined>>;
12
+
13
+ const typescriptParser = javascriptParser.configure({ dialect: "ts" });
14
+ // A JSON script holds one value, read as JavaScript reads it.
15
+ const jsonScriptParser = javascriptParser.configure({
16
+ top: "SingleExpression",
17
+ });
18
+
19
+ const JSON_SCRIPT_TYPE =
20
+ /^(importmap|speculationrules|application\/(.+\+)?json)$/i;
21
+ const JAVASCRIPT_TYPE =
22
+ /^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i;
23
+ const CSS_TYPE = /^(text\/)?(x-)?(stylesheet|css)$/i;
24
+
25
+ const NESTED_TAGS = [
26
+ {
27
+ tag: "script",
28
+ attrs: (attrs: Attributes) =>
29
+ attrs.type === "text/typescript" || attrs.lang === "ts",
30
+ parser: typescriptParser,
31
+ },
32
+ {
33
+ tag: "script",
34
+ attrs: (attrs: Attributes) =>
35
+ attrs.type === "text/babel" || attrs.type === "text/jsx",
36
+ parser: javascriptParser.configure({ dialect: "jsx" }),
37
+ },
38
+ {
39
+ tag: "script",
40
+ attrs: (attrs: Attributes) => attrs.type === "text/typescript-jsx",
41
+ parser: javascriptParser.configure({ dialect: "jsx ts" }),
42
+ },
43
+ {
44
+ tag: "script",
45
+ attrs: (attrs: Attributes) => JSON_SCRIPT_TYPE.test(attrs.type ?? ""),
46
+ parser: jsonScriptParser,
47
+ },
48
+ {
49
+ tag: "script",
50
+ attrs: (attrs: Attributes) =>
51
+ !attrs.type || JAVASCRIPT_TYPE.test(attrs.type),
52
+ parser: javascriptParser,
53
+ },
54
+ {
55
+ tag: "style",
56
+ attrs: (attrs: Attributes) =>
57
+ (!attrs.lang || attrs.lang === "css") &&
58
+ (!attrs.type || CSS_TYPE.test(attrs.type)),
59
+ parser: cssParser,
60
+ },
61
+ ];
62
+
63
+ const EVENT_ATTRIBUTES = (
64
+ "beforeunload copy cut dragstart dragover dragleave dragenter dragend " +
65
+ "drag paste focus blur change click load mousedown mouseenter mouseleave " +
66
+ "mouseup keydown keyup resize scroll unload"
67
+ )
68
+ .split(" ")
69
+ .map((name) => `on${name}`);
70
+
71
+ const NESTED_ATTRIBUTES = [
72
+ { name: "style", parser: cssParser.configure({ top: "Styles" }) },
73
+ ...EVENT_ATTRIBUTES.map((name) => ({ name, parser: javascriptParser })),
74
+ ];
75
+
76
+ /** The HTML grammar, with its embedded languages. */
77
+ export const htmlParser = parser.configure({
78
+ wrap: configureNesting(NESTED_TAGS, NESTED_ATTRIBUTES),
79
+ });
@@ -1,4 +1,4 @@
1
- import type { StreamParser, StringStream } from "@codemirror/language";
1
+ import type { StreamParser, StringStream } from "@codemirror/streamparser";
2
2
 
3
3
  // A shell snippet as read for highlighting only: the command of each
4
4
  // pipeline step, its flags, quoted strings, variables and comments.
@@ -0,0 +1,90 @@
1
+ // A CodeMirror stream parser (a language written as a line tokenizer, like
2
+ // the shell's) run for read-only highlighting. `StreamLanguage` would do it
3
+ // inside an editor state, and so pull CodeMirror's view and state into every
4
+ // page showing a snippet: this drives the parser the same way, line by line
5
+ // over `StringStream`, and names its tokens' tags the same way.
6
+ import { type StreamParser, StringStream } from "@codemirror/streamparser";
7
+ import { type Highlighter, type Tag, tags } from "@lezer/highlight";
8
+
9
+ /** Receives one styled range of the code: its offsets and its classes. */
10
+ export type HighlightedRange = (
11
+ from: number,
12
+ to: number,
13
+ classes: string,
14
+ ) => void;
15
+
16
+ // CodeMirror's defaults, which `StreamLanguage` hands every stream.
17
+ const TAB_SIZE = 4;
18
+ const INDENT_UNIT = 2;
19
+ // Zero-length tokens a parser may return in a row (each one changing its
20
+ // state) before the stream moves on by itself, as `StreamLanguage` allows.
21
+ const MAX_EMPTY_TOKENS = 10;
22
+
23
+ type TagValue = Tag | readonly Tag[] | ((tag: Tag) => Tag);
24
+ const TAG_TABLE = tags as unknown as Record<string, TagValue | undefined>;
25
+ const tagsByName = new Map<string, Tag[]>();
26
+
27
+ // A token type names tags the way `StreamLanguage` reads them: space
28
+ // separated, each a tag followed by its modifiers (`variableName.special`).
29
+ function tagsOf(type: string): Tag[] {
30
+ const cached = tagsByName.get(type);
31
+ if (cached) return cached;
32
+ const resolved = type.split(" ").flatMap((name) => {
33
+ let found: Tag[] = [];
34
+ for (const part of name.split(".")) {
35
+ const value = TAG_TABLE[part];
36
+ if (typeof value === "function") found = found.map(value);
37
+ else if (value && !found.length)
38
+ found = Array.isArray(value) ? [...value] : [value as Tag];
39
+ }
40
+ return found;
41
+ });
42
+ tagsByName.set(type, resolved);
43
+ return resolved;
44
+ }
45
+
46
+ /**
47
+ * Tokenizes `code` with `parser` and calls `put` for each range its
48
+ * highlighter styles, adjacent ranges of one style merged, as
49
+ * `highlightTree` reports a parsed tree.
50
+ */
51
+ export function highlightStream<State>(
52
+ parser: StreamParser<State>,
53
+ code: string,
54
+ highlighter: Highlighter,
55
+ put: HighlightedRange,
56
+ ): void {
57
+ const state = parser.startState?.(INDENT_UNIT) ?? (true as State);
58
+ let pending: { from: number; to: number; classes: string } | null = null;
59
+ const flush = () => {
60
+ if (pending) put(pending.from, pending.to, pending.classes);
61
+ pending = null;
62
+ };
63
+ const add = (from: number, to: number, classes: string | null) => {
64
+ if (pending && (pending.to !== from || pending.classes !== classes))
65
+ flush();
66
+ if (!classes) return;
67
+ if (pending) pending.to = to;
68
+ else pending = { from, to, classes };
69
+ };
70
+
71
+ let lineStart = 0;
72
+ for (const line of code.split("\n")) {
73
+ if (!line) parser.blankLine?.(state, INDENT_UNIT);
74
+ const stream = new StringStream(line, TAB_SIZE, INDENT_UNIT);
75
+ let emptyTokens = 0;
76
+ while (!stream.eol()) {
77
+ stream.start = stream.pos;
78
+ const type = parser.token(stream, state);
79
+ if (stream.pos === stream.start) {
80
+ if (++emptyTokens < MAX_EMPTY_TOKENS) continue;
81
+ stream.next();
82
+ }
83
+ emptyTokens = 0;
84
+ const classes = type ? highlighter.style(tagsOf(type)) : null;
85
+ add(lineStart + stream.start, lineStart + stream.pos, classes);
86
+ }
87
+ lineStart += line.length + 1;
88
+ }
89
+ flush();
90
+ }
@@ -1,18 +1,21 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, shallowRef } from "vue";
2
+ import { computed, onMounted, shallowRef, watch } from "vue";
3
3
  import DmsCopyButton from "../copy/CopyButton.vue";
4
4
  import {
5
5
  codeLanguageLabel,
6
6
  PLAIN_CODE_LANGUAGE,
7
7
  resolveCodeLanguage,
8
8
  } from "../../build/utils/codeLanguages";
9
- import type { CodeToken, highlightCode } from "../../build/utils/codeHighlight";
9
+ import type {
10
+ CodeHighlighter,
11
+ CodeToken,
12
+ } from "../../build/utils/codeHighlight";
10
13
 
11
14
  // Read-only code in the editor's look: a head with the title, the language
12
15
  // and a copy button, then the code highlighted by the editor's languages.
13
16
  // Without a title or a language there is no head: the copy button floats in
14
- // the code's corner. Until the highlighter loads (and on the server) the
15
- // code shows as is.
17
+ // the code's corner. Until its language's grammar loads (and on the server)
18
+ // the code shows as is.
16
19
 
17
20
  /**
18
21
  * How long lines break: `false` scrolls them sideways, `true` wraps them
@@ -63,7 +66,7 @@ const WRAP_CLASSES: Record<"false" | "true" | "anywhere", string> = {
63
66
  anywhere: "whitespace-pre-wrap [overflow-wrap:anywhere]",
64
67
  };
65
68
 
66
- const highlight = shallowRef<typeof highlightCode | null>(null);
69
+ const highlight = shallowRef<CodeHighlighter | null>(null);
67
70
 
68
71
  const language = computed(() => resolveCodeLanguage(props.language));
69
72
  // A language given, even `text`, is named in a head; without a title either,
@@ -79,9 +82,7 @@ const wrapClass = computed(
79
82
  () => WRAP_CLASSES[String(props.wrap) as keyof typeof WRAP_CLASSES],
80
83
  );
81
84
  const tokens = computed<CodeToken[]>(() =>
82
- highlight.value
83
- ? highlight.value(props.code, language.value)
84
- : [{ text: props.code }],
85
+ highlight.value ? highlight.value(props.code) : [{ text: props.code }],
85
86
  );
86
87
  const sizeStyle = computed(() => {
87
88
  if (props.maxLines)
@@ -91,10 +92,20 @@ const sizeStyle = computed(() => {
91
92
  return props.maxHeight ? { maxHeight: props.maxHeight } : undefined;
92
93
  });
93
94
 
94
- onMounted(async () => {
95
- const module = await import("../../build/utils/codeHighlight");
96
- highlight.value = module.highlightCode;
97
- });
95
+ // Only the grammar of the language shown loads. A language changed while
96
+ // one loads drops it: the code stays plain until its own grammar is in.
97
+ async function loadHighlighter(): Promise<void> {
98
+ const requested = language.value;
99
+ highlight.value = null;
100
+ const { loadCodeHighlighter } = await import(
101
+ "../../build/utils/codeHighlight"
102
+ );
103
+ const highlighter = await loadCodeHighlighter(requested);
104
+ if (language.value === requested) highlight.value = highlighter;
105
+ }
106
+
107
+ onMounted(loadHighlighter);
108
+ watch(language, loadHighlighter);
98
109
  </script>
99
110
 
100
111
  <template>
@@ -326,6 +326,7 @@ const localizedSchema = computed(() => {
326
326
  id: field.id,
327
327
  type: field.type,
328
328
  localized: field.localized,
329
+ fallbackLocale: field.fallbackLocale,
329
330
  required: isFieldRequired(field),
330
331
  acceptance: isAcceptanceField(field),
331
332
  }));
@@ -6,7 +6,10 @@ import {
6
6
  findCascaderNode,
7
7
  formatCascaderPath,
8
8
  isCascaderNodeSelectable,
9
+ readCascaderAncestry,
9
10
  resolveCascaderKeyMapping,
11
+ buildCascaderPathLabels,
12
+ CASCADER_PATH_SEPARATOR,
10
13
  searchCascaderTree,
11
14
  type CascaderKeyMapping,
12
15
  type CascaderNode,
@@ -18,6 +21,7 @@ import {
18
21
  FIELD_TRIGGER_INVALID_CLASS,
19
22
  FIELD_TRIGGER_UI,
20
23
  } from "../../../build/utils/fieldTrigger";
24
+ import { useRelationRows } from "../../../build/composables/data-types/relationRows";
21
25
 
22
26
  interface CascaderProps {
23
27
  searchUrl: string;
@@ -88,6 +92,36 @@ const { data, status, execute } = await useDmsAsyncData(
88
92
  );
89
93
 
90
94
  const keyMapping = computed(() => resolveCascaderKeyMapping(props.keyMapping));
95
+ const selectedValues = computed<string[]>(() => {
96
+ if (Array.isArray(props.modelValue)) {
97
+ return props.modelValue.map(String);
98
+ }
99
+ return props.modelValue ? [String(props.modelValue)] : [];
100
+ });
101
+
102
+ // The picked rows and their ancestors, read by `?ids=` level by level: the
103
+ // field names its value before the menu is opened, whatever the tree's size.
104
+ const { readRelationRows } = useRelationRows();
105
+ const { data: ancestry } = await useDmsAsyncData(
106
+ () => `cascader-value-${instanceId}-${selectedValues.value.join(",")}`,
107
+ () =>
108
+ selectedValues.value.length > 0
109
+ ? readCascaderAncestry(
110
+ (ids) =>
111
+ readRelationRows(
112
+ {
113
+ searchUrl: props.searchUrl,
114
+ keyMapping: { value: keyMapping.value.value },
115
+ },
116
+ ids,
117
+ ),
118
+ keyMapping.value,
119
+ selectedValues.value,
120
+ )
121
+ : Promise.resolve(undefined),
122
+ // The field's label, not the page's data.
123
+ { lazy: true, refreshOnPageRefresh: false },
124
+ );
91
125
  const rows = computed(() => data.value?.results ?? []);
92
126
  const isPartial = computed(
93
127
  () => (data.value?.total ?? 0) > rows.value.length && rows.value.length > 0,
@@ -100,12 +134,6 @@ const selectionRules = computed(() => ({
100
134
  maxDepth: props.maxDepth,
101
135
  }));
102
136
 
103
- const selectedValues = computed<string[]>(() => {
104
- if (Array.isArray(props.modelValue)) {
105
- return props.modelValue.map(String);
106
- }
107
- return props.modelValue ? [String(props.modelValue)] : [];
108
- });
109
137
  const selectedSet = computed(() => new Set(selectedValues.value));
110
138
 
111
139
  const open = ref(false);
@@ -134,14 +162,11 @@ const columns = computed<CascaderNode[][]>(() => {
134
162
  return result;
135
163
  });
136
164
 
137
- function initialValueLabel(): string | undefined {
138
- const source = Array.isArray(props.initialValue)
139
- ? props.initialValue[0]
140
- : props.initialValue;
141
- if (!source || typeof source !== "object") return undefined;
142
- const label = (source as Record<string, unknown>)[keyMapping.value.label];
143
- return label === undefined || label === null ? undefined : String(label);
144
- }
165
+ /** The picked id no row answers (deleted, out of reach), if it is one. */
166
+ const notFoundValue = computed(() => {
167
+ const value = selectedValues.value[0];
168
+ return value && ancestry.value?.notFound.includes(value) ? value : undefined;
169
+ });
145
170
 
146
171
  const triggerLabel = computed(() => {
147
172
  if (selectedValues.value.length === 0) return "";
@@ -151,9 +176,15 @@ const triggerLabel = computed(() => {
151
176
  });
152
177
  }
153
178
  const value = selectedValues.value[0]!;
179
+ if (notFoundValue.value) return t("dms.form.relation.not_found");
154
180
  const node = findCascaderNode(roots.value, value);
155
181
  if (node) return formatCascaderPath(node);
156
- return initialValueLabel() ?? value;
182
+ const path = buildCascaderPathLabels(
183
+ ancestry.value?.rows ?? [],
184
+ keyMapping.value,
185
+ value,
186
+ );
187
+ return path.length > 0 ? path.join(CASCADER_PATH_SEPARATOR) : value;
157
188
  });
158
189
 
159
190
  const placeholderLabel = computed(() =>
@@ -339,12 +370,6 @@ watch(searchTerm, () => {
339
370
  };
340
371
  });
341
372
 
342
- onMounted(() => {
343
- if (selectedValues.value.length > 0 || props.initialValue) {
344
- void execute();
345
- }
346
- });
347
-
348
373
  function nodeClasses(node: CascaderNode, isHighlighted: boolean): string[] {
349
374
  return [
350
375
  "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
@@ -383,7 +408,13 @@ function nodeClasses(node: CascaderNode, isHighlighted: boolean): string[] {
383
408
  :aria-label="placeholderLabel"
384
409
  v-bind="ariaAttrs"
385
410
  >
386
- <span v-if="triggerLabel" class="truncate">{{ triggerLabel }}</span>
411
+ <span
412
+ v-if="triggerLabel"
413
+ :class="['truncate', notFoundValue && 'text-muted']"
414
+ :title="notFoundValue ?? triggerLabel"
415
+ >
416
+ {{ triggerLabel }}
417
+ </span>
387
418
  <span v-else class="text-dimmed truncate">{{ placeholderLabel }}</span>
388
419
  </UButton>
389
420