@antelopejs/dms 0.10.0 → 0.11.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +69 -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/index.js +4 -7
  11. package/dist/index.js.map +1 -1
  12. package/dist/store/listing.js +1 -2
  13. package/dist/store/listing.js.map +1 -1
  14. package/dist/store/operations.js +1 -2
  15. package/dist/store/operations.js.map +1 -1
  16. package/dist/utils/ensure-default-tenant.d.ts +2 -1
  17. package/dist/utils/ensure-default-tenant.js +13 -1
  18. package/dist/utils/ensure-default-tenant.js.map +1 -1
  19. package/dist/utils/export-jobs.js +2 -1
  20. package/dist/utils/export-jobs.js.map +1 -1
  21. package/frontend-vue/layers/dms-core/app/build/composables/renderNow.ts +1 -2
  22. package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +3 -0
  23. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -5
  24. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +61 -26
  25. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +5 -37
  26. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  27. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  28. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +0 -2
  29. package/frontend-vue/layers/dms-layout/app/app.config.ts +0 -1
  30. package/frontend-vue/layers/dms-layout/app/assets/css/main.css +11 -1
  31. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Italic-Variable.woff2 +0 -0
  32. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Variable.woff2 +0 -0
  33. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Italic-Variable.woff2 +0 -0
  34. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Variable.woff2 +0 -0
  35. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +2 -7
  36. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +2 -106
  37. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +0 -8
  38. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +0 -10
  39. package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +2 -2
  40. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +24 -4
  41. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +10 -2
  42. package/frontend-vue/layers/dms-layout/app/composables/page/usePageRecordDeletion.ts +162 -0
  43. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +2 -7
  44. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +2 -7
  45. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -0
  46. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-navigation.client.ts +9 -9
  47. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-personal-pages.client.ts +9 -9
  48. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-quick-actions.client.ts +7 -7
  49. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +11 -10
  50. package/frontend-vue/layers/dms-layout/app/theme/actions.ts +5 -4
  51. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +6 -4
  52. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +6 -4
  53. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +5 -1
  54. package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +11 -1
  55. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +6 -1
  56. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/numberSuffix.ts +2 -5
  57. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +26 -5
  58. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationRows.ts +22 -1
  59. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +11 -5
  60. package/frontend-vue/layers/dms-ui/app/build/composables/form/formValidation.ts +36 -4
  61. package/frontend-vue/layers/dms-ui/app/build/composables/form/repeaterTable.ts +17 -4
  62. package/frontend-vue/layers/dms-ui/app/build/composables/table/useRelationFilterLabels.ts +30 -6
  63. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +13 -3
  64. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +21 -0
  65. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +67 -29
  66. package/frontend-vue/layers/dms-ui/app/build/utils/htmlGrammar.ts +79 -0
  67. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +1 -1
  68. package/frontend-vue/layers/dms-ui/app/build/utils/streamHighlight.ts +90 -0
  69. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +23 -12
  70. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -0
  71. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +53 -22
  72. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +27 -6
  73. package/frontend-vue/layers/dms-ui/app/components/form/components/RepeaterTable.vue +14 -7
  74. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +74 -25
  75. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +3 -9
  76. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +16 -1
  77. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +12 -0
  78. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +47 -7
  79. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +19 -9
  80. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +94 -3
  81. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -1
  82. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -1
  83. package/frontend-vue/package.json +6 -2
  84. package/package.json +7 -7
  85. package/skills/dms-module/SKILL.md +1 -1
  86. package/skills/dms-pages/REFERENCE.md +7 -3
  87. package/dist/hooks/tenant-instances.d.ts +0 -1
  88. package/dist/hooks/tenant-instances.js +0 -13
  89. package/dist/hooks/tenant-instances.js.map +0 -1
  90. package/frontend-vue/layers/dms-core/app/composables/hydration/isHydratingComponent.ts +0 -29
  91. package/frontend-vue/layers/dms-layout/app/composables/layout/usePageRecord.ts +0 -50
  92. package/frontend-vue/layers/dms-layout/app/utils/pageRecord.ts +0 -71
