@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.
- package/CHANGELOG.md +47 -0
- package/dist/hooks/tenant-instances.d.ts +1 -0
- package/dist/hooks/tenant-instances.js +13 -0
- package/dist/hooks/tenant-instances.js.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/test/unit/interfaces/dms/page-topics.test.js +67 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js +1 -0
- package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js +46 -0
- package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +11 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +31 -0
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
- package/dist/test/unit/utils/tenant-instances.test.d.ts +1 -0
- package/dist/test/unit/utils/tenant-instances.test.js +53 -0
- package/dist/test/unit/utils/tenant-instances.test.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/tenant-instances.d.ts +2 -0
- package/dist/utils/tenant-instances.js +38 -0
- package/dist/utils/tenant-instances.js.map +1 -0
- package/dist/validation/auth.schema.d.ts +2 -2
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +4 -0
- package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +35 -23
- package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockActions.vue +34 -6
- package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +26 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +3 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/MonoCell.vue +3 -2
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +24 -13
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +12 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +40 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +15 -4
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +85 -22
- package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +10 -2
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +79 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +94 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +41 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/tableQuery.ts +3 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/codeEditor.ts +2 -13
- package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +59 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/codeLanguages.ts +36 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/codePalette.ts +32 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +55 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +77 -20
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +15 -4
- package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +111 -25
- package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +95 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +18 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +8 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +21 -9
- package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +8 -0
- package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +97 -0
- package/frontend-vue/layers/dms-ui/app/components/empty-state/EmptyState.vue +13 -4
- package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +14 -3
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +2 -8
- package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +19 -0
- package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +54 -48
- package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +24 -9
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +4 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +66 -74
- package/frontend-vue/layers/dms-ui/app/components/top-list/internal/TopListRow.vue +4 -3
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +5 -2
- package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +2 -29
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -1
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +4 -0
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +2 -2
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +8 -5
- package/frontend-vue/tests/activity-feed-days.test.ts +26 -12
- package/frontend-vue/tests/banner-block-fetch.test.ts +162 -0
- package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +100 -0
- package/frontend-vue/tests/block-items-period-realtime.test.ts +147 -0
- package/frontend-vue/tests/block-route-params.test.ts +48 -0
- package/frontend-vue/tests/code-block.test.ts +155 -0
- package/frontend-vue/tests/code-highlight.test.ts +72 -0
- package/frontend-vue/tests/composed-text-blocks.test.ts +5 -0
- package/frontend-vue/tests/composed-text-more-blocks.test.ts +391 -0
- package/frontend-vue/tests/drawer-target-direction.test.ts +205 -0
- package/frontend-vue/tests/empty-state-late-slots.test.ts +53 -0
- package/frontend-vue/tests/flow-canvas-delete-badge.test.ts +88 -0
- package/frontend-vue/tests/form-redirect-placeholders.test.ts +11 -0
- package/frontend-vue/tests/key-value-list-copy.test.ts +114 -0
- package/frontend-vue/tests/master-detail-layout.test.ts +53 -0
- package/frontend-vue/tests/mono-cell-truncate.test.ts +50 -0
- package/frontend-vue/tests/nav-card-grid-empty-category.test.ts +1 -0
- package/frontend-vue/tests/page-fill-height.test.ts +20 -3
- package/frontend-vue/tests/route-param-occurrences.test.ts +9 -0
- package/frontend-vue/tests/table-actions-search-label.test.ts +147 -0
- package/frontend-vue/tests/table-clear-filters.test.ts +22 -0
- package/frontend-vue/tests/table-empty-body-viewport.test.ts +25 -0
- package/frontend-vue/tests/table-metric-cells.test.ts +22 -0
- package/frontend-vue/tests/table-pills-cell.test.ts +126 -0
- package/frontend-vue/tests/table-source-navigation.test.ts +123 -0
- package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +137 -0
- package/frontend-vue/tests/table-view-refresh.test.ts +84 -0
- package/frontend-vue/tests/table-view-sources.test.ts +67 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +14 -3
- package/skills/dms-pages/REFERENCE.md +17 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The `pills` cell draws a list of strings and related rows as outline role
|
|
4
|
+
* pills, and the pill items a server styles per row in their own tone and
|
|
5
|
+
* variant.
|
|
6
|
+
*/
|
|
7
|
+
import { createApp, defineComponent, h, type App, type VNodeChild } from "vue";
|
|
8
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import type { DataType } from "../layers/dms-core/app/composables/data-types/useDataType";
|
|
10
|
+
import { isObject } from "../layers/dms-core/app/utils/type-check";
|
|
11
|
+
|
|
12
|
+
const stub = { default: { render: () => null } };
|
|
13
|
+
vi.mock("@nuxt/ui/components/Avatar.vue", () => stub);
|
|
14
|
+
vi.mock("@nuxt/ui/components/Badge.vue", () => stub);
|
|
15
|
+
vi.mock("@nuxt/ui/components/Link.vue", () => stub);
|
|
16
|
+
vi.mock("@nuxt/ui/runtime/vue/components/Icon.vue", () => stub);
|
|
17
|
+
vi.mock("../layers/dms-ui/app/composables/useFileReadUrls", () => ({
|
|
18
|
+
useFileReadUrls: () => ({ getUrl: () => undefined, resolve: () => {} }),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
type Formatter = (
|
|
22
|
+
value: unknown,
|
|
23
|
+
locale: string,
|
|
24
|
+
options?: unknown,
|
|
25
|
+
row?: Record<string, unknown>,
|
|
26
|
+
) => VNodeChild;
|
|
27
|
+
|
|
28
|
+
const registered = new Map<string, DataType["formatter"]>();
|
|
29
|
+
|
|
30
|
+
// Auto-imports of the layer the default types are registered with.
|
|
31
|
+
function stubLayerGlobals() {
|
|
32
|
+
vi.stubGlobal("h", h);
|
|
33
|
+
vi.stubGlobal("isObject", isObject);
|
|
34
|
+
for (const name of [
|
|
35
|
+
"formatNumber",
|
|
36
|
+
"formatPrice",
|
|
37
|
+
"formatPercentage",
|
|
38
|
+
"formatTimeSpan",
|
|
39
|
+
"formatDate",
|
|
40
|
+
"getBooleanLabel",
|
|
41
|
+
]) {
|
|
42
|
+
vi.stubGlobal(name, () => "");
|
|
43
|
+
}
|
|
44
|
+
vi.stubGlobal("useTranslation", () => ({
|
|
45
|
+
processI18n: (text: string) => text.replace(/^\$/, "i18n:"),
|
|
46
|
+
}));
|
|
47
|
+
vi.stubGlobal("useDataTypes", () => ({
|
|
48
|
+
registerDataType: (dataType: DataType) =>
|
|
49
|
+
registered.set(dataType.id, dataType.formatter),
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let app: App | undefined;
|
|
54
|
+
|
|
55
|
+
function renderPills(value: unknown, options: unknown = {}): HTMLElement {
|
|
56
|
+
const content = (registered.get("pills")!.default as Formatter)(
|
|
57
|
+
value,
|
|
58
|
+
"en",
|
|
59
|
+
options,
|
|
60
|
+
{},
|
|
61
|
+
);
|
|
62
|
+
const container = document.createElement("div");
|
|
63
|
+
app = createApp(defineComponent({ setup: () => () => content }));
|
|
64
|
+
app.component("UIcon", defineComponent({ render: () => h("i") }));
|
|
65
|
+
app.mount(container);
|
|
66
|
+
return container;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const pillsOf = (cell: HTMLElement) =>
|
|
70
|
+
[...cell.firstElementChild!.children].map((pill) => ({
|
|
71
|
+
label: pill.textContent,
|
|
72
|
+
className: pill.className,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
stubLayerGlobals();
|
|
77
|
+
const { registerDefaultDataTypes } = await import(
|
|
78
|
+
"../layers/dms-ui/app/build/composables/data-types/registerDefaults"
|
|
79
|
+
);
|
|
80
|
+
registerDefaultDataTypes();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
app?.unmount();
|
|
85
|
+
app = undefined;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("pills cell", () => {
|
|
89
|
+
it("draws strings and related rows as outline role pills, as before", () => {
|
|
90
|
+
const strings = pillsOf(renderPills(["$roles.admin", "Billing"]));
|
|
91
|
+
expect(strings.map((pill) => pill.label)).toEqual([
|
|
92
|
+
"i18n:roles.admin",
|
|
93
|
+
"Billing",
|
|
94
|
+
]);
|
|
95
|
+
expect(strings[0]!.className).toContain("border-accented");
|
|
96
|
+
app?.unmount();
|
|
97
|
+
|
|
98
|
+
const rows = pillsOf(renderPills([{ _id: "r1", name: "Editor" }, "r2"]));
|
|
99
|
+
expect(rows.map((pill) => pill.label)).toEqual(["Editor"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("draws a pill item in its tone and variant, beside plain strings", () => {
|
|
103
|
+
const pills = pillsOf(
|
|
104
|
+
renderPills([
|
|
105
|
+
{ label: "EN", tone: "primary", variant: "soft" },
|
|
106
|
+
{ label: "DE", variant: "outline" },
|
|
107
|
+
{ label: "IT", tone: "success" },
|
|
108
|
+
"ES",
|
|
109
|
+
]),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(pills.map((pill) => pill.label)).toEqual(["EN", "DE", "IT", "ES"]);
|
|
113
|
+
expect(pills[0]!.className).toContain("text-primary");
|
|
114
|
+
expect(pills[0]!.className).toContain("bg-(--dms-accent-tint)");
|
|
115
|
+
expect(pills[1]!.className).toContain("border");
|
|
116
|
+
expect(pills[1]!.className).toContain("bg-transparent");
|
|
117
|
+
expect(pills[2]!.className).toContain("bg-success/12");
|
|
118
|
+
expect(pills[3]!.className).toContain("border-accented");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("draws a pill item without a tone nor a variant as a plain string", () => {
|
|
122
|
+
const [pill] = pillsOf(renderPills([{ label: "FR" }]));
|
|
123
|
+
expect(pill!.label).toBe("FR");
|
|
124
|
+
expect(pill!.className).toContain("border-accented");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A `TableView.fromSource` table whose `fetchUrl` reads the page query lists
|
|
4
|
+
* again when a link changes that query, through the runtime's real async data
|
|
5
|
+
* cache: whether the link keeps the page mounted (the watch) or remounts it
|
|
6
|
+
* (a fresh instance binding to the cached entry of its key).
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
computed,
|
|
10
|
+
createApp,
|
|
11
|
+
defineComponent,
|
|
12
|
+
h,
|
|
13
|
+
nextTick,
|
|
14
|
+
type App,
|
|
15
|
+
} from "vue";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
import type { DmsFrontendRuntime } from "#dms/frontend-module";
|
|
18
|
+
import type { TableViewColumn } from "../layers/dms-ui/app/composables/table-view/types";
|
|
19
|
+
|
|
20
|
+
const engine = await import("#dms/frontend-module");
|
|
21
|
+
vi.stubGlobal("useDmsAsyncData", engine.useDmsAsyncData);
|
|
22
|
+
vi.stubGlobal("useDmsLazyAsyncData", engine.useDmsLazyAsyncData);
|
|
23
|
+
|
|
24
|
+
const { useTableRows } = await import(
|
|
25
|
+
"../layers/dms-ui/app/build/composables/table-view/useTableRows"
|
|
26
|
+
);
|
|
27
|
+
const { useTableCountsData, useTableListData } = await import(
|
|
28
|
+
"../layers/dms-ui/app/build/composables/table-view/useTableListData"
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const FETCH_URL = "/api/log/route?route={{query.route}}";
|
|
32
|
+
const COLUMNS = [{ id: "path", accessorKey: "path" }] as TableViewColumn[];
|
|
33
|
+
const TAB_QUERIES = [{ id: "get", query: { filter_method: "is:GET" } }];
|
|
34
|
+
const ROUTE_VALUE = "GET /api/search?q=shoes&page=2";
|
|
35
|
+
const ENCODED_URL =
|
|
36
|
+
"/api/log/route?route=GET%20%2Fapi%2Fsearch%3Fq%3Dshoes%26page%3D2";
|
|
37
|
+
|
|
38
|
+
type Api = Parameters<typeof useTableRows>[0]["api"];
|
|
39
|
+
|
|
40
|
+
const requests: string[] = [];
|
|
41
|
+
const api = (async (url: string) => {
|
|
42
|
+
requests.push(url);
|
|
43
|
+
return { results: [{ _id: "a", path: "/api/search" }], total: 1 };
|
|
44
|
+
}) as unknown as Api;
|
|
45
|
+
|
|
46
|
+
let runtime: DmsFrontendRuntime;
|
|
47
|
+
const mounted: App[] = [];
|
|
48
|
+
|
|
49
|
+
const SourceTable = defineComponent({
|
|
50
|
+
setup() {
|
|
51
|
+
const route = runtime.route;
|
|
52
|
+
const rows = useTableRows({
|
|
53
|
+
api,
|
|
54
|
+
location: FETCH_URL,
|
|
55
|
+
source: { fetchUrl: FETCH_URL, capabilities: { filter: true } },
|
|
56
|
+
columns: COLUMNS,
|
|
57
|
+
route: () => ({ routeParams: {}, routeQuery: route.query }),
|
|
58
|
+
});
|
|
59
|
+
const archiveQuery = computed(() => ({}));
|
|
60
|
+
const keys = { componentId: "requests", pageId: "sources", rows };
|
|
61
|
+
void useTableListData({
|
|
62
|
+
...keys,
|
|
63
|
+
query: computed(() => ({ offset: 0, limit: 10 })),
|
|
64
|
+
archiveQuery,
|
|
65
|
+
isSelfManaged: computed(() => false),
|
|
66
|
+
});
|
|
67
|
+
void useTableCountsData({
|
|
68
|
+
...keys,
|
|
69
|
+
queries: computed(() => TAB_QUERIES),
|
|
70
|
+
archiveQuery,
|
|
71
|
+
});
|
|
72
|
+
return () => h("div");
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function mountTable(): void {
|
|
77
|
+
const app = createApp(SourceTable);
|
|
78
|
+
engine.provideDmsFrontendRuntime(app, runtime);
|
|
79
|
+
app.mount(document.createElement("div"));
|
|
80
|
+
mounted.push(app);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const settle = async () => {
|
|
84
|
+
await nextTick();
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
requests.length = 0;
|
|
90
|
+
runtime = engine.createDmsFrontendRuntime();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("a source table following the page query", () => {
|
|
98
|
+
it("requests nothing while the query value is missing", async () => {
|
|
99
|
+
mountTable();
|
|
100
|
+
await settle();
|
|
101
|
+
expect(requests).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("lists again when the page stays mounted", async () => {
|
|
105
|
+
mountTable();
|
|
106
|
+
await settle();
|
|
107
|
+
runtime.route.query = { route: ROUTE_VALUE };
|
|
108
|
+
await settle();
|
|
109
|
+
expect(requests).toEqual([ENCODED_URL, ENCODED_URL]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// An Inertia link without preserveState remounts the page: the new table
|
|
113
|
+
// reads the new query from its first render, so its URL never changes.
|
|
114
|
+
it("lists again when the link remounts the page", async () => {
|
|
115
|
+
mountTable();
|
|
116
|
+
await settle();
|
|
117
|
+
mounted.pop()?.unmount();
|
|
118
|
+
runtime.route.query = { route: ROUTE_VALUE };
|
|
119
|
+
mountTable();
|
|
120
|
+
await settle();
|
|
121
|
+
expect(requests).toEqual([ENCODED_URL, ENCODED_URL]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { computed, nextTick, readonly, ref, watch, type Ref } from "vue";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { LocationQuery } from "vue-router";
|
|
4
|
+
import { useAccumulatedPages } from "../layers/dms-ui/app/build/composables/table-view/useAccumulatedPages";
|
|
5
|
+
import { usePersistedPagination } from "../layers/dms-ui/app/build/composables/table-view/usePersistedPagination";
|
|
6
|
+
import { usePreferences } from "../layers/dms-core/app/composables/user/usePreferences";
|
|
7
|
+
|
|
8
|
+
const PAGE_SIZE = 10;
|
|
9
|
+
const THIRD_PAGE = 2;
|
|
10
|
+
|
|
11
|
+
// What a table view derives from `queryParamFilters: { status: { field: "status" } }`.
|
|
12
|
+
const hiddenFiltersOf = (query: () => LocationQuery) =>
|
|
13
|
+
computed(() =>
|
|
14
|
+
query().status === undefined
|
|
15
|
+
? []
|
|
16
|
+
: [{ accessorKey: "status", mode: "is", value: query().status }],
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const TABLE_KEY = "tables.tasks.query-filters";
|
|
20
|
+
const preferenceKey = (suffix: string) => `${TABLE_KEY}.${suffix}`;
|
|
21
|
+
|
|
22
|
+
// The `user-preferences` cookie, surviving the remounts of a page.
|
|
23
|
+
let cookie: Ref<Record<string, unknown>>;
|
|
24
|
+
|
|
25
|
+
// What a table view does on mount: its page, read back from the cookie,
|
|
26
|
+
// under the hidden filters the URL sets.
|
|
27
|
+
const mountTable = (query: Ref<LocationQuery>) => {
|
|
28
|
+
const hiddenFilters = hiddenFiltersOf(() => query.value);
|
|
29
|
+
const pagination = usePersistedPagination({
|
|
30
|
+
preferences: usePreferences(),
|
|
31
|
+
preferenceKey,
|
|
32
|
+
defaults: { pageIndex: 0, pageSize: PAGE_SIZE },
|
|
33
|
+
scope: () => [hiddenFilters.value, []],
|
|
34
|
+
});
|
|
35
|
+
return { hiddenFilters, pagination };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
cookie = ref({});
|
|
40
|
+
vi.stubGlobal("useDmsCookie", () => cookie);
|
|
41
|
+
vi.stubGlobal("ref", ref);
|
|
42
|
+
vi.stubGlobal("readonly", readonly);
|
|
43
|
+
vi.stubGlobal("computed", computed);
|
|
44
|
+
vi.stubGlobal("watch", watch);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.unstubAllGlobals();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("a hidden filter changed by the URL", () => {
|
|
52
|
+
it("sends the pager back to page 1", () => {
|
|
53
|
+
const query = ref<LocationQuery>({ status: "pending" });
|
|
54
|
+
const { pagination } = mountTable(query);
|
|
55
|
+
pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
|
|
56
|
+
|
|
57
|
+
query.value = { status: "cancelled" };
|
|
58
|
+
|
|
59
|
+
expect(pagination.value).toEqual({ pageIndex: 0, pageSize: PAGE_SIZE });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps the page when another query parameter changes", () => {
|
|
63
|
+
const query = ref<LocationQuery>({ status: "pending" });
|
|
64
|
+
const { pagination } = mountTable(query);
|
|
65
|
+
pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
|
|
66
|
+
|
|
67
|
+
query.value = { status: "pending", record: "42" };
|
|
68
|
+
|
|
69
|
+
expect(pagination.value.pageIndex).toBe(THIRD_PAGE);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("sends the pager back to page 1 when the filter goes away", () => {
|
|
73
|
+
const query = ref<LocationQuery>({ status: "pending" });
|
|
74
|
+
const { pagination } = mountTable(query);
|
|
75
|
+
pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
|
|
76
|
+
|
|
77
|
+
query.value = {};
|
|
78
|
+
|
|
79
|
+
expect(pagination.value.pageIndex).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("starts at page 1 when a link remounts the page under another value", async () => {
|
|
83
|
+
const first = mountTable(ref<LocationQuery>({ status: "pending" }));
|
|
84
|
+
first.pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
|
|
85
|
+
await nextTick();
|
|
86
|
+
|
|
87
|
+
const other = mountTable(ref<LocationQuery>({ status: "completed" }));
|
|
88
|
+
expect(other.pagination.value).toEqual({
|
|
89
|
+
pageIndex: 0,
|
|
90
|
+
pageSize: PAGE_SIZE,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("restores the page of a remount under the same value", async () => {
|
|
95
|
+
const first = mountTable(ref<LocationQuery>({ status: "pending" }));
|
|
96
|
+
first.pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
|
|
97
|
+
await nextTick();
|
|
98
|
+
|
|
99
|
+
const again = mountTable(ref<LocationQuery>({ status: "pending" }));
|
|
100
|
+
expect(again.pagination.value.pageIndex).toBe(THIRD_PAGE);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("keeps a page stored before its hidden filters were", () => {
|
|
104
|
+
cookie.value = {
|
|
105
|
+
tables: {
|
|
106
|
+
tasks: {
|
|
107
|
+
"query-filters": {
|
|
108
|
+
pagination: { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const table = mountTable(ref<LocationQuery>({}));
|
|
114
|
+
expect(table.pagination.value.pageIndex).toBe(THIRD_PAGE);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("starts a load-more list over from one page", async () => {
|
|
118
|
+
const query = ref<LocationQuery>({ status: "pending" });
|
|
119
|
+
const hiddenFilters = hiddenFiltersOf(() => query.value);
|
|
120
|
+
const pagination = ref({ pageIndex: 0, pageSize: PAGE_SIZE });
|
|
121
|
+
const pages = useAccumulatedPages({
|
|
122
|
+
mode: "loadMore",
|
|
123
|
+
pagination,
|
|
124
|
+
resetOn: [hiddenFilters],
|
|
125
|
+
});
|
|
126
|
+
pages.loadNextPage();
|
|
127
|
+
expect(pages.requestedPagination.value.pageSize).toBe(PAGE_SIZE * 2);
|
|
128
|
+
|
|
129
|
+
query.value = { status: "cancelled" };
|
|
130
|
+
await nextTick();
|
|
131
|
+
|
|
132
|
+
expect(pages.requestedPagination.value).toEqual({
|
|
133
|
+
pageIndex: 0,
|
|
134
|
+
pageSize: PAGE_SIZE,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A table view reads its rows again when they moved without a write through
|
|
4
|
+
* its own routes: on each event of its `realtimeTopic`, and each time the page
|
|
5
|
+
* asks its blocks to refresh (refreshPageBlocks). The re-read goes through the
|
|
6
|
+
* same refresh as the toolbar's button, which keeps the rows on screen.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { createApp, defineComponent, h } from "vue";
|
|
11
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
12
|
+
import type { RealtimeTopicHandler } from "../layers/dms-core/app/composables/realtime/useUserRealtime";
|
|
13
|
+
import { refreshPageBlocks } from "../layers/dms-ui/app/utils/blockRefresh";
|
|
14
|
+
|
|
15
|
+
const topicHandlers = new Map<string, RealtimeTopicHandler>();
|
|
16
|
+
|
|
17
|
+
vi.mock("../layers/dms-core/app/composables/realtime/useRealtimeTopic", () => ({
|
|
18
|
+
useRealtimeTopic: (topic: string, handler: RealtimeTopicHandler) => {
|
|
19
|
+
topicHandlers.set(topic, handler);
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { useRefreshTriggers } = await import(
|
|
24
|
+
"../layers/dms-ui/app/build/composables/blocks/useRefreshTriggers"
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const tableViewSource = readFileSync(
|
|
28
|
+
resolve(
|
|
29
|
+
__dirname,
|
|
30
|
+
"../layers/dms-ui/app/components/table-view/TableView.vue",
|
|
31
|
+
),
|
|
32
|
+
"utf8",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const UPLOADS_TOPIC = "media:uploads";
|
|
36
|
+
const mounted: Array<{ unmount: () => void }> = [];
|
|
37
|
+
|
|
38
|
+
function mountBlock(topic: string | string[] | undefined) {
|
|
39
|
+
const onRefresh = vi.fn();
|
|
40
|
+
const Block = defineComponent({
|
|
41
|
+
setup() {
|
|
42
|
+
useRefreshTriggers(topic, onRefresh);
|
|
43
|
+
return () => h("div");
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const app = createApp(Block);
|
|
47
|
+
app.mount(document.createElement("div"));
|
|
48
|
+
mounted.push(app);
|
|
49
|
+
return { onRefresh, unmount: () => app.unmount() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => topicHandlers.clear());
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("refreshes on each page refresh while mounted", () => {
|
|
59
|
+
const block = mountBlock(undefined);
|
|
60
|
+
refreshPageBlocks();
|
|
61
|
+
expect(block.onRefresh).toHaveBeenCalledTimes(1);
|
|
62
|
+
|
|
63
|
+
block.unmount();
|
|
64
|
+
mounted.length = 0;
|
|
65
|
+
refreshPageBlocks();
|
|
66
|
+
expect(block.onRefresh).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("refreshes on each event of any of its topics, not on a snapshot", () => {
|
|
70
|
+
const block = mountBlock([UPLOADS_TOPIC, "media:jobs"]);
|
|
71
|
+
expect([...topicHandlers.keys()]).toEqual([UPLOADS_TOPIC, "media:jobs"]);
|
|
72
|
+
|
|
73
|
+
topicHandlers.get(UPLOADS_TOPIC)?.({ type: "batch.done" } as never);
|
|
74
|
+
topicHandlers.get("media:jobs")?.({ type: "job.done" } as never);
|
|
75
|
+
topicHandlers.get(UPLOADS_TOPIC)?.({ entries: [] } as never);
|
|
76
|
+
expect(block.onRefresh).toHaveBeenCalledTimes(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("wires the table view's realtimeTopic and page refresh to its refresh", () => {
|
|
80
|
+
expect(tableViewSource).toMatch(/realtimeTopic\?: string \| string\[\];/);
|
|
81
|
+
expect(tableViewSource).toContain(
|
|
82
|
+
"useRefreshTriggers(props.realtimeTopic, () => void refreshAll());",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { reactive } from "vue";
|
|
2
3
|
import {
|
|
3
4
|
processSourceRows,
|
|
4
5
|
sourceRouteQuery,
|
|
@@ -25,6 +26,11 @@ const COLUMNS = [
|
|
|
25
26
|
|
|
26
27
|
type Api = Parameters<typeof useTableRows>[0]["api"];
|
|
27
28
|
|
|
29
|
+
interface PageRoute {
|
|
30
|
+
params: Record<string, string>;
|
|
31
|
+
query: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
describe("source tables", () => {
|
|
29
35
|
it("sends the route only the part of the query it handles", () => {
|
|
30
36
|
const query = {
|
|
@@ -112,4 +118,65 @@ describe("source tables", () => {
|
|
|
112
118
|
body: { queries: [{ id: "all", query: {} }] },
|
|
113
119
|
});
|
|
114
120
|
});
|
|
121
|
+
|
|
122
|
+
describe("with a templated fetchUrl", () => {
|
|
123
|
+
const FETCH_URL = "/api/ws/{{params.id}}/requests?route={{query.route}}";
|
|
124
|
+
|
|
125
|
+
function sourceRows(route: PageRoute) {
|
|
126
|
+
const api = vi.fn(async (_url: string) => ({ results: ROWS, total: 3 }));
|
|
127
|
+
const rows = useTableRows<LogRow>({
|
|
128
|
+
api: api as unknown as Api,
|
|
129
|
+
location: FETCH_URL,
|
|
130
|
+
source: { fetchUrl: FETCH_URL, capabilities: {} },
|
|
131
|
+
columns: COLUMNS,
|
|
132
|
+
route: () => ({ routeParams: route.params, routeQuery: route.query }),
|
|
133
|
+
});
|
|
134
|
+
return { api, rows };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it("requests the page's URL, its values encoded", async () => {
|
|
138
|
+
const { api, rows } = sourceRows({
|
|
139
|
+
params: { id: "ws-1" },
|
|
140
|
+
query: { route: "GET /api/a?b=c" },
|
|
141
|
+
});
|
|
142
|
+
await rows.list({});
|
|
143
|
+
expect(api).toHaveBeenCalledWith(
|
|
144
|
+
"/api/ws/ws-1/requests?route=GET%20%2Fapi%2Fa%3Fb%3Dc",
|
|
145
|
+
{ query: {} },
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("requests nothing, and lists nothing, while a token has no value", async () => {
|
|
150
|
+
const { api, rows } = sourceRows({ params: { id: "ws-1" }, query: {} });
|
|
151
|
+
expect(rows.sourceUrl.value).toBe(undefined);
|
|
152
|
+
expect(await rows.list({ offset: 10, limit: 10 })).toEqual({
|
|
153
|
+
results: [],
|
|
154
|
+
total: 0,
|
|
155
|
+
offset: 10,
|
|
156
|
+
limit: 10,
|
|
157
|
+
});
|
|
158
|
+
expect(
|
|
159
|
+
await rows.countBatch([{ id: "orders", query: { search: "orders" } }]),
|
|
160
|
+
).toEqual({ orders: 0 });
|
|
161
|
+
expect(api).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("follows the page URL, tab counters included", async () => {
|
|
165
|
+
const route = reactive<PageRoute>({
|
|
166
|
+
params: { id: "ws-1" },
|
|
167
|
+
query: { route: "GET /a" },
|
|
168
|
+
});
|
|
169
|
+
const { api, rows } = sourceRows(route);
|
|
170
|
+
await rows.list({});
|
|
171
|
+
route.query = { route: "POST /b" };
|
|
172
|
+
expect(rows.sourceUrl.value).toBe(
|
|
173
|
+
"/api/ws/ws-1/requests?route=POST%20%2Fb",
|
|
174
|
+
);
|
|
175
|
+
await rows.countBatch([{ id: "all", query: {} }]);
|
|
176
|
+
expect(api.mock.calls.map(([url]) => url)).toEqual([
|
|
177
|
+
"/api/ws/ws-1/requests?route=GET%20%2Fa",
|
|
178
|
+
"/api/ws/ws-1/requests?route=POST%20%2Fb",
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
115
182
|
});
|
|
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
|
-
const EXPECTED_VUE_FILES =
|
|
5
|
+
const EXPECTED_VUE_FILES = 273;
|
|
6
6
|
|
|
7
7
|
function walk(directory: string): string[] {
|
|
8
8
|
return readdirSync(directory).flatMap((name) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/AntelopeJS/dms.git"
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
|
|
38
38
|
"@antelopejs/interface-database": ">=0.1.8 <1.0.0",
|
|
39
39
|
"@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
|
|
40
|
-
"@antelopejs/interface-dms": ">=0.5.
|
|
40
|
+
"@antelopejs/interface-dms": ">=0.5.1 <0.6.0",
|
|
41
41
|
"@antelopejs/interface-email": ">=0.0.5 <1.0.0",
|
|
42
42
|
"@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
|
|
43
43
|
"archiver": "^7.0.1",
|
package/skills/dms/SKILL.md
CHANGED
|
@@ -32,16 +32,27 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
|
|
|
32
32
|
`Grid`, `KpiCard`, … — fluent builders producing a **serializable description**, not live UI: the
|
|
33
33
|
frontend renders it. **Components fetch their own data** via URLs (`fetchUrl`, `submitUrl`, …) —
|
|
34
34
|
routes often declared `@Get`/`@Post` on the same page class; the backend stays the source of truth.
|
|
35
|
-
A URL names the record of its page with `{{params.id}}` / `{{query.X}}` (filled from the page URL;
|
|
35
|
+
A URL names the record of its page with `{{params.id}}` / `{{query.X}}` (filled from the page URL, percent-encoded;
|
|
36
36
|
nothing is fetched while one is unresolved), and `.watch(FormEvents.SUBMIT_SUCCESS,
|
|
37
|
-
BlockFunctions.REFRESH_PAGE)` makes the page's data blocks re-fetch after a form saves
|
|
37
|
+
BlockFunctions.REFRESH_PAGE)` makes the page's data blocks (table views included) re-fetch after a form saves;
|
|
38
|
+
`periodScope` binds a card, chart, list block or `Meter` to a `PeriodSelector`, and
|
|
39
|
+
`realtimeTopic` re-fetches a chart, list block, `Meter`, `Tab` badges or `TableView` on each `PublishMessage`.
|
|
40
|
+
A status the backend computes (health, outage) is a `Banner({ fetchUrl })`: the route answers
|
|
41
|
+
a `BannerContent` (its buttons may run `api` / modal / quick-action targets with `confirm`)
|
|
42
|
+
or `null` for no banner — the block then draws nothing.
|
|
38
43
|
A text naming values (an amount, a date, a count) is sent as a `ComposedText` —
|
|
39
44
|
`{ key, params: { amount: { type: "money", value: 92200, currency: "EUR" } } }` — that the
|
|
40
|
-
browser writes in the reader's language: StatGroup / KeyValueList / Banner
|
|
45
|
+
browser writes in the reader's language: StatGroup / KeyValueList / Banner / NavCardGrid /
|
|
46
|
+
ActivityFeed / Meter / TopListCard item texts (and ActivityFeed `params`, typed `count` for
|
|
47
|
+
plurals) and layout
|
|
41
48
|
banners (`RegisterLayoutBanner({ resolve })` → `{ text, actions }`) take it; never format text server-side.
|
|
42
49
|
A table cell's secondary line is a row field holding one (or `{ text, tone }`), named by
|
|
43
50
|
`StatusPillDisplay({ subField })`, `IdentityDisplay({ subtitleField })` or
|
|
44
51
|
`TwoLineDisplay({ subField })`; `IdentityDisplay({ emptyLabel, emptyIcon })` draws an empty row.
|
|
52
|
+
`StatusPillDisplay({ toneField })` reads the pill's tone from a row field (an HTTP status class),
|
|
53
|
+
and a `PillsDisplay` item may be `{ label, tone?, variant: "soft" | "outline" }`, styled per row.
|
|
54
|
+
A `KeyValueList` row takes `copy: true` (a copy button for its shown value) or `copyValue`
|
|
55
|
+
(the text to copy instead, e.g. a full URL behind a short label).
|
|
45
56
|
- **It's interfaces all the way down.** Everything you consume from the DMS is an AntelopeJS
|
|
46
57
|
interface imported as an `@antelopejs/interface-dms/...` subpath (see the imports below);
|
|
47
58
|
a distributable module exposes its own from a companion `@antelopejs/interface-<name>` package listed in `antelopeJs.implements`.
|
|
@@ -9,12 +9,28 @@ Main builders: `Form`; `TableView(controller, options)`; the charts (`ChartLine`
|
|
|
9
9
|
`ChartArea`, `ChartBar`, `ChartColumn`, `ChartDonut`, `ChartPie`, `ChartScatter`,
|
|
10
10
|
`ChartMixed`, `ChartRadar`, `ChartRadialBar`, `ChartHeatmap`, `ChartCandlestick`, usually
|
|
11
11
|
inside `ChartCard`); layout `Grid` + `GridRow`, `HStack` / `VStack` / `Spacer`, `Tab`,
|
|
12
|
-
`Placeholder`; dashboard widgets `KpiCard`, `TopListCard`, `PeriodSelector`;
|
|
12
|
+
`Placeholder`; dashboard widgets `KpiCard`, `TopListCard`, `PeriodSelector`; display blocks
|
|
13
|
+
`StatGroup`, `KeyValueList`, `NavCardGrid`, `EmptyState` (its `code` shows a snippet to
|
|
14
|
+
start with), `Banner`, `Card`, and `CodeBlock` (read-only highlighted code with a copy
|
|
15
|
+
button: `code` + `language`, or `fetchUrl` answering `{ code, language? }` — see
|
|
16
|
+
`docs/04.components/11.display-blocks.md`); and
|
|
13
17
|
`CustomComponent` (deep import from `@antelopejs/interface-dms/base/custom` — not in the barrel). Nest with
|
|
14
18
|
`.child("key", builder)`; `Grid` children must be `GridRow`s. Builders also carry a
|
|
15
19
|
behavioral DSL — permissioned `.action()`s and `.watch()` reactivity — see
|
|
16
20
|
`docs/02.building/05.actions-and-reactivity.md`.
|
|
17
21
|
|
|
22
|
+
A `TableView` scoped by the page URL takes `queryParamFilters: { status: { field: "status" } }`
|
|
23
|
+
(hidden filters read from `?status=`; `routeParamFilters` for slug params like `:id`). No
|
|
24
|
+
glue is needed around it: an active query filter shows the `filtered` empty state, and a
|
|
25
|
+
link changing its value sends the table back to page 1. A drawer or modal row action already
|
|
26
|
+
gets `navigation` (J / K, `index` / `total` in the order shown) —
|
|
27
|
+
`docs/04.components/06.tables/8.row-drawers.md`.
|
|
28
|
+
|
|
29
|
+
A button or row action (`customButtons`, `rowActions.custom`, a page's `headerActions`)
|
|
30
|
+
points at an `ActionTarget`. A `drawer` target opens from the bottom; give it
|
|
31
|
+
`direction: "right"` for a side sheet (an inspector, a detail panel) instead of hosting your
|
|
32
|
+
own drawer in a `CustomComponent`.
|
|
33
|
+
|
|
18
34
|
## DataTypes
|
|
19
35
|
|
|
20
36
|
A field's `type` is a **DataType instance**: `new DefaultDataTypes.StringType({ … })`. The
|