@platforma-sdk/ui-vue 1.66.1 → 1.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +21 -15
  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 +26 -0
  6. package/dist/__tests__/setup.d.ts +2 -0
  7. package/dist/__tests__/setup.d.ts.map +1 -0
  8. package/dist/components/PlAdvancedFilter/FilterEditor.vue2.js +20 -20
  9. package/dist/components/PlAgColumnHeader/PlAgColumnHeader.js.map +1 -1
  10. package/dist/components/PlAgColumnHeader/PlAgColumnHeader.vue.d.ts.map +1 -1
  11. package/dist/components/PlAgColumnHeader/PlAgColumnHeader.vue2.js +43 -33
  12. package/dist/components/PlAgColumnHeader/PlAgColumnHeader.vue2.js.map +1 -1
  13. package/dist/components/PlAgColumnHeader/types.d.ts +1 -0
  14. package/dist/components/PlAgColumnHeader/types.d.ts.map +1 -1
  15. package/dist/components/PlAgDataTable/compositions/useFilterableColumns.js +5 -5
  16. package/dist/components/PlAgDataTable/sources/table-source-v2.d.ts.map +1 -1
  17. package/dist/components/PlAgDataTable/sources/table-source-v2.js +10 -11
  18. package/dist/components/PlAgDataTable/sources/table-source-v2.js.map +1 -1
  19. package/dist/components/PlAgDataTable/sources/table-state-v2.js +13 -13
  20. package/dist/components/PlDatasetSelector/PlDatasetSelector.js +9 -0
  21. package/dist/components/PlDatasetSelector/PlDatasetSelector.js.map +1 -0
  22. package/dist/components/PlDatasetSelector/PlDatasetSelector.style.css +1 -0
  23. package/dist/components/PlDatasetSelector/PlDatasetSelector.vue.d.ts +105 -0
  24. package/dist/components/PlDatasetSelector/PlDatasetSelector.vue.d.ts.map +1 -0
  25. package/dist/components/PlDatasetSelector/PlDatasetSelector.vue2.js +111 -0
  26. package/dist/components/PlDatasetSelector/PlDatasetSelector.vue2.js.map +1 -0
  27. package/dist/components/PlDatasetSelector/__tests__/PlDatasetSelector.jsdomtest.d.ts +2 -0
  28. package/dist/components/PlDatasetSelector/__tests__/PlDatasetSelector.jsdomtest.d.ts.map +1 -0
  29. package/dist/components/PlDatasetSelector/index.d.ts +2 -0
  30. package/dist/components/PlDatasetSelector/index.d.ts.map +1 -0
  31. package/dist/components/PlDatasetSelector/index.js +1 -0
  32. package/dist/components/PlTableFilters/PlTableFiltersV2.vue2.js +8 -8
  33. package/dist/index.js +15 -14
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal/createAppV3.d.ts.map +1 -1
  36. package/dist/internal/createAppV3.js +74 -83
  37. package/dist/internal/createAppV3.js.map +1 -1
  38. package/dist/internal/getServices.d.ts +5 -0
  39. package/dist/internal/getServices.d.ts.map +1 -0
  40. package/dist/internal/getServices.js +19 -0
  41. package/dist/internal/getServices.js.map +1 -0
  42. package/dist/internal/service_factories.d.ts +2 -2
  43. package/dist/internal/service_factories.d.ts.map +1 -1
  44. package/dist/internal/service_factories.js.map +1 -1
  45. package/dist/internal/utils.d.ts +3 -0
  46. package/dist/internal/utils.d.ts.map +1 -0
  47. package/dist/internal/utils.js +16 -0
  48. package/dist/internal/utils.js.map +1 -0
  49. package/dist/lib.d.ts +1 -0
  50. package/dist/lib.d.ts.map +1 -1
  51. package/dist/lib.js +2 -0
  52. package/package.json +8 -6
  53. package/src/__tests__/setup.ts +10 -0
  54. package/src/components/PlAgColumnHeader/PlAgColumnHeader.vue +13 -7
  55. package/src/components/PlAgColumnHeader/types.ts +1 -0
  56. package/src/components/PlAgDataTable/sources/table-source-v2.ts +23 -21
  57. package/src/components/PlDatasetSelector/PlDatasetSelector.vue +164 -0
  58. package/src/components/PlDatasetSelector/__tests__/PlDatasetSelector.jsdomtest.ts +257 -0
  59. package/src/components/PlDatasetSelector/index.ts +1 -0
  60. package/src/internal/createAppV3.ts +10 -37
  61. package/src/internal/getServices.ts +36 -0
  62. package/src/internal/service_factories.ts +2 -2
  63. package/src/internal/utils.ts +25 -0
  64. package/src/lib.ts +2 -0
  65. package/vitest.config.mts +22 -0