@@ -1,8 +1,5 @@
1
1
  import type { DataTypeFormatter } from "#dms-core/app/composables/data-types/useDataType";
2
- import {
3
- getPluginI18n,
4
- resolveI18nKey,
5
- } from "../../../../../dms-core/app/composables/translation/useTranslation";
2
+ import { getAppTranslation } from "../../../../../dms-core/app/composables/translation/useTranslation";
6
3
 
7
4
  /** A formatter writing a number for a locale, with Intl's options. */
8
5
  type NumberFormatter = (
@@ -41,7 +38,7 @@ export function withNumberSuffix(format: NumberFormatter): DataTypeFormatter {
41
38
  if (!suffix || typeof value !== "number" || typeof formatted !== "string") {
42
39
  return formatted;
43
40
  }
44
- const unit = resolveI18nKey(getPluginI18n().translate, suffix);
41
+ const unit = getAppTranslation().processI18n(suffix);
45
42
  return `${formatted}${UNIT_SEPARATOR}${unit}`;
46
43
  };
47
44
  }
@@ -7,7 +7,7 @@ import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
7
7
  import ULink from "@nuxt/ui/components/Link.vue";
8
8
  import { buildRelationBadges } from "./relationBadges";
9
9
  import { mapRelationBeforeState, sameRelationValue } from "./relationValue";
10
- import { relationSelectSource } from "./relationRows";
10
+ import { NotFoundRelation, relationSelectSource } from "./relationRows";
11
11
  import {
12
12
  isSet,
13
13
  readRowField,
@@ -338,12 +338,31 @@ function renderRelationBadge(label: string, title?: string) {
338
338
  });
339
339
  }
340
340
 
341
+ // An id its `select` route did not answer: "Not found", the id on hover.
342
+ function renderNotFoundRelation(relation: NotFoundRelation) {
343
+ const { t } = getAppTranslation();
344
+ return h(
345
+ "span",
346
+ { class: "text-muted", title: relation.id },
347
+ t("dms.form.relation.not_found"),
348
+ );
349
+ }
350
+
341
351
  function renderRelationBadges(values: unknown[], labelKey: string) {
342
- const { processI18n } = getAppTranslation();
343
- const { visible, hidden } = buildRelationBadges(values, labelKey);
344
- const badges = visible.map((label) =>
345
- renderRelationBadge(processI18n(label)),
352
+ const { processI18n, t } = getAppTranslation();
353
+ const notFound = values.filter(
354
+ (item): item is NotFoundRelation => item instanceof NotFoundRelation,
355
+ );
356
+ const { visible, hidden } = buildRelationBadges(
357
+ values.filter((item) => !(item instanceof NotFoundRelation)),
358
+ labelKey,
346
359
  );
360
+ const badges = [
361
+ ...visible.map((label) => renderRelationBadge(processI18n(label))),
362
+ ...notFound.map((relation) =>
363
+ renderRelationBadge(t("dms.form.relation.not_found"), relation.id),
364
+ ),
365
+ ];
347
366
  if (hidden.length > 0) {
348
367
  const hiddenLabels = hidden.map((label) => processI18n(label)).join(", ");
349
368
  badges.push(renderRelationBadge(`+${hidden.length}`, hiddenLabels));
@@ -359,6 +378,7 @@ function renderRelation(value: unknown, options: unknown) {
359
378
  return renderRelationBadges(value, labelKey);
360
379
  }
361
380
 
381
+ if (value instanceof NotFoundRelation) return renderNotFoundRelation(value);
362
382
  if (!value || !isObject(value)) return value;
363
383
 
364
384
  const valueObject = value as Record<string, string>;
@@ -981,6 +1001,7 @@ const registerCascaderRelationType = (
981
1001
  },
982
1002
  beforeStateMapper: mapRelationBeforeState,
983
1003
  isSameValue: sameRelationValue,
1004
+ selectSource: relationSelectSource,
984
1005
  });
985
1006
  };
986
1007
 
@@ -45,12 +45,33 @@ export function relationRowLabel(
45
45
  return row[source.keyMapping?.label || RELATION_LABEL_KEY];
46
46
  }
47
47
 
