From f182032cf9e9b4450a384a9edcd6e8dff257c343 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 9 Sep 2026 22:38:57 -0400 Subject: [PATCH 1/8] Add a shared DatasetPicker component Review, Query, Training, Scoring and Pipelines each grew their own way of choosing datasets: an autocomplete, a searchable data table with a plus button, a checkbox table with select all. This adds one component that covers all of them (search field, list of the datasets on offer with an add button per row, select all for whatever the search lists, and an optional browse button for platforms without a listing) so the pages can adopt it in follow-up changes. Nothing uses it yet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W1C4QY6hxjHaUPJfWPfQuu --- .../dive-common/components/DatasetPicker.vue | 219 ++++++++++++++++++ client/dive-common/datasetPicker.spec.ts | 27 +++ client/dive-common/datasetPicker.ts | 38 +++ 3 files changed, 284 insertions(+) create mode 100644 client/dive-common/components/DatasetPicker.vue create mode 100644 client/dive-common/datasetPicker.spec.ts create mode 100644 client/dive-common/datasetPicker.ts diff --git a/client/dive-common/components/DatasetPicker.vue b/client/dive-common/components/DatasetPicker.vue new file mode 100644 index 000000000..5f86382ce --- /dev/null +++ b/client/dive-common/components/DatasetPicker.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/client/dive-common/datasetPicker.spec.ts b/client/dive-common/datasetPicker.spec.ts new file mode 100644 index 000000000..9a7d03417 --- /dev/null +++ b/client/dive-common/datasetPicker.spec.ts @@ -0,0 +1,27 @@ +import { filterDatasetRows, selectableIds } from './datasetPicker'; + +const rows = [ + { + id: 'a', name: 'Amchitka East', type: 'image-sequence', fps: 5, + }, + { + id: 'b', name: 'Bering clip', type: 'video', fps: 30, + }, + { id: 'c', name: 'Caton rig', type: 'multi' }, +]; + +describe('filterDatasetRows', () => { + it('matches any listed field, ignoring case and surrounding space', () => { + expect(filterDatasetRows(rows, ' VIDEO ').map((r) => r.id)).toEqual(['b']); + expect(filterDatasetRows(rows, 'ri').map((r) => r.id)).toEqual(['b', 'c']); + expect(filterDatasetRows(rows, '30', ['fps']).map((r) => r.id)).toEqual(['b']); + expect(filterDatasetRows(rows, '')).toHaveLength(3); + }); +}); + +describe('selectableIds', () => { + it('leaves out what is already selected', () => { + expect(selectableIds(rows, ['b'])).toEqual(['a', 'c']); + expect(selectableIds(filterDatasetRows(rows, 'rig'), ['c'])).toEqual([]); + }); +}); diff --git a/client/dive-common/datasetPicker.ts b/client/dive-common/datasetPicker.ts new file mode 100644 index 000000000..a3b1bc558 --- /dev/null +++ b/client/dive-common/datasetPicker.ts @@ -0,0 +1,38 @@ +/** + * Rows and filtering behind the shared dataset picker, kept free of Vue so + * the search behaviour is testable and identical on every page. + */ + +/** A dataset offered for selection; extra fields feed extra table columns. */ +export interface DatasetPickerRow { + id: string; + name: string; + type?: string; + [column: string]: unknown; +} + +/** + * Rows whose listed fields contain the search text (case-insensitive, + * whitespace-trimmed). An empty search keeps everything. + */ +export function filterDatasetRows( + rows: readonly T[], + search: string, + fields: readonly string[] = ['name', 'type'], +): T[] { + const needle = search.trim().toLowerCase(); + if (!needle) return [...rows]; + return rows.filter((row) => fields.some((field) => { + const value = row[field]; + return value !== undefined && value !== null && String(value).toLowerCase().includes(needle); + })); +} + +/** Ids of the listed rows not yet selected: what "select all" adds. */ +export function selectableIds( + rows: readonly T[], + selectedIds: readonly string[], +): string[] { + const selected = new Set(selectedIds); + return rows.filter((row) => !selected.has(row.id)).map((row) => row.id); +} From 055661a0c7f86c7fdba73083a2689ea2d99e70c7 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 9 Sep 2026 22:44:58 -0400 Subject: [PATCH 2/8] Use DatasetPicker on the Training and Pipelines pages Both pages offered their datasets through their own data table with a plus button (Pipelines also had its own search and select all); they now share the picker, which keeps the fps column and Training's view button through the picker's headers and row-actions slot. Training's staged items are typed as the cache rows they always were. The picker's search field is clearable, which yields null; filter on an empty string in that case. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W1C4QY6hxjHaUPJfWPfQuu --- .../dive-common/components/DatasetPicker.vue | 12 ++- client/dive-common/datasetPicker.spec.ts | 1 + client/dive-common/datasetPicker.ts | 5 +- .../frontend/components/MultiPipeline.vue | 91 +++---------------- .../frontend/components/MultiTrainingMenu.vue | 82 ++++++----------- 5 files changed, 54 insertions(+), 137 deletions(-) diff --git a/client/dive-common/components/DatasetPicker.vue b/client/dive-common/components/DatasetPicker.vue index 5f86382ce..5d3d0a94a 100644 --- a/client/dive-common/components/DatasetPicker.vue +++ b/client/dive-common/components/DatasetPicker.vue @@ -66,10 +66,11 @@ export default defineComponent({ }, }, setup(props, { emit }) { - const search = ref(''); + /** Null when the field's clear button is used. */ + const search = ref(''); const searchFields = computed(() => props.headers.map((h) => h.value)); - const listed = computed(() => filterDatasetRows(props.items, search.value, searchFields.value)); + const listed = computed(() => filterDatasetRows(props.items, search.value ?? '', searchFields.value)); const addable = computed(() => selectableIds(listed.value, props.selectedIds)); const selected = computed(() => new Set(props.selectedIds)); @@ -80,6 +81,10 @@ export default defineComponent({ }, ]); + function rowClass(item: DatasetPickerRow) { + return selected.value.has(item.id) ? 'picker-row-selected' : ''; + } + function addAll() { if (addable.value.length) emit('add-many', addable.value); } @@ -90,6 +95,7 @@ export default defineComponent({ addable, selected, tableHeaders, + rowClass, addAll, clientSettings, itemsPerPageOptions, @@ -172,7 +178,7 @@ export default defineComponent({ :footer-props="{ itemsPerPageOptions }" :hide-default-footer="compact && listed.length <= clientSettings.rowsPerPage" :no-data-text="items.length ? 'Nothing matches the search.' : noDataText" - :item-class="(item) => (selected.has(item.id) ? 'picker-row-selected' : '')" + :item-class="rowClass" class="picker-table" > diff --git a/client/platform/desktop/frontend/components/MultiTrainingMenu.vue b/client/platform/desktop/frontend/components/MultiTrainingMenu.vue index cf38b2cac..62b194a55 100644 --- a/client/platform/desktop/frontend/components/MultiTrainingMenu.vue +++ b/client/platform/desktop/frontend/components/MultiTrainingMenu.vue @@ -12,11 +12,12 @@ import { watch, } from 'vue'; import { - DatasetConfig, Pipelines, TrainingConfigs, useApi, Pipe, + Pipelines, TrainingConfigs, useApi, Pipe, } from 'dive-common/apispec'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { itemsPerPageOptions, simplifyTrainingName } from 'dive-common/constants'; import { clientSettings } from 'dive-common/store/settings'; +import DatasetPicker from 'dive-common/components/DatasetPicker.vue'; import { useRoute, useRouter } from 'vue-router/composables'; import { DesktopJob, RunTraining } from 'platform/desktop/constants'; @@ -31,6 +32,7 @@ function joinPath(dir: string, filename: string) { } export default defineComponent({ + components: { DatasetPicker }, setup() { const { runTraining, getPipelineList, deleteTrainedPipeline, getTrainingConfigurations, exportTrainedPipeline, @@ -128,7 +130,7 @@ export default defineComponent({ ]; const data = reactive({ - stagedItems: {} as Record, + stagedItems: {} as Record, trainingOutputName: '', selectedTrainingConfig: 'foo.whatever', fineTuneTraining: false, @@ -186,7 +188,7 @@ export default defineComponent({ return []; }); - function toggleStaged(meta: DatasetConfig) { + function toggleStaged(meta: JsonConfigCache) { if (data.stagedItems[meta.id]) { del(data.stagedItems, meta.id); } else { @@ -194,16 +196,18 @@ export default defineComponent({ } } const availableItems = computed(() => Object.values(datasets.value) - .filter((item) => item.subType === null) - .map((item) => ({ - ...item, - included: item.id in data.stagedItems, - }))); + .filter((item) => item.subType === null)); const stagedItems = computed(() => Object.values(data.stagedItems)); - function getAvailableItemClass({ included }: JsonConfigCache & { included: boolean }) { - return included ? 'disabled-row' : ''; + const stagedIds = computed(() => Object.keys(data.stagedItems)); + + /** Stage the picked datasets that are not staged yet. */ + function stageIds(ids: string[]) { + ids.forEach((id) => { + const meta = datasets.value[id]; + if (meta && !data.stagedItems[id]) toggleStaged(meta); + }); } const isReadyToTrain = computed(() => ( @@ -343,7 +347,8 @@ export default defineComponent({ labelFile, clearLabelText, toggleStaged, - getAvailableItemClass, + stagedIds, + stageIds, deleteModel, exportModel, simplifyTrainingName, @@ -375,17 +380,7 @@ export default defineComponent({ }, available: { items: availableItems, - headers: headersTmpl.concat({ - text: 'View', - value: 'view', - sortable: false, - width: 80, - }, { - text: 'Include', - value: 'action', - sortable: false, - width: 80, - }), + headers: headersTmpl, }, staged: { items: stagedItems, @@ -582,37 +577,28 @@ export default defineComponent({ These datasets meet the requirements for the chosen training configuration. - - - - From 6d850ed12848ea9cda6c3c68946c031c7f8f9916 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 9 Sep 2026 22:52:08 -0400 Subject: [PATCH 3/8] DatasetPicker: remove all, per-row remove, and hold the list in place Selected rows now show a clickable check that removes them, and a "Remove all (n)" button drops every listed selection, mirroring select all. Adding or removing keeps the picker at the same place on screen: the pages list the selection above it, so growing that list used to push the picker down under the user's pointer. Training and Pipelines section titles and descriptions lose their card padding so they line up with the tables and the picker under them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W1C4QY6hxjHaUPJfWPfQuu --- .../dive-common/components/DatasetPicker.vue | 107 ++++++++++++++++-- .../frontend/components/MultiPipeline.vue | 26 +++-- .../frontend/components/MultiTrainingMenu.vue | 25 ++-- 3 files changed, 134 insertions(+), 24 deletions(-) diff --git a/client/dive-common/components/DatasetPicker.vue b/client/dive-common/components/DatasetPicker.vue index 5d3d0a94a..1e3581711 100644 --- a/client/dive-common/components/DatasetPicker.vue +++ b/client/dive-common/components/DatasetPicker.vue @@ -1,6 +1,6 @@