@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.
- package/.turbo/turbo-build.log +21 -15
- package/.turbo/turbo-formatter$colon$check.log +2 -2
- package/.turbo/turbo-linter$colon$check.log +2 -2
- package/.turbo/turbo-types$colon$check.log +1 -1
- package/CHANGELOG.md +26 -0
- package/dist/__tests__/setup.d.ts +2 -0
- package/dist/__tests__/setup.d.ts.map +1 -0
- package/dist/components/PlAdvancedFilter/FilterEditor.vue2.js +20 -20
- package/dist/components/PlAgColumnHeader/PlAgColumnHeader.js.map +1 -1
- package/dist/components/PlAgColumnHeader/PlAgColumnHeader.vue.d.ts.map +1 -1
- package/dist/components/PlAgColumnHeader/PlAgColumnHeader.vue2.js +43 -33
- package/dist/components/PlAgColumnHeader/PlAgColumnHeader.vue2.js.map +1 -1
- package/dist/components/PlAgColumnHeader/types.d.ts +1 -0
- package/dist/components/PlAgColumnHeader/types.d.ts.map +1 -1
- package/dist/components/PlAgDataTable/compositions/useFilterableColumns.js +5 -5
- package/dist/components/PlAgDataTable/sources/table-source-v2.d.ts.map +1 -1
- package/dist/components/PlAgDataTable/sources/table-source-v2.js +10 -11
- package/dist/components/PlAgDataTable/sources/table-source-v2.js.map +1 -1
- package/dist/components/PlAgDataTable/sources/table-state-v2.js +13 -13
- package/dist/components/PlDatasetSelector/PlDatasetSelector.js +9 -0
- package/dist/components/PlDatasetSelector/PlDatasetSelector.js.map +1 -0
- package/dist/components/PlDatasetSelector/PlDatasetSelector.style.css +1 -0
- package/dist/components/PlDatasetSelector/PlDatasetSelector.vue.d.ts +105 -0
- package/dist/components/PlDatasetSelector/PlDatasetSelector.vue.d.ts.map +1 -0
- package/dist/components/PlDatasetSelector/PlDatasetSelector.vue2.js +111 -0
- package/dist/components/PlDatasetSelector/PlDatasetSelector.vue2.js.map +1 -0
- package/dist/components/PlDatasetSelector/__tests__/PlDatasetSelector.jsdomtest.d.ts +2 -0
- package/dist/components/PlDatasetSelector/__tests__/PlDatasetSelector.jsdomtest.d.ts.map +1 -0
- package/dist/components/PlDatasetSelector/index.d.ts +2 -0
- package/dist/components/PlDatasetSelector/index.d.ts.map +1 -0
- package/dist/components/PlDatasetSelector/index.js +1 -0
- package/dist/components/PlTableFilters/PlTableFiltersV2.vue2.js +8 -8
- package/dist/index.js +15 -14
- package/dist/index.js.map +1 -1
- package/dist/internal/createAppV3.d.ts.map +1 -1
- package/dist/internal/createAppV3.js +74 -83
- package/dist/internal/createAppV3.js.map +1 -1
- package/dist/internal/getServices.d.ts +5 -0
- package/dist/internal/getServices.d.ts.map +1 -0
- package/dist/internal/getServices.js +19 -0
- package/dist/internal/getServices.js.map +1 -0
- package/dist/internal/service_factories.d.ts +2 -2
- package/dist/internal/service_factories.d.ts.map +1 -1
- package/dist/internal/service_factories.js.map +1 -1
- package/dist/internal/utils.d.ts +3 -0
- package/dist/internal/utils.d.ts.map +1 -0
- package/dist/internal/utils.js +16 -0
- package/dist/internal/utils.js.map +1 -0
- package/dist/lib.d.ts +1 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +2 -0
- package/package.json +8 -6
- package/src/__tests__/setup.ts +10 -0
- package/src/components/PlAgColumnHeader/PlAgColumnHeader.vue +13 -7
- package/src/components/PlAgColumnHeader/types.ts +1 -0
- package/src/components/PlAgDataTable/sources/table-source-v2.ts +23 -21
- package/src/components/PlDatasetSelector/PlDatasetSelector.vue +164 -0
- package/src/components/PlDatasetSelector/__tests__/PlDatasetSelector.jsdomtest.ts +257 -0
- package/src/components/PlDatasetSelector/index.ts +1 -0
- package/src/internal/createAppV3.ts +10 -37
- package/src/internal/getServices.ts +36 -0
- package/src/internal/service_factories.ts +2 -2
- package/src/internal/utils.ts +25 -0
- package/src/lib.ts +2 -0
- 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
|
|
67
|
-
const axesKey: PTableKey = axesResultIndices.map((ri) => pTableValue(columns[ri],
|
|
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,
|
|
70
|
+
(acc, field, colIdx) => {
|
|
71
71
|
acc[field.toString() as `${number}`] =
|
|
72
|
-
fieldResultMapping[
|
|
72
|
+
fieldResultMapping[colIdx] === -1
|
|
73
73
|
? PTableHidden
|
|
74
|
-
: pTableValue(columns[fieldResultMapping[
|
|
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,
|
|
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:
|
|
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
|
|
482
|
+
return isNil(idx) || idx === -1 ? null : idx;
|
|
478
483
|
});
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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 =
|
|
91
|
-
|
|
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
|
-
|
|
234
|
-
(
|
|
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
|
|
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 {
|
|
12
|
+
import type { ServiceProxy } from "@platforma-sdk/model";
|
|
13
13
|
|
|
14
14
|
export type UiServiceOptions = {
|
|
15
|
-
proxy:
|
|
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