diff --git a/client/dive-common/components/DatasetPicker.vue b/client/dive-common/components/DatasetPicker.vue new file mode 100644 index 000000000..1750a4afe --- /dev/null +++ b/client/dive-common/components/DatasetPicker.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/client/dive-common/datasetPicker.spec.ts b/client/dive-common/datasetPicker.spec.ts new file mode 100644 index 000000000..89c8f93c5 --- /dev/null +++ b/client/dive-common/datasetPicker.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { datasetTypeOptions, 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 names only by default, ignoring case and surrounding space', () => { + expect(filterDatasetRows(rows, ' bering ').map((r) => r.id)).toEqual(['b']); + expect(filterDatasetRows(rows, 'ri').map((r) => r.id)).toEqual(['b', 'c']); + expect(filterDatasetRows(rows, 'video')).toHaveLength(0); + expect(filterDatasetRows(rows, '')).toHaveLength(3); + expect(filterDatasetRows(rows, null)).toHaveLength(3); + }); + + it('keeps rows whose type equals the type filter, ignoring case and space', () => { + expect(filterDatasetRows(rows, '', ['name'], ' VIDEO ').map((r) => r.id)).toEqual(['b']); + expect(filterDatasetRows(rows, '', ['name'], 'image-sequence').map((r) => r.id)).toEqual(['a']); + expect(filterDatasetRows(rows, '', ['name'], null)).toHaveLength(3); + }); + + it('applies search and type filter together', () => { + expect(filterDatasetRows(rows, 'ri', ['name'], 'video').map((r) => r.id)).toEqual(['b']); + expect(filterDatasetRows(rows, 'ri', ['name'], 'multi').map((r) => r.id)).toEqual(['c']); + expect(filterDatasetRows(rows, 'Amchitka', ['name'], 'video')).toHaveLength(0); + }); +}); + +describe('datasetTypeOptions', () => { + it('lists distinct types in sorted order', () => { + expect(datasetTypeOptions(rows)).toEqual(['image-sequence', 'multi', 'video']); + }); +}); + +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..c214b4b80 --- /dev/null +++ b/client/dive-common/datasetPicker.ts @@ -0,0 +1,58 @@ +/** + * Rows and filtering behind the shared dataset picker, kept free of Vue so + * the search and type-filter behaviour is testable and identical on every page. + */ + +/** A dataset offered for selection; any extra fields can feed extra table columns. */ +export interface DatasetPickerRow { + id: string; + name: string; + type?: string; +} + +/** + * Rows matching an optional type equality filter and an optional substring + * search across the listed fields (both case-insensitive, whitespace-trimmed). + * Search defaults to the name only; type is narrowed separately via typeFilter. + * Empty / null filters keep everything for that criterion. + */ +export function filterDatasetRows( + rows: readonly T[], + search: string | null | undefined = '', + fields: readonly string[] = ['name'], + typeFilter: string | null | undefined = null, +): T[] { + const typeNeedle = (typeFilter ?? '').trim().toLowerCase(); + const searchNeedle = (search ?? '').trim().toLowerCase(); + if (!typeNeedle && !searchNeedle) return [...rows]; + return rows.filter((row) => { + if (typeNeedle && (row.type ?? '').toLowerCase() !== typeNeedle) { + return false; + } + if (!searchNeedle) return true; + return fields.some((field) => { + const value = (row as unknown as Record)[field]; + return value !== undefined && value !== null && String(value).toLowerCase().includes(searchNeedle); + }); + }); +} + +/** Distinct type values present in the rows, sorted for a stable select list. */ +export function datasetTypeOptions( + rows: readonly T[], +): string[] { + const types = new Set(); + rows.forEach((row) => { + if (row.type) types.add(row.type); + }); + return [...types].sort((a, b) => a.localeCompare(b)); +} + +/** 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); +} diff --git a/client/platform/desktop/frontend/components/MultiPipeline.vue b/client/platform/desktop/frontend/components/MultiPipeline.vue index 5e7399611..7f9c7f6b4 100644 --- a/client/platform/desktop/frontend/components/MultiPipeline.vue +++ b/client/platform/desktop/frontend/components/MultiPipeline.vue @@ -11,7 +11,6 @@ import { useRoute, useRouter } from 'vue-router/composables'; import { Pipe, Pipelines, useApi } from 'dive-common/apispec'; import { parentDatasetId } from 'dive-common/compositeDatasetId'; import { - itemsPerPageOptions, stereoPipelineMarker, multiCamPipelineMarkers, MultiType, @@ -23,6 +22,7 @@ import { pipelineRequiresCalibration, } from 'dive-common/pipelineCalibration'; import PipelineCalibrationWarningIcon from 'dive-common/components/PipelineCalibrationWarningIcon.vue'; +import DatasetPicker from 'dive-common/components/DatasetPicker.vue'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { clientSettings } from 'dive-common/store/settings'; import { datasets, JsonConfigCache } from '../store/dataset'; @@ -80,14 +80,6 @@ const headersTmpl: DataTableHeader[] = [ width: 80, }, ]; -const availableDatasetHeaders = headersTmpl.concat( - { - text: 'Include', - value: 'include', - sortable: false, - width: 80, - }, -); const stagedDatasetHeaders: DataTableHeader[] = headersTmpl.concat([ { text: 'Remove', @@ -113,10 +105,8 @@ function computeOutputDatasetName(item: JsonConfigCache) { const timeStamp = (new Date()).toISOString().replace(/[:.]/g, '-'); return `${selectedPipeline.value?.name}_${item.name}_${timeStamp}`; } +/** Every dataset, narrowed to stereo ones once a measurement pipeline type is chosen. */ function getAvailableItems(): JsonConfigCache[] { - if (!selectedPipelineType.value || !selectedPipeline.value) { - return []; - } if (selectedPipelineType.value === stereoPipelineMarker) { // Only allow stereo datasets to be included for bulk pipeline // operations if the selected pipeline type is a measurement. @@ -126,10 +116,12 @@ function getAvailableItems(): JsonConfigCache[] { } return Object.values(datasets.value); } -const availableItems: Ref = ref([]); -const availableDatasetSearch = ref(''); +const availableItems = computed(() => getAvailableItems()); const stagedDatasetIds: Ref = ref([]); const stagedDatasets = computed(() => availableItems.value.filter((item: JsonConfigCache) => stagedDatasetIds.value.includes(item.id))); +const stagedParentIds = computed(() => [ + ...new Set(stagedDatasetIds.value.map((id) => parentDatasetId(id))), +]); const calibrationAvailableByDatasetId = ref>({}); async function refreshCalibrationForDatasets(datasetIds: string[]) { @@ -146,8 +138,18 @@ async function refreshCalibrationForDatasets(datasetIds: string[]) { }; } +/** Drop staged ids that the current pipeline type no longer offers (e.g. non-stereo under measurement). */ watch(availableItems, (items) => { - refreshCalibrationForDatasets(items.map((item) => item.id)); + const available = new Set(items.map((item) => item.id)); + const next = stagedDatasetIds.value.filter((id) => available.has(id)); + if (next.length !== stagedDatasetIds.value.length) { + stagedDatasetIds.value = next; + } +}); + +/** Calibration status only for what is staged, not the whole library. */ +watch(stagedDatasetIds, (ids) => { + refreshCalibrationForDatasets(ids); }, { immediate: true }); const runDisabled = computed(() => { @@ -166,49 +168,30 @@ function isPipelineItemDisabledForCalibration(pipe: Pipe) { return pipelineDisabledForMissingCalibration( pipe, calibrationAvailableByDatasetId.value, - availableItems.value.map((dataset) => parentDatasetId(dataset.id)), + stagedParentIds.value, ); } -watch(selectedPipeline, () => { - availableItems.value = getAvailableItems(); -}); function toggleStaged(item: JsonConfigCache) { if (stagedDatasetIds.value.includes(item.id)) { stagedDatasetIds.value = stagedDatasetIds.value.filter((id: string) => id !== item.id); } else { - stagedDatasetIds.value.push(item.id); + stagedDatasetIds.value = stagedDatasetIds.value.concat(item.id); } } -function datasetMatchesSearch(item: JsonConfigCache, search: string) { - if (!search) { - return true; - } - const record = item as unknown as Record; - return headersTmpl.some((header) => { - const value = String(record[header.value] ?? '').toLowerCase(); - return value.includes(search); - }); +/** Stage the picked datasets that are not staged yet. */ +function stageIds(ids: string[]) { + const staged = new Set(stagedDatasetIds.value); + stagedDatasetIds.value = stagedDatasetIds.value.concat(ids.filter((id) => !staged.has(id))); } -/* Mirrors the table's default search so "select all" only stages what is listed. */ -const unstagedSearchMatches = computed(() => { - const search = availableDatasetSearch.value.trim().toLowerCase(); - return availableItems.value.filter((item) => ( - !stagedDatasetIds.value.includes(item.id) - && datasetMatchesSearch(item, search) - )); -}); -function stageAllAvailable() { - stagedDatasetIds.value = stagedDatasetIds.value.concat( - unstagedSearchMatches.value.map((item) => item.id), - ); +function unstageIds(ids: string[]) { + const dropped = new Set(ids); + stagedDatasetIds.value = stagedDatasetIds.value.filter((id) => !dropped.has(id)); } async function runPipelineForDatasets() { const pipeline = selectedPipeline.value; if (pipeline !== null) { - // Only the staged datasets compatible with (and displayed for) the - // selected pipeline; staged ids can hold datasets other pipelines accept. const runIds = stagedDatasets.value.map((item: JsonConfigCache) => item.id); const results = await Promise.allSettled( runIds.map((datasetId: string) => { @@ -243,7 +226,6 @@ async function runPipelineForDatasets() { onBeforeMount(async () => { stagedDatasetIds.value = preselectedDatasetIds(); unsortedPipelines.value = await getPipelineList(); - availableItems.value = getAvailableItems(); }); @@ -251,16 +233,18 @@ onBeforeMount(async () => {