48
+ /**
49
+ * An id a relation's `select` route did not answer: a deleted row, or one out
50
+ * of the reader's reach — it cannot tell which, so it is shown as "Not
51
+ * found", never as "Deleted", with the id itself in its tooltip.
52
+ */
53
+ export class NotFoundRelation {
54
+ constructor(readonly id: string) {}
55
+ }
56
+
57
+ /** The ids of a read that its rows do not answer. */
58
+ export function notFoundRelationIds(
59
+ source: DataTypeSelectSource,
60
+ ids: string[],
61
+ rows: RelationRow[],
62
+ ): string[] {
63
+ const answered = new Set(
64
+ rows.map((row) => String(relationRowValue(source, row))),
65
+ );
66
+ return ids.filter((id) => !answered.has(id));
67
+ }
68
+
48
69
  /**
49
70
  * Reads the rows of a relation's ids from its `select` route, narrowed with
50
71
  * `?ids=` to those rows alone (matched on the mapped value key), whatever the
51
72
  * route's page size. The one way the picker, the filter chips and the
52
73
  * read-only lists name the ids they hold. A failed read answers no rows: the
53
- * ids then show as they are.
74
+ * ids then show as not found, as a row out of the reader's reach does.
54
75
  */
55
76
  export function useRelationRows() {
56
77
  const { $authFetch } = useAuthFetch();
@@ -1,15 +1,19 @@
1
1
  import { type Ref, ref, watch } from "vue";
2
2
  import { apiErrorStatus } from "#dms-core/app/composables/useFieldErrors";
3
- import { HTTP_FORBIDDEN } from "#dms-core/app/utils/http-status";
3
+ import {
4
+ HTTP_FORBIDDEN,
5
+ HTTP_NOT_FOUND,
6
+ } from "#dms-core/app/utils/http-status";
4
7
 
5
8
  /**
6
9
  * Why a form could not load the record it edits: `forbidden` when the server
7
10
  * refused the caller the read (a role that may edit rows but not view them),
8
11
  * `missing` when its address lacks the record's id (a page reached without
9
- * its `?id=`), `error` for anything else (a network failure, a server error,
10
- * a row gone).
12
+ * its `?id=`), `not_found` when the server answered 404 (the record was
13
+ * deleted, or never existed), `error` for anything else (a network failure,
14
+ * a server error).
11
15
  */
12
- export type FormLoadFailure = "forbidden" | "missing" | "error";
16
+ export type FormLoadFailure = "forbidden" | "missing" | "not_found" | "error";
13
17
 
14
18
  /** What a form knows of the record it is about, as far as `missing` goes. */
15
19
  export interface FormRecordAddress {
@@ -51,7 +55,9 @@ export interface FormRecordLoad {
51
55
  /** The failure a load error stands for; `null` when there is none. */
52
56
  export function formLoadFailure(error: unknown): FormLoadFailure | null {
53
57
  if (!error) return null;
54
- return apiErrorStatus(error) === HTTP_FORBIDDEN ? "forbidden" : "error";
58
+ const status = apiErrorStatus(error);
59
+ if (status === HTTP_FORBIDDEN) return "forbidden";
60
+ return status === HTTP_NOT_FOUND ? "not_found" : "error";
55
61
  }
56
62
 
57
63
  /** How a form takes the answers of its record's later reads. */
@@ -22,6 +22,11 @@ export interface FormValidationField {
22
22
  type?: string;
23
23
  /** Holds one value per content language. */
24
24
  localized?: boolean;
25
+ /**
26
+ * The language a localized field falls back to: required as soon as the
27
+ * field holds any language, and where its required error goes.
28
+ */
29
+ fallbackLocale?: string;
25
30
  /** A box to tick: unticked, it is missing. */
26
31
  acceptance?: boolean;
27
32
  /**
@@ -63,6 +68,32 @@ function issueField(issue: { path: PropertyKey[] }): PropertyKey | undefined {
63
68
  return issue.path[0];
64
69
  }
65
70
 
71
+ /** Whether a localized value fills some language but not its fallback one. */
72
+ function lacksFallbackLocale(
73
+ field: FormValidationField,
74
+ value: unknown,
75
+ ): boolean {
76
+ if (!field.localized || !field.fallbackLocale) return false;
77
+ if (typeof value !== "object" || value === null) return false;
78
+ const languages = value as Record<string, unknown>;
79
+ const isAnyFilled = Object.values(languages).some(
80
+ (text) => !isBlankValue(text, field.type),
81
+ );
82
+ return (
83
+ isAnyFilled && isBlankValue(languages[field.fallbackLocale], field.type)
84
+ );
85
+ }
86
+
87
+ /** Where a localized field's required error goes: the language to fill. */
88
+ function requiredPath(
89
+ field: FormValidationField,
90
+ locale: string | undefined,
91
+ ): PropertyKey[] {
92
+ if (!field.localized) return [field.id];
93
+ const language = field.fallbackLocale ?? locale;
94
+ return language ? [field.id, language] : [field.id];
95
+ }
96
+
66
97
  /** An issue saying the value is not of a branch's type at all. */
67
98
  function isTypeMismatch(issue: z.ZodIssue, path: PropertyKey[]): boolean {
68
99
  return issue.code === "invalid_type" && issue.path.length === path.length;
@@ -117,6 +148,10 @@ export async function validateFormState(
117
148
  for (const field of context.fields) {
118
149
  const value = state[field.id];
119
150
  if (reported.has(field.id)) continue;
151
+ if (lacksFallbackLocale(field, value)) {
152
+ missing.add(field.id);
153
+ continue;
154
+ }
120
155
  const isBlank = field.acceptance
121
156
  ? value !== true
122
157
  : isBlankValue(value, field.type);
@@ -159,10 +194,7 @@ export async function validateFormState(
159
194
  if (!missing.has(field.id)) continue;
160
195
  issues.push({
161
196
  message: context.requiredMessage,
162
- path:
163
- field.localized && context.locale
164
- ? [field.id, context.locale]
165
- : [field.id],
197
+ path: requiredPath(field, context.locale),
166
198
  });
167
199
  }
168
200
 
@@ -3,6 +3,7 @@ import type {
3
3
  DataTypeSelectSource,
4
4
  } from "#dms-core/app/composables/data-types/useDataType";
5
5
  import type { RenderableColumn } from "../data-types/useColumnValueRenderer";
6
+ import { NotFoundRelation } from "../data-types/relationRows";
6
7
 
7
8
  /** A column of a read-only list: a field of its rows. */
8
9
  export interface RepeaterTableColumn {
@@ -49,17 +50,29 @@ export function relationLabelSource(
49
50
  return source ? { ...source, multiple: !!options?.multiple } : undefined;
50
51
  }
51
52
 
53
+ /** The rows a relation column's read answered, by value, and the ids it asked. */
54
+ export interface RelationColumnRows {
55
+ rowsByValue: ReadonlyMap<unknown, Record<string, unknown>>;
56
+ readIds: ReadonlySet<string>;
57
+ }
58
+
52
59
  /**
53
60
  * A relation value as its formatter draws it: the related row in place of
54
- * each id it holds. An id no row matches (a deleted one) stays as it is; a
55
- * value the server already joined is kept.
61
+ * each id it holds. An id the read asked for and no row answers (deleted, out
62
+ * of reach) is not found; one not asked yet stays as it is, as does a value
63
+ * the server already joined.
56
64
  */
57
65
  export function joinRelationValue(
58
66
  value: unknown,
59
- rowsByValue: ReadonlyMap<unknown, Record<string, unknown>>,
67
+ { rowsByValue, readIds }: RelationColumnRows,
60
68
  multiple: boolean,
61
69
  ): unknown {
62
- const join = (item: unknown) => rowsByValue.get(item) ?? item;
70
+ const join = (item: unknown) => {
71
+ const row = rowsByValue.get(item);
72
+ if (row) return row;
73
+ const id = String(item);
74
+ return readIds.has(id) ? new NotFoundRelation(id) : item;
75
+ };
63
76
  if (multiple && Array.isArray(value)) return value.map(join);
64
77
  return join(value);
65
78
  }
@@ -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.