@antelopejs/dms 0.7.1 → 0.7.3

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 (110) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/hooks/tenant-instances.d.ts +1 -0
  3. package/dist/hooks/tenant-instances.js +13 -0
  4. package/dist/hooks/tenant-instances.js.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +67 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js +1 -0
  10. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.d.ts +1 -0
  12. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js +46 -0
  13. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js.map +1 -0
  14. package/dist/test/unit/interfaces/dms-base/column-display.test.js +11 -0
  15. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  16. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +31 -0
  17. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  18. package/dist/test/unit/utils/tenant-instances.test.d.ts +1 -0
  19. package/dist/test/unit/utils/tenant-instances.test.js +53 -0
  20. package/dist/test/unit/utils/tenant-instances.test.js.map +1 -0
  21. package/dist/utils/index.d.ts +1 -0
  22. package/dist/utils/index.js +1 -0
  23. package/dist/utils/index.js.map +1 -1
  24. package/dist/utils/tenant-instances.d.ts +2 -0
  25. package/dist/utils/tenant-instances.js +38 -0
  26. package/dist/utils/tenant-instances.js.map +1 -0
  27. package/dist/validation/auth.schema.d.ts +2 -2
  28. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +4 -0
  29. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +35 -23
  30. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockActions.vue +34 -6
  31. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +26 -8
  32. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +3 -1
  33. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +1 -1
  34. package/frontend-vue/layers/dms-ui/app/build/components/table/MonoCell.vue +3 -2
  35. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +24 -13
  36. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +1 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +12 -5
  38. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +40 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +15 -4
  40. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +85 -22
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +10 -2
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +79 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +94 -0
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +41 -5
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/tableQuery.ts +3 -0
  46. package/frontend-vue/layers/dms-ui/app/build/utils/codeEditor.ts +2 -13
  47. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +59 -0
  48. package/frontend-vue/layers/dms-ui/app/build/utils/codeLanguages.ts +36 -0
  49. package/frontend-vue/layers/dms-ui/app/build/utils/codePalette.ts +32 -0
  50. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +55 -0
  51. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +77 -20
  52. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +15 -4
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +111 -25
  54. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +95 -0
  55. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +18 -0
  56. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +8 -0
  57. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +21 -9
  58. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +8 -0
  59. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +97 -0
  60. package/frontend-vue/layers/dms-ui/app/components/empty-state/EmptyState.vue +13 -4
  61. package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +14 -3
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +2 -8
  63. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +19 -0
  64. package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +54 -48
  65. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +24 -9
  66. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +4 -1
  67. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +66 -74
  68. package/frontend-vue/layers/dms-ui/app/components/top-list/internal/TopListRow.vue +4 -3
  69. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +5 -2
  70. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +2 -29
  71. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -1
  72. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +2 -0
  73. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +4 -0
  74. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +2 -2
  75. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  76. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  77. package/frontend-vue/package.json +4 -3
  78. package/frontend-vue/pnpm-lock.yaml +8 -5
  79. package/frontend-vue/tests/activity-feed-days.test.ts +26 -12
  80. package/frontend-vue/tests/banner-block-fetch.test.ts +162 -0
  81. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +100 -0
  82. package/frontend-vue/tests/block-items-period-realtime.test.ts +147 -0
  83. package/frontend-vue/tests/block-route-params.test.ts +48 -0
  84. package/frontend-vue/tests/code-block.test.ts +155 -0
  85. package/frontend-vue/tests/code-highlight.test.ts +72 -0
  86. package/frontend-vue/tests/composed-text-blocks.test.ts +5 -0
  87. package/frontend-vue/tests/composed-text-more-blocks.test.ts +391 -0
  88. package/frontend-vue/tests/drawer-target-direction.test.ts +205 -0
  89. package/frontend-vue/tests/empty-state-late-slots.test.ts +53 -0
  90. package/frontend-vue/tests/flow-canvas-delete-badge.test.ts +88 -0
  91. package/frontend-vue/tests/form-redirect-placeholders.test.ts +11 -0
  92. package/frontend-vue/tests/key-value-list-copy.test.ts +114 -0
  93. package/frontend-vue/tests/master-detail-layout.test.ts +53 -0
  94. package/frontend-vue/tests/mono-cell-truncate.test.ts +50 -0
  95. package/frontend-vue/tests/nav-card-grid-empty-category.test.ts +1 -0
  96. package/frontend-vue/tests/page-fill-height.test.ts +20 -3
  97. package/frontend-vue/tests/route-param-occurrences.test.ts +9 -0
  98. package/frontend-vue/tests/table-actions-search-label.test.ts +147 -0
  99. package/frontend-vue/tests/table-clear-filters.test.ts +22 -0
  100. package/frontend-vue/tests/table-empty-body-viewport.test.ts +25 -0
  101. package/frontend-vue/tests/table-metric-cells.test.ts +22 -0
  102. package/frontend-vue/tests/table-pills-cell.test.ts +126 -0
  103. package/frontend-vue/tests/table-source-navigation.test.ts +123 -0
  104. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +137 -0
  105. package/frontend-vue/tests/table-view-refresh.test.ts +84 -0
  106. package/frontend-vue/tests/table-view-sources.test.ts +67 -0
  107. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  108. package/package.json +2 -2
  109. package/skills/dms/SKILL.md +14 -3
  110. package/skills/dms-pages/REFERENCE.md +17 -1
