@platforma-sdk/ui-vue 1.84.0 → 1.84.2

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 (27) hide show
  1. package/.turbo/turbo-build.log +12 -11
  2. package/.turbo/turbo-formatter$colon$check.log +2 -2
  3. package/.turbo/turbo-linter$colon$check.log +2 -2
  4. package/.turbo/turbo-types$colon$check.log +1 -1
  5. package/CHANGELOG.md +123 -0
  6. package/dist/components/PlAgDataTable/PlAgDataTableV2.js.map +1 -1
  7. package/dist/components/PlAgDataTable/PlAgDataTableV2.style.js.map +1 -1
  8. package/dist/components/PlAgDataTable/PlAgDataTableV2.vue.d.ts.map +1 -1
  9. package/dist/components/PlAgDataTable/PlAgDataTableV2.vue2.js +175 -180
  10. package/dist/components/PlAgDataTable/PlAgDataTableV2.vue2.js.map +1 -1
  11. package/dist/components/PlAgDataTable/__tests__/PlAgDataTableV2.reload.jsdomtest.d.ts +2 -0
  12. package/dist/components/PlAgDataTable/__tests__/PlAgDataTableV2.reload.jsdomtest.d.ts.map +1 -0
  13. package/dist/components/PlAgDataTable/sources/grid-state.d.ts +22 -0
  14. package/dist/components/PlAgDataTable/sources/grid-state.d.ts.map +1 -0
  15. package/dist/components/PlAgDataTable/sources/grid-state.js +39 -0
  16. package/dist/components/PlAgDataTable/sources/grid-state.js.map +1 -0
  17. package/dist/components/PlAgDataTable/sources/grid-state.test.d.ts +2 -0
  18. package/dist/components/PlAgDataTable/sources/grid-state.test.d.ts.map +1 -0
  19. package/dist/components/PlAgDataTable/sources/table-state-v2.d.ts.map +1 -1
  20. package/dist/components/PlAgDataTable/sources/table-state-v2.js +1 -0
  21. package/dist/components/PlAgDataTable/sources/table-state-v2.js.map +1 -1
  22. package/package.json +7 -7
  23. package/src/components/PlAgDataTable/PlAgDataTableV2.vue +28 -16
  24. package/src/components/PlAgDataTable/__tests__/PlAgDataTableV2.reload.jsdomtest.ts +222 -0
  25. package/src/components/PlAgDataTable/sources/grid-state.test.ts +94 -0
  26. package/src/components/PlAgDataTable/sources/grid-state.ts +110 -0
  27. package/src/components/PlAgDataTable/sources/table-state-v2.ts +7 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The component recreates the whole AG Grid (`:key="reloadKey"`) when the stored