@@ -63,15 +63,15 @@ function columns2rows(
63
63
  axesResultIndices: number[],
64
64
  ): PlAgDataTableV2Row[] {
65
65
  const rowData: PlAgDataTableV2Row[] = [];
66
- for (let iRow = 0; iRow < columns[0].data.length; ++iRow) {
67
- const axesKey: PTableKey = axesResultIndices.map((ri) => pTableValue(columns[ri], iRow));
66
+ for (let rowIdx = 0; rowIdx < columns[0].data.length; ++rowIdx) {
67
+ const axesKey: PTableKey = axesResultIndices.map((ri) => pTableValue(columns[ri], rowIdx));
68
68
  const id = canonicalizeJson<PlTableRowId>(axesKey);
69
69
  const row = fields.reduce<PlAgDataTableV2Row>(
70
- (acc, field, iCol) => {
70
+ (acc, field, colIdx) => {
71
71
  acc[field.toString() as `${number}`] =
72
- fieldResultMapping[iCol] === -1
72
+ fieldResultMapping[colIdx] === -1
73
73
  ? PTableHidden
74
- : pTableValue(columns[fieldResultMapping[iCol]], iRow);
74
+ : pTableValue(columns[fieldResultMapping[colIdx]], rowIdx);
75
75
  return acc;
76
76
  },
77
77
  { id, axesKey },
@@ -156,10 +156,10 @@ export async function calculateGridOptions({
156
156
  // request indices: non-hidden display fields + visible axes for row selection keys.
157
157
  // Axes replaced by label columns request label data (display); original axis values
158
158
  // are fetched via visibleAxes (row keys).
159
- const { requestIndices, fieldResultMapping, axesResultIndices } = buildRequestIndices(
159
+ const { requestIndices, axesResultIndices, fieldResultMapping } = buildRequestIndices(
160
160
  indices,
161
+ visibleAxes.map(([idx]) => idx),
161
162
  specsToVisibleSpecsMapping,
162
- visibleAxes.map(([i]) => i),
163
163
  );
164
164
 
165
165
  let rowCount = -1;
@@ -313,7 +313,12 @@ export function makeColDef(
313
313
  throw Error(`unsupported data type: ${valueType}`);
314
314
  }
315
315
  })(),
316
- tooltip: readAnnotation(labeledSpec.spec, Annotation.Description)?.trim(),
316
+ tooltip:
317
+ readAnnotation(spec.spec, Annotation.Description)?.trim() ??
318
+ readAnnotation(labeledSpec.spec, Annotation.Description)?.trim(),
319
+ info:
320
+ readAnnotation(spec.spec, Annotation.Table.Info)?.trim() ??
321
+ readAnnotation(labeledSpec.spec, Annotation.Table.Info)?.trim(),
317
322
  } satisfies PlAgHeaderComponentParams,
318
323
  cellDataType: (() => {
319
324
  switch (valueType) {
@@ -465,26 +470,23 @@ function collectVisibleAxes(
465
470
  */
466
471
  function buildRequestIndices(
467
472
  indices: number[],
468
- specsToVisibleSpecsMapping: Map<number, number>,
469
473
  visibleAxesIndices: number[],
474
+ specsToVisibleSpecsMapping: Map<number, number>,
470
475
  ): {
471
476
  requestIndices: number[];
472
- fieldResultMapping: number[];
473
477
  axesResultIndices: number[];
478
+ fieldResultMapping: number[];
474
479
  } {
475
480
  const resolved = indices.map((displayField) => {
476
481
  const idx = specsToVisibleSpecsMapping.get(displayField);
477
- return idx === undefined || idx === -1 ? null : idx;
482
+ return isNil(idx) || idx === -1 ? null : idx;
478
483
  });
479
- const requestedFields = resolved.filter((v): v is number => v !== null);
480
-
481
- const fieldResultMapping: number[] = [];
482
- let pos = 0;
483
- for (const v of resolved) {
484
- fieldResultMapping.push(v === null ? -1 : pos++);
485
- }
486
-
487
- const requestIndices = uniq([...requestedFields, ...visibleAxesIndices]);
484
+ const requestIndices = uniq([
485
+ ...resolved.filter((v): v is number => v !== null),
486
+ ...visibleAxesIndices,
487
+ ]);
488
+ const fieldResultMapping = resolved.map((v) => (v === null ? -1 : requestIndices.indexOf(v)));
488
489
  const axesResultIndices = visibleAxesIndices.map((vi) => requestIndices.indexOf(vi));
489
- return { requestIndices, fieldResultMapping, axesResultIndices };
490
+
491
+ return { requestIndices, axesResultIndices, fieldResultMapping };
490
492
  }
@@ -0,0 +1,164 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Select a dataset and (optionally) a filter column, emitting a `PrimaryRef`.
4
+ *
5
+ * Behaves like {@link PlDropdownRef} when none of the offered datasets carry
6
+ * filter options. When the selected dataset has compatible filters, a second
7
+ * dropdown appears with the filters plus a "No filter" entry.
8
+ *
9
+ * Accepts `PrimaryRef | PlRef | undefined` as `modelValue` (a plain `PlRef`
10
+ * is treated as an unfiltered dataset), but always emits `PrimaryRef`
11
+ * (or `undefined` when cleared).
12
+ */
13
+ export default {
14
+ name: "PlDatasetSelector",
15
+ };
16
+ </script>
17
+
18
+ <script lang="ts" setup>
19
+ import type { DatasetOption, PlRef, PrimaryRef } from "@platforma-sdk/model";
20
+ import { createPrimaryRef, isPrimaryRef, plRefsEqual } from "@platforma-sdk/model";
21
+ import type { ListOption } from "@milaboratories/uikit";
22
+ import { PlDropdown, PlDropdownRef } from "@milaboratories/uikit";
23
+ import { computed } from "vue";
24
+
25
+ const slots = defineSlots<{
26
+ tooltip?: () => unknown;
27
+ }>();
28
+
29
+ /**
30
+ * v-model value. Accepts `PrimaryRef`, plain `PlRef` (treated as unfiltered),
31
+ * or `undefined`. Writes always emit `PrimaryRef` or `undefined`.
32
+ */
33
+ const model = defineModel<PrimaryRef | PlRef | undefined>();
34
+
35
+ const props = withDefaults(
36
+ defineProps<{
37
+ /** Available datasets, each optionally carrying compatible filter choices. */
38
+ options?: Readonly<DatasetOption[]>;
39
+ /** Label above the dataset dropdown. */
40
+ label?: string;
41
+ /** Helper text below the dataset dropdown (shown when there is no error). */
42
+ helper?: string;
43
+ /** Helper text shown while `options` is undefined (loading). */
44
+ loadingOptionsHelper?: string;
45
+ /** Error message displayed below the dataset dropdown. */
46
+ error?: unknown;
47
+ /** Placeholder when no dataset is selected. */
48
+ placeholder?: string;
49
+ /** Label above the filter dropdown. */
50
+ filterLabel?: string;
51
+ /** Placeholder for the filter dropdown. */
52
+ filterPlaceholder?: string;
53
+ /** Label of the "no filter" entry prepended to the filter options. */
54
+ noFilterLabel?: string;
55
+ /** Show a clear button on the dataset dropdown. */
56
+ clearable?: boolean;
57
+ /** Mark the dataset dropdown as required. */
58
+ required?: boolean;
59
+ /** Disable all interaction. */
60
+ disabled?: boolean;
61
+ }>(),
62
+ {
63
+ options: undefined,
64
+ label: undefined,
65
+ helper: undefined,
66
+ loadingOptionsHelper: undefined,
67
+ error: undefined,
68
+ placeholder: "...",
69
+ filterLabel: "",
70
+ filterPlaceholder: "...",
71
+ noFilterLabel: "No filter",
72
+ clearable: false,
73
+ required: false,
74
+ disabled: false,
75
+ },
76
+ );
77
+
78
+ const selectedDataset = computed<PlRef | undefined>(() => {
79
+ const v = model.value;
80
+ if (v === undefined) return undefined;
81
+ return isPrimaryRef(v) ? v.column : v;
82
+ });
83
+
84
+ const selectedFilter = computed<PlRef | undefined>(() => {
85
+ const v = model.value;
86
+ return isPrimaryRef(v) ? v.filter : undefined;
87
+ });
88
+
89
+ const currentDatasetOption = computed<DatasetOption | undefined>(() => {
90
+ const dataset = selectedDataset.value;
91
+ if (!dataset) return undefined;
92
+ return props.options?.find((o) => plRefsEqual(o.ref, dataset, true));
93
+ });
94
+
95
+ const hasFilters = computed(() => (currentDatasetOption.value?.filters?.length ?? 0) > 0);
96
+
97
+ /**
98
+ * Filter dropdown options. The first entry (`null`) is the "No filter" choice —
99
+ * null distinguishes it from `undefined` (dropdown clear button, disabled here).
100
+ */
101
+ const filterOptions = computed<ListOption<PlRef | null>[]>(() => {
102
+ const filters = currentDatasetOption.value?.filters;
103
+ if (!filters) return [];
104
+ return [
105
+ { label: props.noFilterLabel, value: null } as ListOption<PlRef | null>,
106
+ ...filters.map((f) => ({ label: f.label, value: f.ref }) as ListOption<PlRef | null>),
107
+ ];
108
+ });
109
+
110
+ const filterValue = computed<PlRef | null>(() => selectedFilter.value ?? null);
111
+
112
+ function emitValue(dataset: PlRef | undefined, filter: PlRef | undefined) {
113
+ model.value = dataset === undefined ? undefined : createPrimaryRef(dataset, filter);
114
+ }
115
+
116
+ function onDatasetChange(dataset: PlRef | undefined) {
117
+ emitValue(dataset, undefined);
118
+ }
119
+
120
+ function onFilterChange(value: PlRef | null | undefined) {
121
+ const dataset = selectedDataset.value;
122
+ if (!dataset) return;
123
+ emitValue(dataset, value ?? undefined);
124
+ }
125
+ </script>
126
+
127
+ <template>
128
+ <div class="pl-dataset-selector">
129
+ <PlDropdownRef
130
+ :model-value="selectedDataset"
131
+ :options="options"
132
+ :label="label"
133
+ :helper="helper"
134
+ :loading-options-helper="loadingOptionsHelper"
135
+ :error="error"
136
+ :placeholder="placeholder"
137
+ :clearable="clearable"
138
+ :required="required"
139
+ :disabled="disabled"
140
+ @update:model-value="onDatasetChange"
141
+ >
142
+ <template v-if="slots.tooltip" #tooltip>
143
+ <slot name="tooltip" />
144
+ </template>
145
+ </PlDropdownRef>
146
+ <PlDropdown
147
+ v-if="hasFilters"
148
+ :model-value="filterValue"
149
+ :options="filterOptions"
150
+ :label="filterLabel"
151
+ :placeholder="filterPlaceholder"
152
+ :disabled="disabled"
153
+ @update:model-value="onFilterChange"
154
+ />
155
+ </div>
156
+ </template>
157
+
158
+ <style scoped>
159
+ .pl-dataset-selector {
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: 12px;
163
+ }
164
+ </style>
@@ -0,0 +1,257 @@
1
+ import { flushPromises, mount } from "@vue/test-utils";
2
+ import type { DatasetOption, PrimaryRef } from "@platforma-sdk/model";
3
+ import { createPlRef, createPrimaryRef } from "@platforma-sdk/model";
4
+ import { describe, expect, it } from "vitest";
5
+ import PlDatasetSelector from "../PlDatasetSelector.vue";
6
+
7
+ const datasetA = createPlRef("1", "out-a", true);
8
+ const datasetB = createPlRef("2", "out-b", true);
9
+ const filterA1 = createPlRef("1", "filter-a1");
10
+ const filterA2 = createPlRef("1", "filter-a2");
11
+
12
+ const optionsWithFilters: DatasetOption[] = [
13
+ {
14
+ label: "Dataset A",
15
+ ref: datasetA,
16
+ filters: [
17
+ { label: "Top 1000", ref: filterA1 },
18
+ { label: "High quality", ref: filterA2 },
19
+ ],
20
+ },
21
+ // Dataset B has no filters — filter dropdown must stay hidden.
22
+ { label: "Dataset B", ref: datasetB },
23
+ ];
24
+
25
+ const datasetC = createPlRef("3", "out-c", true);
26
+
27
+ const optionsNoFilters: DatasetOption[] = [{ label: "Dataset B", ref: datasetB }];
28
+
29
+ const optionsWithEmptyFilters: DatasetOption[] = [
30
+ { label: "Dataset C", ref: datasetC, filters: [] },
31
+ ];
32
+
33
+ async function pickOption(index: number) {
34
+ const options = [...document.body.querySelectorAll(".dropdown-list-item")] as HTMLElement[];
35
+ options[index].click();
36
+ await flushPromises();
37
+ }
38
+
39
+ describe("PlDatasetSelector", () => {
40
+ it("renders a single dropdown when no dataset has filters", async () => {
41
+ const wrapper = mount(PlDatasetSelector, {
42
+ props: { modelValue: undefined, options: optionsNoFilters },
43
+ attachTo: document.body,
44
+ });
45
+ await flushPromises();
46
+
47
+ const selector = wrapper.find(".pl-dataset-selector");
48
+ expect(selector.exists()).toBe(true);
49
+ // Only PlDropdownRef is rendered — no filter dropdown.
50
+ expect(selector.element.children.length).toBe(1);
51
+ wrapper.unmount();
52
+ });
53
+
54
+ it("shows the filter dropdown when the selected dataset has filters", async () => {
55
+ const wrapper = mount(PlDatasetSelector, {
56
+ props: { modelValue: createPrimaryRef(datasetA), options: optionsWithFilters },
57
+ attachTo: document.body,
58
+ });
59
+ await flushPromises();
60
+
61
+ expect(wrapper.find(".pl-dataset-selector").element.children.length).toBe(2);
62
+ wrapper.unmount();
63
+ });
64
+
65
+ it("hides the filter dropdown when the selected dataset has no filters", async () => {
66
+ const wrapper = mount(PlDatasetSelector, {
67
+ props: { modelValue: createPrimaryRef(datasetB), options: optionsWithFilters },
68
+ attachTo: document.body,
69
+ });
70
+ await flushPromises();
71
+
72
+ expect(wrapper.find(".pl-dataset-selector").element.children.length).toBe(1);
73
+ wrapper.unmount();
74
+ });
75
+
76
+ it("emits PrimaryRef with filter: undefined when dataset changes", async () => {
77
+ const wrapper = mount(PlDatasetSelector, {
78
+ props: {
79
+ modelValue: createPrimaryRef(datasetA, filterA1),
80
+ options: optionsWithFilters,
81
+ "onUpdate:modelValue": (e) => wrapper.setProps({ modelValue: e }),
82
+ },
83
+ attachTo: document.body,
84
+ });
85
+ await flushPromises();
86
+
87
+ // Open the dataset dropdown (the first input — dataset comes first).
88
+ const inputs = wrapper.findAll("input");
89
+ await inputs[0].trigger("focus");
90
+
91
+ // Dataset A is already selected (index 0); pick Dataset B (index 1).
92
+ await pickOption(1);
93
+
94
+ const emitted = wrapper.emitted("update:modelValue");
95
+ expect(emitted).toBeDefined();
96
+ const last = emitted![emitted!.length - 1][0] as PrimaryRef;
97
+ expect(last).toEqual({ __isPrimaryRef: "v1", column: datasetB });
98
+ wrapper.unmount();
99
+ });
100
+
101
+ it("emits PrimaryRef with filter set when a filter is picked", async () => {
102
+ const wrapper = mount(PlDatasetSelector, {
103
+ props: {
104
+ modelValue: createPrimaryRef(datasetA),
105
+ options: optionsWithFilters,
106
+ "onUpdate:modelValue": (e) => wrapper.setProps({ modelValue: e }),
107
+ },
108
+ attachTo: document.body,
109
+ });
110
+ await flushPromises();
111
+
112
+ // Open the filter dropdown — it's the second input in the component.
113
+ const inputs = wrapper.findAll("input");
114
+ expect(inputs.length).toBe(2);
115
+ await inputs[1].trigger("focus");
116
+
117
+ // Options are: [No filter, Top 1000, High quality]. Pick "Top 1000" (index 1).
118
+ await pickOption(1);
119
+
120
+ const emitted = wrapper.emitted("update:modelValue");
121
+ expect(emitted).toBeDefined();
122
+ const last = emitted![emitted!.length - 1][0] as PrimaryRef;
123
+ expect(last).toEqual({ __isPrimaryRef: "v1", column: datasetA, filter: filterA1 });
124
+ wrapper.unmount();
125
+ });
126
+
127
+ it("emits PrimaryRef with filter: undefined when 'No filter' is picked", async () => {
128
+ const wrapper = mount(PlDatasetSelector, {
129
+ props: {
130
+ modelValue: createPrimaryRef(datasetA, filterA1),
131
+ options: optionsWithFilters,
132
+ "onUpdate:modelValue": (e) => wrapper.setProps({ modelValue: e }),
133
+ },
134
+ attachTo: document.body,
135
+ });
136
+ await flushPromises();
137
+
138
+ const inputs = wrapper.findAll("input");
139
+ await inputs[1].trigger("focus");
140
+ // Pick "No filter" (index 0).
141
+ await pickOption(0);
142
+
143
+ const emitted = wrapper.emitted("update:modelValue");
144
+ expect(emitted).toBeDefined();
145
+ const last = emitted![emitted!.length - 1][0] as PrimaryRef;
146
+ expect(last).toEqual({ __isPrimaryRef: "v1", column: datasetA });
147
+ expect("filter" in last).toBe(false);
148
+ wrapper.unmount();
149
+ });
150
+
151
+ it("accepts plain PlRef as modelValue for backward compat (filterless dataset)", async () => {
152
+ const wrapper = mount(PlDatasetSelector, {
153
+ props: { modelValue: datasetB, options: optionsWithFilters },
154
+ attachTo: document.body,
155
+ });
156
+ await flushPromises();
157
+
158
+ expect(wrapper.find(".pl-dataset-selector").element.children.length).toBe(1);
159
+ wrapper.unmount();
160
+ });
161
+
162
+ it("accepts plain PlRef as modelValue for backward compat (dataset with filters)", async () => {
163
+ const wrapper = mount(PlDatasetSelector, {
164
+ props: { modelValue: datasetA, options: optionsWithFilters },
165
+ attachTo: document.body,
166
+ });
167
+ await flushPromises();
168
+
169
+ // PlRef matching dataset A — filter dropdown should appear since A has filters.
170
+ expect(wrapper.find(".pl-dataset-selector").element.children.length).toBe(2);
171
+ wrapper.unmount();
172
+ });
173
+
174
+ it("hides filter dropdown when dataset has filters: [] (empty array)", async () => {
175
+ const wrapper = mount(PlDatasetSelector, {
176
+ props: { modelValue: createPrimaryRef(datasetC), options: optionsWithEmptyFilters },
177
+ attachTo: document.body,
178
+ });
179
+ await flushPromises();
180
+
181
+ expect(wrapper.find(".pl-dataset-selector").element.children.length).toBe(1);
182
+ wrapper.unmount();
183
+ });
184
+
185
+ it("filter dropdown defaults to 'No filter' when dataset has filters but none selected", async () => {
186
+ const wrapper = mount(PlDatasetSelector, {
187
+ props: {
188
+ modelValue: createPrimaryRef(datasetA),
189
+ options: optionsWithFilters,
190
+ "onUpdate:modelValue": (e) => wrapper.setProps({ modelValue: e }),
191
+ },
192
+ attachTo: document.body,
193
+ });
194
+ await flushPromises();
195
+
196
+ // No emission on mount — the component does not auto-select a filter.
197
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
198
+
199
+ // Filter dropdown is visible.
200
+ const inputs = wrapper.findAll("input");
201
+ expect(inputs.length).toBe(2);
202
+
203
+ // Open filter dropdown and verify "No filter" is the first option.
204
+ await inputs[1].trigger("focus");
205
+ const items = document.body.querySelectorAll(".dropdown-list-item");
206
+ expect(items.length).toBe(3); // No filter, Top 1000, High quality
207
+ expect(items[0].textContent).toContain("No filter");
208
+ wrapper.unmount();
209
+ });
210
+
211
+ it("emits PrimaryRef without filter key when selecting a filterless dataset", async () => {
212
+ const wrapper = mount(PlDatasetSelector, {
213
+ props: {
214
+ modelValue: undefined,
215
+ options: optionsNoFilters,
216
+ "onUpdate:modelValue": (e) => wrapper.setProps({ modelValue: e }),
217
+ },
218
+ attachTo: document.body,
219
+ });
220
+ await flushPromises();
221
+
222
+ const inputs = wrapper.findAll("input");
223
+ await inputs[0].trigger("focus");
224
+ await pickOption(0);
225
+
226
+ const emitted = wrapper.emitted("update:modelValue");
227
+ expect(emitted).toBeDefined();
228
+ const last = emitted![emitted!.length - 1][0] as PrimaryRef;
229
+ expect(last).toEqual({ __isPrimaryRef: "v1", column: datasetB });
230
+ expect("filter" in last).toBe(false);
231
+ wrapper.unmount();
232
+ });
233
+
234
+ it("emits undefined when cleared via the dataset dropdown", async () => {
235
+ const wrapper = mount(PlDatasetSelector, {
236
+ props: {
237
+ modelValue: createPrimaryRef(datasetA, filterA1),
238
+ options: optionsWithFilters,
239
+ clearable: true,
240
+ "onUpdate:modelValue": (e) => wrapper.setProps({ modelValue: e }),
241
+ },
242
+ attachTo: document.body,
243
+ });
244
+ await flushPromises();
245
+
246
+ // PlDropdown's clear button carries the ".clear" class.
247
+ const clearBtn = wrapper.find(".clear");
248
+ expect(clearBtn.exists()).toBe(true);
249
+ await clearBtn.trigger("click");
250
+
251
+ const emitted = wrapper.emitted("update:modelValue");
252
+ expect(emitted).toBeDefined();
253
+ const last = emitted![emitted!.length - 1][0];
254
+ expect(last).toBeUndefined();
255
+ wrapper.unmount();
256
+ });
257
+ });
@@ -0,0 +1 @@
1
+ export { default as PlDatasetSelector } from "./PlDatasetSelector.vue";
@@ -22,11 +22,8 @@ import {
22
22
  getPluginData,
23
23
  isPluginOutputKey,
24
24
  pluginOutputPrefix,
25
- createNodeServiceProxy,
26
- buildServices,
27
25
  } from "@platforma-sdk/model";
28
26
  import { type UiServices as AllUiServices } from "@milaboratories/pl-model-common";
29
- import { createUiServiceRegistry } from "./service_factories";
30
27
  import type { Ref } from "vue";
31
28
  import { reactive, computed, ref, markRaw } from "vue";
32
29
  import type { OutputValues, OutputErrors, AppSettings } from "../types";
@@ -36,6 +33,8 @@ import { applyPatch } from "fast-json-patch";
36
33
  import { UpdateSerializer } from "./UpdateSerializer";
37
34
  import { watchIgnorable } from "@vueuse/core";
38
35
  import type { PluginState, PluginAccess } from "../usePlugin";
36
+ import { logDebug, logError } from "./utils";
37
+ import { getServices } from "./getServices";
39
38
 
40
39
  export const patchPoolingDelay = 150;
41
40
 
@@ -53,14 +52,6 @@ export const createNextAuthorMarker = (marker: AuthorMarker | undefined): Author
53
52
  localVersion: (marker?.localVersion ?? 0) + 1,
54
53
  });
55
54
 
56
- const stringifyForDebug = (v: unknown) => {
57
- try {
58
- return JSON.stringify(v, null, 2);
59
- } catch (err) {
60
- return err instanceof Error ? err.message : String(err);
61
- }
62
- };
63
-
64
55
  /**
65
56
  * Creates an application instance with reactive state management, outputs, and methods for state updates and navigation.
66
57
  *
@@ -87,25 +78,8 @@ export function createAppV3<
87
78
  platforma: PlatformaExtended<PlatformaV3<Data, Args, Outputs, Href, Plugins, UiServices>>,
88
79
  settings: AppSettings,
89
80
  ) {
90
- const debug = (msg: string, ...rest: unknown[]) => {
91
- if (settings.debug) {
92
- console.log(
93
- `%c>>> %c${msg}`,
94
- "color: orange; font-weight: bold",
95
- "color: orange",
96
- ...rest.map((r) => stringifyForDebug(r)),
97
- );
98
- }
99
- };
100
-
101
- const error = (msg: string, ...rest: unknown[]) => {
102
- console.error(
103
- `%c>>> %c${msg}`,
104
- "color: red; font-weight: bold",
105
- "color: red",
106
- ...rest.map((r) => stringifyForDebug(r)),
107
- );
108
- };
81
+ const debug = settings.debug ? logDebug : () => {};
82
+ const error = logError;
109
83
 
110
84
  const data = {
111
85
  isExternalSnapshot: false,
@@ -230,11 +204,12 @@ export function createAppV3<
230
204
  (async () => {
231
205
  window.addEventListener("beforeunload", () => {
232
206
  closedRef.value = true;
233
- Promise.allSettled([uiRegistry.dispose(), platforma.dispose().then(unwrapResult)]).catch(
234
- (err) => {
207
+ platforma
208
+ .dispose()
209
+ .then(unwrapResult)
210
+ .catch((err) => {
235
211
  error("error in dispose", err);
236
- },
237
- );
212
+ });
238
213
  });
239
214
 
240
215
  while (!closedRef.value) {
@@ -334,9 +309,7 @@ export function createAppV3<
334
309
  },
335
310
  };
336
311
 
337
- const proxy = createNodeServiceProxy(platforma.serviceDispatch);
338
- const uiRegistry = createUiServiceRegistry({ proxy });
339
- const services = buildServices<UiServices>(platforma.serviceDispatch, uiRegistry);
312
+ const services = getServices<UiServices>({ platforma });
340
313
 
341
314
  /** Creates a lazily-cached per-plugin reactive state. */
342
315
  const createPluginState = <F extends PluginFactoryLike>(
@@ -0,0 +1,36 @@
1
+ import {
2
+ BlockDefaultUiServices,
3
+ buildServices,
4
+ createServiceProxy,
5
+ getRawPlatformaInstance,
6
+ PlatformaV3,
7
+ UiServices,
8
+ } from "@platforma-sdk/model";
9
+ import { createUiServiceRegistry } from "./service_factories";
10
+ import { isNil } from "es-toolkit";
11
+ import { logError } from "./utils";
12
+
13
+ let cachedServices: null | Partial<UiServices> = null;
14
+
15
+ export function getServices<Services extends Partial<UiServices> = BlockDefaultUiServices>(deps?: {
16
+ platforma?: PlatformaV3<any, any, any, any, any, Services>;
17
+ }): Services {
18
+ if (!isNil(cachedServices)) {
19
+ return cachedServices as Services;
20
+ }
21
+
22
+ const platforma =
23
+ deps?.platforma ??
24
+ (getRawPlatformaInstance() as PlatformaV3<any, any, any, any, any, Services>);
25
+ const proxy = createServiceProxy(platforma.serviceDispatch);
26
+ const uiRegistry = createUiServiceRegistry({ proxy });
27
+ const services = buildServices<Services>(platforma.serviceDispatch, uiRegistry);
28
+
29
+ window.addEventListener("beforeunload", () => {
30
+ uiRegistry.dispose().catch((err) => {
31
+ logError("uiRegistry error in dispose", err);
32
+ });
33
+ });
34
+
35
+ return (cachedServices = services) as Services;
36
+ }
@@ -9,10 +9,10 @@
9
9
 
10
10
  import { Services, UiServiceRegistry } from "@milaboratories/pl-model-common";
11
11
  import { SpecDriver } from "@milaboratories/pf-spec-driver";
12
- import type { NodeServiceProxy } from "@platforma-sdk/model";
12
+ import type { ServiceProxy } from "@platforma-sdk/model";
13
13
 
14
14
  export type UiServiceOptions = {
15
- proxy: NodeServiceProxy;
15
+ proxy: ServiceProxy;
16
16
  };
17
17
 
18
18
  export function createUiServiceRegistry(options: UiServiceOptions) {
@@ -0,0 +1,25 @@
1
+ export const logDebug = (msg: string, ...rest: unknown[]) => {
2
+ console.log(
3
+ `%c>>> %c${msg}`,
4
+ "color: orange; font-weight: bold",
5
+ "color: orange",
6
+ ...rest.map((r) => stringifyForDebug(r)),
7
+ );
8
+ };
9
+
10
+ export const logError = (msg: string, ...rest: unknown[]) => {
11
+ console.error(
12
+ `%c>>> %c${msg}`,
13
+ "color: red; font-weight: bold",
14
+ "color: red",
15
+ ...rest.map((r) => stringifyForDebug(r)),
16
+ );
17
+ };
18
+
19
+ const stringifyForDebug = (v: unknown) => {
20
+ try {
21
+ return JSON.stringify(v, null, 2);
22
+ } catch (err) {
23
+ return err instanceof Error ? err.message : String(err);
24
+ }
25
+ };
package/src/lib.ts CHANGED
@@ -33,6 +33,8 @@ export * from "./components/PlBtnExportArchive";
33
33
 
34
34
  export * from "./components/PlAdvancedFilter";
35
35
 
36
+ export * from "./components/PlDatasetSelector";
37
+
36
38
  export * from "./defineApp";
37
39
 
38
40
  export { usePlugin } from "./usePlugin";