@@ -18,7 +18,10 @@ import {
18
18
  import { registerMetricCellTypes } from "./metricCells";
19
19
  import { registerTwoLineCellType } from "./twoLineCell";
20
20
  import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
21
- import StatusPill from "../../../components/status-pill/StatusPill.vue";
21
+ import StatusPill, {
22
+ type StatusPillVariant,
23
+ } from "../../../components/status-pill/StatusPill.vue";
24
+ import { isDmsTone } from "../../utils/tone";
22
25
  import IdentityCell from "../../components/table-view/IdentityCell.vue";
23
26
  import {
24
27
  firstNameOf,
@@ -453,9 +456,76 @@ const PILL_FILLED_CLASS =
453
456
  "shrink-0 border-(--dms-accent-fill) bg-(--dms-accent-fill) font-[650] text-(--dms-accent-on-fill)";
454
457
 
455
458
  /**
456
- * `pills`: a list (relation rows, select values, strings) as v2 role pills,
457
- * or a single filled pill when the row's `exclusive.field` is set (an owner's
458
- * crown). A bare id a relation could not resolve (a deleted row) is skipped.
459
+ * A pill the server styles per row. Mirror of `DefaultDisplays.PillItem` from
460
+ * `@antelopejs/interface-dms/base/table-view`.
461
+ */
462
+ interface PillItem {
463
+ label: string;
464
+ tone?: string;
465
+ variant?: string;
466
+ }
467
+
468
+ const PILL_VARIANTS = new Set<string>(["soft", "outline"]);
469
+ const DEFAULT_PILL_VARIANT: StatusPillVariant = "soft";
470
+
471
+ const pillVariantOf = (variant: string | undefined): StatusPillVariant =>
472
+ variant && PILL_VARIANTS.has(variant)
473
+ ? (variant as StatusPillVariant)
474
+ : DEFAULT_PILL_VARIANT;
475
+ const DEFAULT_PILL_TONE = "neutral";
476
+
477
+ // An object with a `label` but no `labelKey` field: a related row carries
478
+ // that field, which tells the two apart.
479
+ const isPillItem = (item: unknown, labelKey: string): item is PillItem =>
480
+ isObject(item) && typeof item.label === "string" && !(labelKey in item);
481
+
482
+ /**
483
+ * The pills of a list. A bare id is skipped next to the related rows it
484
+ * could not be resolved into; next to pill items it is a pill.
485
+ */
486
+ function pillsOf(
487
+ items: unknown[],
488
+ labelKey: string,
489
+ processI18n: (text: string) => string,
490
+ ): PillItem[] {
491
+ const hasRelatedRows = items.some(
492
+ (item) => isObject(item) && !isPillItem(item, labelKey),
493
+ );
494
+ return items.flatMap((item): PillItem[] => {
495
+ if (isPillItem(item, labelKey)) {
496
+ return [{ ...item, label: processI18n(item.label) }];
497
+ }
498
+ if (isObject(item)) {
499
+ const label = (item as Record<string, unknown>)[labelKey];
500
+ return label === undefined ? [] : [{ label: processI18n(String(label)) }];
501
+ }
502
+ return hasRelatedRows ? [] : [{ label: processI18n(String(item)) }];
503
+ });
504
+ }
505
+
506
+ /** A plain pill in the role-pill outline, a styled one as a status pill. */
507
+ function renderPill(pill: PillItem) {
508
+ if (!pill.tone && !pill.variant) {
509
+ return h(
510
+ "span",
511
+ { class: [PILL_CLASS, PILL_OUTLINE_CLASS, PILL_SHRINK_CLASS] },
512
+ [h("span", { class: "truncate" }, pill.label)],
513
+ );
514
+ }
515
+ return h(StatusPill, {
516
+ label: pill.label,
517
+ tone: pill.tone && isDmsTone(pill.tone) ? pill.tone : DEFAULT_PILL_TONE,
518
+ variant: pillVariantOf(pill.variant),
519
+ dot: "none",
520
+ class: PILL_SHRINK_CLASS,
521
+ });
522
+ }
523
+
524
+ /**
525
+ * `pills`: a list (relation rows, select values, strings, pill items) as v2
526
+ * role pills, a pill item in its own tone and variant, or a single filled
527
+ * pill when the row's `exclusive.field` is set (an owner's crown). A bare id
528
+ * a relation could not resolve (a deleted row) is skipped.
459
529
  */
460
530
  function renderPills(value: unknown, options: unknown, row: Row) {
461
531
  const { processI18n } = useTranslation();
@@ -468,17 +538,13 @@ function renderPills(value: unknown, options: unknown, row: Row) {
468
538
  processI18n(opts.exclusive.label),
469
539
  ]);
470
540
  }
471
- const labelKey = opts.labelKey ?? DEFAULT_RELATION_LABEL_KEY;
472
541
  const items = Array.isArray(value) ? value : value == null ? [] : [value];
473
- const hasObjects = items.some((item) => isObject(item));
474
- const labels = items.flatMap((item) => {
475
- if (isObject(item)) {
476
- const label = (item as Record<string, unknown>)[labelKey];
477
- return label === undefined ? [] : [processI18n(String(label))];
478
- }
479
- return hasObjects ? [] : [processI18n(String(item))];
480
- });
481
- if (labels.length === 0) {
542
+ const pills = pillsOf(
543
+ items,
544
+ opts.labelKey ?? DEFAULT_RELATION_LABEL_KEY,
545
+ processI18n,
546
+ );
547
+ if (pills.length === 0) {
482
548
  return opts.emptyLabel
483
549
  ? h(
484
550
  "span",
@@ -491,14 +557,11 @@ function renderPills(value: unknown, options: unknown, row: Row) {
491
557
  // few letters); the list's tooltip names them all.
492
558
  return h(
493
559
  "span",
494
- { class: "flex min-w-0 gap-1 overflow-hidden", title: labels.join(", ") },
495
- labels.map((label) =>
496
- h(
497
- "span",
498
- { class: [PILL_CLASS, PILL_OUTLINE_CLASS, PILL_SHRINK_CLASS] },
499
- [h("span", { class: "truncate" }, label)],
500
- ),
501
- ),
560
+ {
561
+ class: "flex min-w-0 gap-1 overflow-hidden",
562
+ title: pills.map((pill) => pill.label).join(", "),
563
+ },
564
+ pills.map(renderPill),
502
565
  );
503
566
  }
504
567
 
@@ -21,6 +21,12 @@ export interface TableNarrowingState {
21
21
  * search neither narrows the rows nor needs offering to clear.
22
22
  */
23
23
  searchApplies?: Ref<boolean>;
24
+ /**
25
+ * Filters the table applies without showing them (the page URL's
26
+ * `queryParamFilters`) narrow the rows: they count as filters, though no
27
+ * clear action reaches them.
28
+ */
29
+ hiddenFiltersApply?: Ref<boolean>;
24
30
  }
25
31
 
26
32
  /** What a clear would change. */
@@ -57,9 +63,11 @@ export const clearableTableFilters = (
57
63
  search: hasSearch(state),
58
64
  });
59
65
 
60
- /** The rows are narrowed by a search or a filter chip. */
66
+ /** The rows are narrowed by a search, a filter chip or a hidden filter. */
61
67
  export const isTableNarrowed = (state: TableNarrowingState): boolean =>
62
- hasSearch(state) || state.columnFilters.value.some(isFilterEffective);
68
+ hasSearch(state) ||
69
+ state.columnFilters.value.some(isFilterEffective) ||
70
+ !!state.hiddenFiltersApply?.value;
63
71
 
64
72
  /** i18n key of a clear action, naming what it clears. */
65
73
  export const clearTableFiltersLabelKey = (
@@ -0,0 +1,79 @@
1
+ import type { PaginationState } from "@tanstack/vue-table";
2
+ import { computed, ref, watch, type Ref } from "vue";
3
+
4
+ const FIRST_PAGE_INDEX = 0;
5
+ const PAGINATION_PREFERENCE = "pagination";
6
+ // The hidden filters the stored page was reached under, next to it.
7
+ const PAGINATION_SCOPE_PREFERENCE = "paginationScope";
8
+
9
+ /** The preference store the page is kept in (`usePreferences`). */
10
+ export interface PaginationPreferences {
11
+ getPreference: <V>(keyPath: string, defaultValue?: V) => V;
12
+ setPreference: (keyPath: string, value: unknown) => void;
13
+ }
14
+
15
+ export interface PersistedPaginationOptions {
16
+ preferences: PaginationPreferences;
17
+ /** The table's preference key of a setting. */
18
+ preferenceKey: (suffix: string) => string;
19
+ defaults: PaginationState;
20
+ /**
21
+ * What lists other rows without the user picking it: the hidden filters
22
+ * the page URL sets. A change of value lists from the first page.
23
+ */
24
+ scope: () => unknown;
25
+ }
26
+
27
+ const firstPage = (pagination: PaginationState): PaginationState => ({
28
+ ...pagination,
29
+ pageIndex: FIRST_PAGE_INDEX,
30
+ });
31
+
32
+ /**
33
+ * The table's page, kept in the user's preferences with the hidden filters
34
+ * it was reached under. A page restored under other hidden filters (a link
35
+ * that remounted the page with another `?status=`) starts at the first
36
+ * page, as does a change of their value while the table is shown; compared
37
+ * by value, so another query parameter changing keeps the page.
38
+ */
39
+ export function usePersistedPagination(
40
+ options: PersistedPaginationOptions,
41
+ ): Ref<PaginationState> {
42
+ const { preferences, preferenceKey } = options;
43
+ const scopeKey = computed(() => JSON.stringify(options.scope() ?? null));
44
+ const stored = preferences.getPreference<PaginationState>(
45
+ preferenceKey(PAGINATION_PREFERENCE),
46
+ options.defaults,
47
+ );
48
+ // A page stored before its scope was: kept, as it always was.
49
+ const storedScope = preferences.getPreference<string>(
50
+ preferenceKey(PAGINATION_SCOPE_PREFERENCE),
51
+ scopeKey.value,
52
+ );
53
+ const pagination = ref<PaginationState>(
54
+ storedScope === scopeKey.value ? stored : firstPage(stored),
55
+ );
56
+
57
+ // Synchronous, so the list is queried once, with both changes.
58
+ watch(
59
+ scopeKey,
60
+ () => {
61
+ if (pagination.value.pageIndex === FIRST_PAGE_INDEX) return;
62
+ pagination.value = firstPage(pagination.value);
63
+ },
64
+ { flush: "sync" },
65
+ );
66
+ watch(
67
+ [pagination, scopeKey],
68
+ ([value, scope]) => {
69
+ preferences.setPreference(preferenceKey(PAGINATION_PREFERENCE), value);
70
+ preferences.setPreference(
71
+ preferenceKey(PAGINATION_SCOPE_PREFERENCE),
72
+ scope,
73
+ );
74
+ },
75
+ { deep: true },
76
+ );
77
+
78
+ return pagination;
79
+ }
@@ -0,0 +1,94 @@
1
+ import type { ComputedRef } from "vue";
2
+ import type { TableViewListResponse } from "../../../composables/table-view/types";
3
+ import { useServerRenderedAsyncData } from "./useServerRenderedAsyncData";
4
+ import type { useTableRows } from "./useTableRows";
5
+ import { buildTableDataKey } from "./utils/tableQuery";
6
+
7
+ type Query = Record<string, unknown>;
8
+ type TableRows<T extends Record<string, unknown>> = ReturnType<
9
+ typeof useTableRows<T>
10
+ >;
11
+
12
+ /** What the list and the tab counters of a table view are read for. */
13
+ export interface TableListDataOptions<T extends Record<string, unknown>> {
14
+ componentId: string | undefined;
15
+ pageId: string | undefined;
16
+ rows: TableRows<T>;
17
+ query: ComputedRef<object>;
18
+ archiveQuery: ComputedRef<Query>;
19
+ /** A self-managed display (the kanban) reads its own rows. */
20
+ isSelfManaged: ComputedRef<boolean>;
21
+ }
22
+
23
+ /** One counter: its id and its list query, as the table view builds it. */
24
+ export interface TableCountRequest {
25
+ id: string;
26
+ query: object;
27
+ }
28
+
29
+ export interface TableCountsDataOptions<T extends Record<string, unknown>> {
30
+ componentId: string | undefined;
31
+ pageId: string | undefined;
32
+ rows: TableRows<T>;
33
+ queries: ComputedRef<TableCountRequest[]>;
34
+ archiveQuery: ComputedRef<Query>;
35
+ }
36
+
37
+ const EMPTY_LIST_RESULT: TableViewListResponse<never> = {
38
+ results: [],
39
+ total: 0,
40
+ offset: 0,
41
+ limit: 0,
42
+ };
43
+
44
+ /**
45
+ * The page of rows a table view shows. The key names the source URL: a
46
+ * navigation that remounts the page while the previous one is still on screen
47
+ * binds to the cached entry of its key, so another URL needs another key to be
48
+ * requested at all, and the watch covers a page kept mounted.
49
+ */
50
+ export function useTableListData<T extends Record<string, unknown>>(
51
+ options: TableListDataOptions<T>,
52
+ ) {
53
+ const { rows, query, archiveQuery, isSelfManaged } = options;
54
+ const key = buildTableDataKey({
55
+ componentId: options.componentId,
56
+ pageId: options.pageId,
57
+ query: query.value,
58
+ archiveQuery: archiveQuery.value,
59
+ isSelfManaged: isSelfManaged.value,
60
+ sourceUrl: rows.sourceUrl.value,
61
+ });
62
+ return useServerRenderedAsyncData(
63
+ key,
64
+ (): Promise<TableViewListResponse<T>> =>
65
+ isSelfManaged.value
66
+ ? Promise.resolve(EMPTY_LIST_RESULT)
67
+ : rows.list({ ...query.value, ...archiveQuery.value }),
68
+ { watch: [query, archiveQuery, isSelfManaged, rows.sourceUrl] },
69
+ );
70
+ }
71
+
72
+ /**
73
+ * The counters of a table view's tabs and views, keyed by id; `null` until
74
+ * they arrive. Keyed by source URL like the list (see useTableListData).
75
+ */
76
+ export function useTableCountsData<T extends Record<string, unknown>>(
77
+ options: TableCountsDataOptions<T>,
78
+ ) {
79
+ const { rows, queries, archiveQuery } = options;
80
+ const sourceSuffix = rows.sourceUrl.value ? `-${rows.sourceUrl.value}` : "";
81
+ return useServerRenderedAsyncData<Record<string, number>>(
82
+ `table-view-${options.componentId}-${options.pageId}-tab-counts${sourceSuffix}`,
83
+ async () => {
84
+ if (queries.value.length === 0) return {};
85
+ return await rows.countBatch(
86
+ queries.value.map(({ id, query }) => ({
87
+ id,
88
+ query: { ...query, ...archiveQuery.value },
89
+ })),
90
+ );
91
+ },
92
+ { watch: [queries, archiveQuery, rows.sourceUrl] },
93
+ );
94
+ }
@@ -1,9 +1,14 @@
1
1
  import { get } from "@nuxt/ui/runtime/utils/index.js";
2
+ import { computed } from "vue";
2
3
  import type {
3
4
  TableViewColumn,
4
5
  TableViewListResponse,
5
6
  TableViewSourceConfig,
6
7
  } from "../../../composables/table-view/types";
8
+ import {
9
+ type ReplaceUrlVariablesContext,
10
+ resolveUrlVariables,
11
+ } from "../../utils/urlVariables";
7
12
 
8
13
  type Query = Record<string, unknown>;
9
14
 
@@ -20,6 +25,11 @@ export interface TableRowsOptions {
20
25
  /** A `TableView.fromSource` table's route, instead of a controller. */
21
26
  source?: TableViewSourceConfig;
22
27
  columns: TableViewColumn[];
28
+ /**
29
+ * The page route a source's `fetchUrl` names: its parameters and query,
30
+ * read again on every request.
31
+ */
32
+ route?: () => ReplaceUrlVariablesContext;
23
33
  }
24
34
 
25
35
  const FILTER_PREFIX = "filter_";
@@ -27,6 +37,7 @@ const SEARCH_KEY = "search";
27
37
  const SORT_KEYS = ["sortKey", "sortDirection"];
28
38
  const PAGE_KEYS = ["offset", "limit"];
29
39
  const DESCENDING = "desc";
40
+ const NO_ROUTE_VALUES: ReplaceUrlVariablesContext = { routeQuery: {} };
30
41
 
31
42
  type SourceCapabilities = TableViewSourceConfig["capabilities"];
32
43
 
@@ -107,26 +118,51 @@ export function processSourceRows<T extends Record<string, unknown>>(
107
118
  };
108
119
  }
109
120
 
121
+ /** A page with no rows: what a source shows while its URL is unresolved. */
122
+ function emptySourcePage<T>(query: Query): TableViewListResponse<T> {
123
+ return {
124
+ results: [],
125
+ total: 0,
126
+ offset: Number(query.offset) || 0,
127
+ limit: Number(query.limit) || 0,
128
+ };
129
+ }
130
+
110
131
  /**
111
132
  * Where a table view reads its rows: its data controller's routes, or the
112
133
  * route of a `TableView.fromSource` table — which answers `{ results, total }`
113
134
  * for the part of the query it handles. A route answering every row leaves
114
135
  * the rest to the browser; a route answering one page is taken as it is.
136
+ *
137
+ * A source's `{{params.X}}` and `{{query.X}}` tokens are filled from `route`;
138
+ * while one has no value nothing is requested and the list is empty.
139
+ * `sourceUrl` is the URL requested, for the caller to list again when it
140
+ * changes.
115
141
  */
116
142
  export function useTableRows<T extends Record<string, unknown>>(
117
143
  options: TableRowsOptions,
118
144
  ) {
119
145
  const { api, location, source, columns } = options;
120
146
 
147
+ const sourceUrl = computed(() =>
148
+ source
149
+ ? resolveUrlVariables(
150
+ source.fetchUrl,
151
+ options.route?.() ?? NO_ROUTE_VALUES,
152
+ )
153
+ : undefined,
154
+ );
155
+
121
156
  const listFromSource = async (
122
157
  sourceConfig: TableViewSourceConfig,
123
158
  query: Query,
124
159
  ): Promise<TableViewListResponse<T>> => {
160
+ const url = sourceUrl.value;
161
+ if (!url) return emptySourcePage(query);
125
162
  const { capabilities } = sourceConfig;
126
- const response = await api<{ results: T[]; total: number }>(
127
- sourceConfig.fetchUrl,
128
- { query: sourceRouteQuery(query, capabilities) },
129
- );
163
+ const response = await api<{ results: T[]; total: number }>(url, {
164
+ query: sourceRouteQuery(query, capabilities),
165
+ });
130
166
  if (capabilities.paginate) {
131
167
  return {
132
168
  ...response,
@@ -171,5 +207,5 @@ export function useTableRows<T extends Record<string, unknown>>(
171
207
  ? Promise.resolve(undefined)
172
208
  : api<T>(`${location}/get`, { query: { id } });
173
209
 
174
- return { isSource: !!source, list, countBatch, getRow };
210
+ return { isSource: !!source, sourceUrl, list, countBatch, getRow };
175
211
  }
@@ -17,6 +17,8 @@ interface TableDataKeyConfig {
17
17
  query: unknown;
18
18
  archiveQuery: unknown;
19
19
  isSelfManaged: boolean;
20
+ /** The resolved URL of a `TableView.fromSource` table. */
21
+ sourceUrl?: string;
20
22
  }
21
23
 
22
24
  const NO_VALUE_COMPARE_MODES = new Set(["is_empty", "is_not_empty"]);
@@ -72,4 +74,5 @@ export const buildTableDataKey = (config: TableDataKeyConfig): string =>
72
74
  query: config.query,
73
75
  archiveQuery: config.archiveQuery,
74
76
  isSelfManaged: config.isSelfManaged,
77
+ sourceUrl: config.sourceUrl,
75
78
  })}`;
@@ -35,7 +35,7 @@ import {
35
35
  placeholder as placeholderText,
36
36
  tooltips,
37
37
  } from "@codemirror/view";
38
- import { tags } from "@lezer/highlight";
38
+ import { CODE_PALETTE } from "./codePalette";
39
39
 
40
40
  /** A suggestion of the autocomplete (interface-dms `CodeCompletion`). */
41
41
  export interface CodeEditorCompletion {
@@ -84,18 +84,7 @@ const LANGUAGES: Record<string, () => Extension> = {
84
84
  javascript: () => javascript(),
85
85
  };
86
86
 
87
- // The query console's palette, read from the theme's variables so light and
88
- // dark follow the app.
89
- const HIGHLIGHT = HighlightStyle.define([
90
- { tag: [tags.keyword, tags.operatorKeyword], color: "var(--ui-primary)" },
91
- { tag: [tags.string, tags.special(tags.string)], color: "var(--ui-success)" },
92
- { tag: [tags.number, tags.bool, tags.null], color: "var(--ui-warning)" },
93
- { tag: [tags.comment], color: "var(--ui-text-dimmed)", fontStyle: "italic" },
94
- { tag: [tags.propertyName, tags.attributeName], color: "var(--ui-info)" },
95
- { tag: [tags.function(tags.variableName)], color: "var(--ui-primary)" },
96
- { tag: [tags.tagName], color: "var(--ui-primary)" },
97
- { tag: [tags.invalid], color: "var(--ui-error)" },
98
- ]);
87
+ const HIGHLIGHT = HighlightStyle.define(CODE_PALETTE);
99
88
 
100
89
  const THEME = EditorView.theme({
101
90
  "&": {
@@ -0,0 +1,59 @@
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";
13
+ import type { Parser } from "@lezer/common";
14
+ import { highlightTree, tagHighlighter } from "@lezer/highlight";
15
+ import { CODE_PALETTE, type CodeTokenStyle } from "./codePalette";
16
+ import { shellParser } from "./shellParser";
17
+
18
+ /** A run of code drawn in one style; unstyled text has no `style`. */
19
+ export interface CodeToken {
20
+ text: string;
21
+ style?: CodeTokenStyle;
22
+ }
23
+
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,
31
+ };
32
+
33
+ // Each palette entry's position is its class, read back into its style.
34
+ const HIGHLIGHTER = tagHighlighter(
35
+ CODE_PALETTE.map((entry, index) => ({ tag: entry.tag, class: `${index}` })),
36
+ );
37
+
38
+ function styleOf(classes: string): CodeTokenStyle {
39
+ const { color, fontStyle } = CODE_PALETTE[Number(classes.split(" ")[0])];
40
+ return { color, fontStyle };
41
+ }
42
+
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 }];
50
+ const tokens: CodeToken[] = [];
51
+ let cursor = 0;
52
+ highlightTree(parser.parse(code), HIGHLIGHTER, (from, to, classes) => {
53
+ if (from > cursor) tokens.push({ text: code.slice(cursor, from) });
54
+ tokens.push({ text: code.slice(from, to), style: styleOf(classes) });
55
+ cursor = to;
56
+ });
57
+ if (cursor < code.length) tokens.push({ text: code.slice(cursor) });
58
+ return tokens;
59
+ }
@@ -0,0 +1,36 @@
1
+ // The languages the code views name, shared by the editor's status line and
2
+ // the read-only snippets. Kept apart from the highlighters so a view can
3
+ // label its language before CodeMirror has loaded.
4
+
5
+ /** The language a snippet is shown as written in. */
6
+ export const PLAIN_CODE_LANGUAGE = "text";
7
+
8
+ const LANGUAGE_ALIASES: Record<string, string> = {
9
+ js: "javascript",
10
+ ts: "typescript",
11
+ bash: "shell",
12
+ sh: "shell",
13
+ plain: PLAIN_CODE_LANGUAGE,
14
+ };
15
+
16
+ // The label of each language, as the status line and the snippet head show it.
17
+ const CODE_LANGUAGE_LABELS: Record<string, string> = {
18
+ json: "JSON",
19
+ sql: "SQL",
20
+ html: "HTML",
21
+ javascript: "JavaScript",
22
+ typescript: "TypeScript",
23
+ shell: "Shell",
24
+ text: "Text",
25
+ };
26
+
27
+ /** The language an alias (`bash`, `ts`, `plain`) stands for; `text` when unset. */
28
+ export function resolveCodeLanguage(language: string | undefined): string {
29
+ const name = language?.trim().toLowerCase() || PLAIN_CODE_LANGUAGE;
30
+ return LANGUAGE_ALIASES[name] ?? name;
31
+ }
32
+
33
+ /** The label of a language; an unknown one is shown as written. */
34
+ export function codeLanguageLabel(language: string): string {
35
+ return CODE_LANGUAGE_LABELS[language] ?? language;
36
+ }
@@ -0,0 +1,32 @@
1
+ import type { Tag } from "@lezer/highlight";
2
+ import { tags } from "@lezer/highlight";
3
+
4
+ /**
5
+ * How one kind of token is drawn: its colour, and italic for comments. A type
6
+ * rather than an interface, so it binds to a `style` attribute as it is.
7
+ */
8
+ export type CodeTokenStyle = {
9
+ color: string;
10
+ fontStyle?: string;
11
+ };
12
+
13
+ /** The tokens one entry of the palette colours. */
14
+ export interface CodePaletteEntry extends CodeTokenStyle {
15
+ tag: Tag | Tag[];
16
+ }
17
+
18
+ /**
19
+ * The query console's palette, read from the theme's variables so light and
20
+ * dark follow the app: the editor and the read-only snippets draw code alike.
21
+ */
22
+ export const CODE_PALETTE: CodePaletteEntry[] = [
23
+ { tag: [tags.keyword, tags.operatorKeyword], color: "var(--ui-primary)" },
24
+ { tag: [tags.string, tags.special(tags.string)], color: "var(--ui-success)" },
25
+ { tag: [tags.number, tags.bool, tags.null], color: "var(--ui-warning)" },
26
+ { tag: [tags.comment], color: "var(--ui-text-dimmed)", fontStyle: "italic" },
27
+ { tag: [tags.propertyName, tags.attributeName], color: "var(--ui-info)" },
28
+ { tag: [tags.function(tags.variableName)], color: "var(--ui-primary)" },
29
+ { tag: [tags.tagName], color: "var(--ui-primary)" },
30
+ { tag: [tags.invalid], color: "var(--ui-error)" },
31
+ { tag: [tags.special(tags.variableName)], color: "var(--ui-warning)" },
32
+ ];
@@ -0,0 +1,55 @@
1
+ import type { StreamParser, StringStream } from "@codemirror/language";
2
+
3
+ // A shell snippet as read for highlighting only: the command of each
4
+ // pipeline step, its flags, quoted strings, variables and comments.
5
+
6
+ interface ShellState {
7
+ /** The next word is a command: at a line start, after `|`, `&&` or `;`. */
8
+ isAtCommand: boolean;
9
+ /** The line ends with `\`: the next one carries on the same command. */
10
+ isContinued: boolean;
11
+ }
12
+
13
+ const COMMENT = /^#.*/;
14
+ const VARIABLE = /^\$(?:\{[^}]*\}|\w+|[?!#$@*-])/;
15
+ const STRING = /^(?:"(?:[^"\\]|\\.)*"?|'[^']*'?)/;
16
+ const FLAG = /^--?[\w-]+/;
17
+ const SEPARATOR = /^(?:\|\|?|&&?|;)/;
18
+ const WORD = /^[^\s|&;"'$]+/;
19
+ const CONTINUATION = /^\\$/;
20
+
21
+ function readToken(stream: StringStream, state: ShellState): string | null {
22
+ if (stream.match(COMMENT)) return "comment";
23
+ if (stream.match(STRING)) return "string";
24
+ if (stream.match(VARIABLE)) return "variableName.special";
25
+ if (stream.match(SEPARATOR)) {
26
+ state.isAtCommand = true;
27
+ return "operator";
28
+ }
29
+ if (stream.match(FLAG)) return "attributeName";
30
+ if (stream.match(CONTINUATION)) {
31
+ state.isContinued = true;
32
+ return null;
33
+ }
34
+ if (!stream.match(WORD)) {
35
+ stream.next();
36
+ return null;
37
+ }
38
+ const isCommand = state.isAtCommand;
39
+ state.isAtCommand = false;
40
+ return isCommand ? "keyword" : null;
41
+ }
42
+
43
+ export const shellParser: StreamParser<ShellState> = {
44
+ name: "shell",
45
+ startState: () => ({ isAtCommand: true, isContinued: false }),
46
+ copyState: (state) => ({ ...state }),
47
+ token(stream, state) {
48
+ if (stream.sol()) {
49
+ state.isAtCommand = !state.isContinued;
50
+ state.isContinued = false;
51
+ }
52
+ if (stream.eatSpace()) return null;
53
+ return readToken(stream, state);
54
+ },
55
+ };