3
+ * grid state disagrees with the grid's own state, which is meant to converge
4
+ * after a single remount.
5
+ *
6
+ * It did not converge when the stored state travelled back through the platform:
7
+ * the write is debounced, and a block that is running has its ui state re-pushed
8
+ * meanwhile, so the state flipped between what the grid had and what the project
9
+ * still held. Every flip re-armed the comparison and the grid was recreated ~20
10
+ * times a second — the symptom being a strobing header and a console full of AG
11
+ * Grid licence banners.
12
+ */
13
+ import { mount } from "@vue/test-utils";
14
+ import { beforeAll, describe, expect, it, vi } from "vitest";
15
+ import { defineComponent, h, onUnmounted, ref } from "vue";
16
+
17
+ const hoisted = vi.hoisted(() => ({ driver: {} as Record<string, unknown> }));
18
+
19
+ // The CSV exporter reaches the pf-spec WASM module, which cannot initialise
20
+ // under jsdom; nothing here exercises export.
21
+ vi.mock("@milaboratories/pf-spec-driver", () => ({ SpecDriver: class {} }));
22
+ vi.mock("@milaboratories/columns-collection-driver", () => ({
23
+ ColumnsCollectionDriverImpl: class {},
24
+ }));
25
+ vi.mock("@platforma-sdk/model", async (importOriginal) => {
26
+ const actual = await importOriginal<Record<string, unknown>>();
27
+ return { ...actual, getRawPlatformaInstance: () => ({ pFrameDriver: hoisted.driver }) };
28
+ });
29
+
30
+ const { activateAgGrid } = await import("../../../composition/AgGrid");
31
+ const { canonicalizeJson } = await import("@platforma-sdk/model");
32
+ const PlAgDataTableV2 = (await import("../PlAgDataTableV2.vue")).default;
33
+
34
+ /* eslint-disable @typescript-eslint/no-explicit-any */
35
+ type Any = Record<string, any>;
36
+
37
+ const axisSpec = { name: "sampleId", type: "String", annotations: { "pl7.app/label": "Sample" } };
38
+ const colSpec = (name: string): Any => ({
39
+ kind: "PColumn",
40
+ name,
41
+ valueType: "Int",
42
+ axesSpec: [axisSpec],
43
+ annotations: { "pl7.app/label": name },
44
+ });
45
+ const specs: Any[] = [
46
+ { type: "axis", id: { name: "sampleId", type: "String" }, spec: axisSpec },
47
+ { type: "column", id: "col-a", spec: colSpec("A") },
48
+ { type: "column", id: "col-b", spec: colSpec("B") },
49
+ ];
50
+ const columnId = (spec: Any) =>
51
+ canonicalizeJson({ type: "column", id: spec.id } as never) as string;
52
+
53
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
54
+
55
+ /** Counts AG Grid instantiations: every `createGrid` inserts one root wrapper. */
56
+ function countGrids() {
57
+ let created = 0;
58
+ const observer = new MutationObserver((records) => {
59
+ for (const record of records)
60
+ for (const node of record.addedNodes)
61
+ if (node instanceof Element && node.classList?.contains("ag-root-wrapper")) created++;
62
+ });
63
+ observer.observe(document.body, { childList: true, subtree: true });
64
+ return { get: () => created, stop: () => observer.disconnect() };
65
+ }
66
+
67
+ /**
68
+ * Stands in for the block app plus the platform: a write from the table does not
69
+ * land in the prop straight away (it is debounced, then travels to the project
70
+ * state and back), and while a block runs the platform keeps re-pushing the
71
+ * state it currently holds.
72
+ */
73
+ const Host = defineComponent({
74
+ props: {
75
+ settings: { type: Object, required: true },
76
+ initial: { type: Object, required: true },
77
+ echoMs: { type: Number, required: true },
78
+ pushEveryMs: { type: Number, required: true },
79
+ },
80
+ setup(props) {
81
+ const clone = (value: Any) => JSON.parse(JSON.stringify(value)) as Any;
82
+ const platform = { value: clone(props.initial) };
83
+ const pushed = ref(clone(platform.value));
84
+ const timer = setInterval(() => (pushed.value = clone(platform.value)), props.pushEveryMs);
85
+ onUnmounted(() => clearInterval(timer));
86
+ return () =>
87
+ h(PlAgDataTableV2 as Any, {
88
+ modelValue: pushed.value,
89
+ "onUpdate:modelValue": (state: Any) => {
90
+ const snapshot = clone(state);
91
+ setTimeout(() => {
92
+ platform.value = snapshot;
93
+ pushed.value = clone(snapshot);
94
+ }, props.echoMs);
95
+ },
96
+ settings: props.settings,
97
+ });
98
+ },
99
+ });
100
+
101
+ beforeAll(() => activateAgGrid());
102
+
103
+ describe("PlAgDataTableV2", () => {
104
+ it("takes the loading overlay down when the model goes between handles", async () => {
105
+ // What a block re-running does to the table: it is already loading against one
106
+ // set of handles when the model drops them for a moment on its way to the next.
107
+ // That settings change starts no calculation of its own, so the one in flight is
108
+ // the only thing left that can clear the overlay.
109
+ hoisted.driver.getSpec = async () => {
110
+ await wait(150);
111
+ return specs;
112
+ };
113
+ hoisted.driver.getShape = async () => ({ rows: 0, columns: specs.length });
114
+ hoisted.driver.getData = async () => [];
115
+
116
+ const sourceId = "src-1";
117
+ const withHandles = (n: number) => ({
118
+ sourceId,
119
+ sheets: [],
120
+ model: {
121
+ sourceId,
122
+ fullTableHandle: `full-${n}`,
123
+ visibleTableHandle: `visible-${n}`,
124
+ fullPframeHandle: `pf-${n}`,
125
+ },
126
+ });
127
+ const betweenHandles = { sourceId, sheets: [], model: { sourceId } };
128
+
129
+ const wrapper = mount(Host, {
130
+ attachTo: document.body,
131
+ props: {
132
+ settings: withHandles(1),
133
+ initial: {
134
+ version: 8,
135
+ stateCache: [],
136
+ pTableParams: {
137
+ sourceId,
138
+ hiddenColIds: null,
139
+ sorting: null,
140
+ filters: null,
141
+ defaultFilters: null,
142
+ },
143
+ },
144
+ echoMs: 0,
145
+ pushEveryMs: 1000,
146
+ },
147
+ });
148
+
149
+ await wait(50); // the first calculation is in flight
150
+ await wrapper.setProps({ settings: betweenHandles });
151
+ await wait(900);
152
+
153
+ const overlay = (document.body.textContent || "").includes("Loading data...");
154
+ wrapper.unmount();
155
+ document.body.innerHTML = "";
156
+ expect(overlay).toBe(false);
157
+ });
158
+
159
+ it("stops recreating the grid once the stored state has been applied", async () => {
160
+ hoisted.driver.getSpec = async () => {
161
+ await wait(40);
162
+ return specs;
163
+ };
164
+ hoisted.driver.getShape = async () => ({ rows: 0, columns: specs.length });
165
+ hoisted.driver.getData = async () => [];
166
+
167
+ const sourceId = "src-1";
168
+ const counter = countGrids();
169
+ const wrapper = mount(Host, {
170
+ attachTo: document.body,
171
+ props: {
172
+ // A block that has just been rebuilt: still running, model not there yet.
173
+ settings: { sourceId, sheets: [], model: undefined },
174
+ initial: {
175
+ version: 8,
176
+ stateCache: [
177
+ {
178
+ sourceId,
179
+ // A sort the user left behind in a previous session.
180
+ gridState: { sort: { sortModel: [{ colId: columnId(specs[1]), sort: "asc" }] } },
181
+ sheetsState: [],
182
+ filtersState: null,
183
+ defaultFiltersState: null,
184
+ },
185
+ ],
186
+ pTableParams: {
187
+ sourceId,
188
+ hiddenColIds: null,
189
+ sorting: null,
190
+ filters: null,
191
+ defaultFilters: null,
192
+ },
193
+ },
194
+ echoMs: 400,
195
+ pushEveryMs: 50,
196
+ },
197
+ });
198
+
199
+ await wait(20);
200
+ await wrapper.setProps({
201
+ settings: {
202
+ sourceId,
203
+ sheets: [],
204
+ model: {
205
+ sourceId,
206
+ fullTableHandle: "full-1",
207
+ visibleTableHandle: "visible-1",
208
+ fullPframeHandle: "pf-1",
209
+ },
210
+ },
211
+ });
212
+ await wait(1200);
213
+
214
+ const created = counter.get();
215
+ counter.stop();
216
+ wrapper.unmount();
217
+ document.body.innerHTML = "";
218
+
219
+ // One grid for the mount, at most one more to apply the stored sort.
220
+ expect(created).toBeLessThanOrEqual(2);
221
+ });
222
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PlDataTableGridStateCore, PlTableColumnIdJson } from "@platforma-sdk/model";
3
+ import { isStoredStateApplied } from "./grid-state";
4
+
5
+ const colA = '{"id":"a","type":"column"}' as PlTableColumnIdJson;
6
+ const colB = '{"id":"b","type":"column"}' as PlTableColumnIdJson;
7
+ const dropped = '{"id":"gone","type":"column"}' as PlTableColumnIdJson;
8
+
9
+ const grid = new Set<PlTableColumnIdJson>([colA, colB]);
10
+ const noColumns = new Set<PlTableColumnIdJson>();
11
+
12
+ const order = (...ids: PlTableColumnIdJson[]): PlDataTableGridStateCore => ({
13
+ columnOrder: { orderedColIds: ids },
14
+ });
15
+ const hidden = (...ids: PlTableColumnIdJson[]): PlDataTableGridStateCore => ({
16
+ columnVisibility: { hiddenColIds: ids },
17
+ });
18
+ const sorted = (id: PlTableColumnIdJson): PlDataTableGridStateCore => ({
19
+ sort: { sortModel: [{ colId: id, sort: "asc" }] },
20
+ });
21
+
22
+ describe("isStoredStateApplied", () => {
23
+ // Every case below is one a rebuild could not satisfy, so counting it as a
24
+ // disagreement makes the reload watch rebuild the grid forever.
25
+ describe("does not ask for what a rebuild cannot deliver", () => {
26
+ it("ignores a field the stored state says nothing about", () => {
27
+ expect(
28
+ isStoredStateApplied(hidden(colA), { ...hidden(colA), ...order(colA, colB) }, grid),
29
+ ).toBe(true);
30
+ });
31
+
32
+ it("ignores a column order naming a column the grid no longer has", () => {
33
+ expect(isStoredStateApplied(order(colA, dropped, colB), order(colA, colB), grid)).toBe(true);
34
+ });
35
+
36
+ it("ignores a hidden column the grid no longer has", () => {
37
+ expect(isStoredStateApplied(hidden(colA, dropped), hidden(colA), grid)).toBe(true);
38
+ });
39
+
40
+ it("ignores a sort on a column the grid no longer has", () => {
41
+ expect(isStoredStateApplied(sorted(dropped), {}, grid)).toBe(true);
42
+ });
43
+
44
+ it("asks for nothing while the grid has no columns at all", () => {
45
+ expect(isStoredStateApplied({ ...order(colA, colB), ...hidden(colA) }, {}, noColumns)).toBe(
46
+ true,
47
+ );
48
+ });
49
+
50
+ it("asks for nothing when the stored state is empty", () => {
51
+ expect(isStoredStateApplied({}, order(colA), grid)).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("still asks for what a rebuild can deliver", () => {
56
+ it("reloads for a different hidden set", () => {
57
+ expect(isStoredStateApplied(hidden(colA), hidden(colB), grid)).toBe(false);
58
+ });
59
+
60
+ it("reloads for a different order of columns it knows", () => {
61
+ expect(isStoredStateApplied(order(colA, colB), order(colB, colA), grid)).toBe(false);
62
+ });
63
+
64
+ it("reloads for a different sort", () => {
65
+ expect(isStoredStateApplied(sorted(colA), sorted(colB), grid)).toBe(false);
66
+ });
67
+
68
+ it("ignores where the grid puts columns the stored order does not mention", () => {
69
+ expect(isStoredStateApplied(order(colA, colB), order(colA, dropped, colB), grid)).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe("an explicitly cleared stored state is an opinion, not silence", () => {
74
+ it("reloads when the stored state cleared the sorting and the grid is still sorted", () => {
75
+ expect(isStoredStateApplied({ sort: { sortModel: [] } }, sorted(colA), grid)).toBe(false);
76
+ });
77
+
78
+ it("reloads when the stored state shows every column and the grid still hides one", () => {
79
+ expect(isStoredStateApplied(hidden(), hidden(colA), grid)).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("treats a state the grid reported as absent or empty alike", () => {
84
+ it("for columnVisibility", () => {
85
+ expect(isStoredStateApplied(hidden(), { columnVisibility: undefined }, grid)).toBe(true);
86
+ });
87
+
88
+ it("for sort", () => {
89
+ expect(isStoredStateApplied({ sort: { sortModel: [] } }, { sort: undefined }, grid)).toBe(
90
+ true,
91
+ );
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,110 @@
1
+ import { isJsonEqual } from "@milaboratories/helpers";
2
+ import type { PlDataTableGridStateCore, PlTableColumnIdJson } from "@platforma-sdk/model";
3
+
4
+ /**
5
+ * Whether the grid already shows everything the stored state asks for that it
6
+ * could possibly show.
7
+ *
8
+ * The answer decides whether to destroy and rebuild the grid around the stored
9
+ * state, so it must only ever ask for things a rebuild can deliver. Two kinds of
10
+ * request cannot be delivered, and asking anyway is how this comparison became an
11
+ * engine for endless rebuilds:
12
+ *
13
+ * - a field the stored state does not express. AG Grid always reports its whole
14
+ * state, and a state saved while the grid had no columns carries no
15
+ * `columnOrder` at all; demanding equality there asks the grid to unreport
16
+ * something.
17
+ * - an id the grid does not have. State outlives column sets — a sort on a
18
+ * column a later run dropped, hidden ids saved against different columns — and
19
+ * no rebuild brings those columns back.
20
+ *
21
+ * Both are dropped before comparing rather than guarded against afterwards.
22
+ */
23
+ export function isStoredStateApplied(
24
+ desired: PlDataTableGridStateCore,
25
+ actual: PlDataTableGridStateCore,
26
+ gridColIds: ReadonlySet<PlTableColumnIdJson>,
27
+ ): boolean {
28
+ const {
29
+ orderedColIds: desiredOrder,
30
+ hiddenColIds: desiredHidden,
31
+ sortModel: desiredSort,
32
+ } = dropColumnsTheGridDoesNotHave(desired, gridColIds);
33
+ const {
34
+ orderedColIds: actualOrder,
35
+ hiddenColIds: actualHidden,
36
+ sortModel: actualSort,
37
+ } = normalizeReportedState(actual);
38
+
39
+ if (desiredHidden !== undefined && !isJsonEqual(desiredHidden, actualHidden)) return false;
40
+
41
+ if (desiredSort !== undefined && !isJsonEqual(desiredSort, actualSort)) return false;
42
+
43
+ if (desiredOrder !== undefined) {
44
+ // Only the relative order of the columns it names: the grid may hold others,
45
+ // and where it puts them is not being asked about.
46
+ const desiredColumns = new Set(desiredOrder);
47
+ const actualOrderOfThose = actualOrder.filter((id) => desiredColumns.has(id));
48
+ if (!isJsonEqual(desiredOrder, actualOrderOfThose)) return false;
49
+ }
50
+
51
+ return true;
52
+ }
53
+
54
+ /**
55
+ * Read a state the grid reported the way the grid means it.
56
+ *
57
+ * AG Grid leaves out what carries no information — no `sort` while nothing is
58
+ * sorted, no `columnVisibility` while every column is shown — so absent and
59
+ * empty mean the same thing coming from it, and hidden ids arrive in whatever
60
+ * order it happens to hold them.
61
+ *
62
+ * The stored state is deliberately NOT read this way: there an empty list is the
63
+ * user having cleared the sorting or shown every column, and the grid has to be
64
+ * made to match it.
65
+ */
66
+ function normalizeReportedState(state: PlDataTableGridStateCore) {
67
+ return {
68
+ orderedColIds: state.columnOrder?.orderedColIds ?? [],
69
+ hiddenColIds: sortIds(state.columnVisibility?.hiddenColIds ?? []),
70
+ sortModel: state.sort?.sortModel ?? [],
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Strike out every column the stored state names that the grid does not have, so
76
+ * what is left is what the grid can still be asked about.
77
+ */
78
+ function dropColumnsTheGridDoesNotHave(
79
+ state: PlDataTableGridStateCore,
80
+ gridColIds: ReadonlySet<PlTableColumnIdJson>,
81
+ ) {
82
+ const inGrid = (id: PlTableColumnIdJson) => gridColIds.has(id);
83
+ const hidden = keepColumnsInGrid(state.columnVisibility?.hiddenColIds, inGrid);
84
+ return {
85
+ orderedColIds: keepColumnsInGrid(state.columnOrder?.orderedColIds, inGrid),
86
+ hiddenColIds: hidden && sortIds(hidden),
87
+ sortModel: keepColumnsInGrid(state.sort?.sortModel, (item) => inGrid(item.colId)),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * The entries naming a column the grid has, or `undefined` when there is nothing
93
+ * left to ask about: either the field was never expressed, or every column it
94
+ * named is gone and no rebuild brings those back. An explicitly empty list is an
95
+ * opinion — "sorted by nothing", "every column visible" — and survives as one.
96
+ */
97
+ function keepColumnsInGrid<T>(
98
+ entries: T[] | undefined,
99
+ inGrid: (entry: T) => boolean,
100
+ ): T[] | undefined {
101
+ if (entries === undefined) return undefined;
102
+ const kept = entries.filter(inGrid);
103
+ if (entries.length > 0 && kept.length === 0) return undefined;
104
+ return kept;
105
+ }
106
+
107
+ /** Hidden columns are a set; put them in one order so either side compares alike. */
108
+ function sortIds(ids: PlTableColumnIdJson[]): PlTableColumnIdJson[] {
109
+ return [...ids].sort();
110
+ }
@@ -68,6 +68,13 @@ export function useTableState(
68
68
  set: (state) => {
69
69
  const newState: PlDataTableStateV2Normalized = {
70
70
  ...tableStateNormalized.value,
71
+ // Copy the cache before editing it below: spreading keeps the same array,
72
+ // and splicing it in place would edit the current state as well — the
73
+ // change check at the end would then compare the new state against
74
+ // itself, find them equal, and skip the assignment, so a change that
75
+ // leaves `pTableParams` untouched (reordering columns, say) would never
76
+ // be written back to the project.
77
+ stateCache: [...tableStateNormalized.value.stateCache],
71
78
  pTableParams: createDefaultPTableParams(),
72
79
  };
73
80