From 47c67cfb74d1369ffcfc30cfe184b57da4fe5153 Mon Sep 17 00:00:00 2001 From: Khairul Syazwan Date: Tue, 8 Sep 2026 22:38:08 +0800 Subject: [PATCH 1/5] feat(vue-mri): bulk selection state and the action bar (#3127) Add the selection arithmetic, a page-scoped select-all, a live count and the bulk action bar on the Data Exploration page. Compare and Delete render as stubs; the next subphase wires their behaviour. Select-all acts on the current page, and the selection survives a page change so the user can collect cards from more than one page. The retain watcher therefore reads the whole filtered set, never the rendered page: a watcher on the page ids would drop the selection every time the user turned the page. Selection outranks hover on the card border. `--clickable:hover` is a class plus a pseudo-class, so without an explicit rule it repaints a selected card and the card stops looking selected while the pointer is over it. Extract toCardId into the list helper so the card view model and the bulk selection build the namespaced id the same way and cannot drift apart. --- .../src/components/ExplorationsPage.vue | 113 +++++++++++++++--- .../helpers/__tests__/explorationList.test.ts | 26 +++- .../__tests__/explorationSelection.test.ts | 85 +++++++++++++ .../src/components/helpers/explorationList.ts | 18 +++ .../helpers/explorationSelection.ts | 40 +++++++ .../ui/apps/vue-mri-ui-lib/src/lib/i18n.ts | 10 ++ .../src/stores/__tests__/explorations.test.ts | 75 ++++++++++++ .../vue-mri-ui-lib/src/stores/explorations.ts | 11 ++ .../src/components/D2eExplorationCard.vue | 18 ++- 9 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationSelection.test.ts create mode 100644 plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationSelection.ts create mode 100644 plugins/ui/apps/vue-mri-ui-lib/src/stores/__tests__/explorations.test.ts diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationsPage.vue b/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationsPage.vue index 22e57f25c5..ac957868a3 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationsPage.vue +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationsPage.vue @@ -25,7 +25,33 @@ /> -
+
+ + + {{ selectedCountLabel }} + +
+ + {{ getText('MRI_PA_COMPARE_D2E_COHORT_TEXT') }} + + + {{ getText('MRI_PA_BUTTON_DELETE') }} + +
+
+
import { computed, reactive, ref, watch } from 'vue' import { useStore } from 'vuex' -import { D2eButton, D2eExplorationCard, D2eIconButton, D2eMenu, D2eSelect, D2eTextField } from '@d2e/ui' +import { D2eButton, D2eCheckbox, D2eExplorationCard, D2eIconButton, D2eMenu, D2eSelect, D2eTextField } from '@d2e/ui' import { useExplorationsStore } from '../stores/explorations' import { useNotificationStore } from '../stores/notifications' import { usePortalContext } from '../composables/usePortalContext' @@ -323,7 +349,8 @@ import { isDashboardFlowOpen, shouldResetDashboardFlow, } from './helpers/explorationAnalyze' -import { filterAndSort, type ExplorationSortKey } from './helpers/explorationList' +import { filterAndSort, toCardId, type ExplorationSortKey } from './helpers/explorationList' +import { allSelected, someSelected } from './helpers/explorationSelection' import { applyFilters, authorOptions, emptyFilters, isEmpty, type ExplorationFilters } from './helpers/explorationFilters' import { PAGE_SIZES, clampPage, pageSlice } from './helpers/explorationPaging' import { chartQueryFor } from './helpers/explorationSqlQuery' @@ -416,9 +443,9 @@ const isWizardEnabled = computed( ) const canAnalyze = computed(() => Boolean(store.getters.getCanDatasetMaterializeCohorts) && isWizardEnabled.value) -const getText = (key: string): string => { +const getText = (key: string, param?: string | string[]): string => { const resolver = store.getters.getText - return typeof resolver === 'function' ? resolver(key) : key + return typeof resolver === 'function' ? resolver(key, param) : key } const load = (): void => { @@ -495,16 +522,7 @@ const cards = computed(() => { const bookmark = card.bookmark const cohortDefinition = card.cohortDefinition const atlas = card.atlasCohortDefinition - // Namespaced: a bookmark id and a cohort-definition id come from different - // tables and can collide, and two never-materialized records can share a - // displayName. Either collision makes one checkbox select two cards. - const id = bookmark?.id - ? `bookmark:${bookmark.id}` - : cohortDefinition?.id - ? `cohort:${cohortDefinition.id}` - : atlas?.id - ? `atlas:${atlas.id}` - : `name:${card.displayName}` + const id = toCardId(card) // An Atlas record is a cohort; a D2E bookmark is an exploration. const idLabel = ['A', 'A+M'].includes(getBookmarkType(card)) ? getText('MRI_PA_EXPLORATIONS_COHORT_ID_LABEL') @@ -556,6 +574,31 @@ const cards = computed(() => { }) }) +/* ---- bulk selection --------------------------------------------------- */ + +/** The ids on the current page only. Select-all acts on these. */ +const pageIds = computed(() => cards.value.map(c => c.id)) +/** Every id in the filtered set, across every page. `retain` reads this, never + `pageIds` — a watcher on the page would drop the user's selection on every + page change. */ +const matchedIds = computed(() => matchedCards.value.map(toCardId)) +const allPageSelected = computed(() => allSelected(pageIds.value, explorations.selectedBookmarkIds)) +const somePageSelected = computed(() => someSelected(pageIds.value, explorations.selectedBookmarkIds)) +const selectedCountLabel = computed(() => getText('MRI_PA_EXPLORATIONS_N_SELECTED', String(explorations.selectedCount))) +// Stubs in this subphase. Subphase 2 wires Compare and Delete. +const canCompare = computed(() => explorations.selectedCount >= 2) +const openCompare = (): void => {} +const openBulkDelete = (): void => {} + +// A change to the search, a filter or the sort can drop cards out of the +// matched set; a selected card that leaves it must leave the selection too. +// Watching `matchedIds` (not `pageIds`) is deliberate: `matchedIds` covers +// every page, so turning the page — which changes `pageIds` but not +// `matchedIds` — never fires this and never drops the user's selection. +watch(matchedIds, ids => { + explorations.retain(ids) +}) + /** * Close the panel and put the cohort builder's state back. * @@ -999,6 +1042,46 @@ const onMoreSelect = (card: { source: BookmarkDisplay }, value: string): void => gap: 8px; } + /* Replaces the toolbar row while a selection is live (Figma 1821:433737, + "Frame 7"). Same 60px height as the row it replaces. + + The frame nests the two buttons in their own group (`Frame 2147226911`), + so the row carries 16px between groups and the group carries 8px between + the buttons. Mirroring that nesting keeps both gaps declarative — a flat + row cannot express two gaps without per-child margins. */ + &__bulk { + display: flex; + align-items: center; + gap: var(--d2e-spacing-s); + flex-shrink: 0; + height: 60px; + padding: var(--d2e-spacing-xs-s) var(--d2e-spacing-s); + background: var(--d2e-color-neutral-lightest); + border-top: var(--d2e-border-width-sm) solid var(--d2e-color-neutral-lighter); + border-bottom: var(--d2e-border-width-sm) solid var(--d2e-color-neutral-lighter); + + // D2eButton has no height/padding/shadow prop; its own border-radius + // already defaults to --d2e-radius-md (8px), which matches the frame. + :deep(.d2e-button) { + height: 36px; + padding: var(--d2e-spacing-xs) 22px; + box-shadow: var(--d2e-elevation-e2); + } + } + + &__bulk-actions { + display: flex; + align-items: center; + gap: var(--d2e-spacing-xs); + } + + &__bulk-count { + font-size: var(--d2e-font-body2-size); + font-weight: var(--d2e-font-body2-weight); + line-height: var(--d2e-font-body2-line-height); + color: var(--d2e-color-primary); + } + /* Search is 466x44 with a 1px #ACABA8 border and a 4px radius (Figma 1762:475284). Vuetify's own outlined field is 56px tall. */ &__search { diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationList.test.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationList.test.ts index 707f6861b9..0fe9409ae6 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationList.test.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationList.test.ts @@ -1,8 +1,32 @@ import { describe, it, expect } from 'vitest' -import { filterAndSort, lastUpdatedMs, scoreCard } from '../explorationList' +import { filterAndSort, lastUpdatedMs, scoreCard, toCardId } from '../explorationList' const card = (over: Record = {}) => ({ displayName: 'card', ...over }) as never +describe('toCardId', () => { + it('namespaces a bookmark id', () => { + expect(toCardId(card({ bookmark: { id: '42' } }))).toBe('bookmark:42') + }) + + it('namespaces a cohort-definition id', () => { + expect(toCardId(card({ cohortDefinition: { id: '7' } }))).toBe('cohort:7') + }) + + it('namespaces an atlas id', () => { + expect(toCardId(card({ atlasCohortDefinition: { id: '9' } }))).toBe('atlas:9') + }) + + it('falls back to the display name when no id is available', () => { + expect(toCardId(card({ displayName: 'unsaved' }))).toBe('name:unsaved') + }) + + it('prefers the bookmark id over a cohort or atlas id', () => { + expect( + toCardId(card({ bookmark: { id: '1' }, cohortDefinition: { id: '2' }, atlasCohortDefinition: { id: '3' } })), + ).toBe('bookmark:1') + }) +}) + describe('lastUpdatedMs', () => { it('prefers the bookmark dateModified', () => { const c = card({ diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationSelection.test.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationSelection.test.ts new file mode 100644 index 0000000000..848238340e --- /dev/null +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationSelection.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { allSelected, applyPageSelection, retainIds, someSelected } from '../explorationSelection' + +describe('allSelected', () => { + it('is false for an empty page', () => { + expect(allSelected([], ['a', 'b'])).toBe(false) + }) + + it('is true only when every page id is selected', () => { + expect(allSelected(['a', 'b'], ['a', 'b', 'c'])).toBe(true) + }) + + it('is false when some page ids are missing from the selection', () => { + expect(allSelected(['a', 'b'], ['a'])).toBe(false) + }) + + it('is false when none of the page ids are selected', () => { + expect(allSelected(['a', 'b'], [])).toBe(false) + }) +}) + +describe('someSelected', () => { + it('is false when all page ids are selected (exclusive with allSelected)', () => { + expect(someSelected(['a', 'b'], ['a', 'b'])).toBe(false) + }) + + it('is false when none of the page ids are selected', () => { + expect(someSelected(['a', 'b'], [])).toBe(false) + }) + + it('is true for a mix of selected and unselected cards on the page', () => { + expect(someSelected(['a', 'b'], ['a'])).toBe(true) + }) + + it('is false for an empty page', () => { + expect(someSelected([], ['a'])).toBe(false) + }) +}) + +describe('retainIds', () => { + it('drops an id that left the visible set, and keeps the rest', () => { + expect(retainIds(['a', 'b', 'c'], ['a', 'c'])).toEqual(['a', 'c']) + }) + + it('keeps every id when all are still visible', () => { + expect(retainIds(['a', 'b'], ['a', 'b', 'c'])).toEqual(['a', 'b']) + }) + + it('returns an empty array when nothing survives', () => { + expect(retainIds(['a', 'b'], [])).toEqual([]) + }) + + it('does not mutate its inputs', () => { + const selected = ['a', 'b', 'c'] + const visible = ['a', 'c'] + const result = retainIds(selected, visible) + expect(selected).toEqual(['a', 'b', 'c']) + expect(visible).toEqual(['a', 'c']) + expect(result).not.toBe(selected) + }) +}) + +describe('applyPageSelection', () => { + it('adds the page ids without touching a selection from another page', () => { + expect(applyPageSelection(['x'], ['a', 'b'], true)).toEqual(expect.arrayContaining(['x', 'a', 'b'])) + expect(applyPageSelection(['x'], ['a', 'b'], true)).toHaveLength(3) + }) + + it('removes only the page ids when clearing, keeping a selection from another page', () => { + expect(applyPageSelection(['x', 'a', 'b'], ['a', 'b'], false)).toEqual(['x']) + }) + + it('does not duplicate an id already selected when adding', () => { + expect(applyPageSelection(['a'], ['a', 'b'], true)).toEqual(expect.arrayContaining(['a', 'b'])) + expect(applyPageSelection(['a'], ['a', 'b'], true)).toHaveLength(2) + }) + + it('does not mutate its inputs', () => { + const selected = ['x'] + const pageIds = ['a', 'b'] + applyPageSelection(selected, pageIds, true) + expect(selected).toEqual(['x']) + expect(pageIds).toEqual(['a', 'b']) + }) +}) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationList.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationList.ts index 4d4fff217e..75f0505058 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationList.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationList.ts @@ -15,6 +15,24 @@ const SCORE_NONE = 0 export type ExplorationSortKey = 'lastUpdated' | 'nameAsc' | 'nameDesc' +/** + * The namespaced card id: `bookmark:`, `cohort:`, `atlas:` or + * `name:`. A bookmark id and a cohort-definition id come from + * different tables and can collide, and two never-materialized records can + * share a displayName — either collision would make one checkbox select two + * cards. Shared by the page's card view model and its bulk-selection + * `matchedIds`, so the two never drift apart. + */ +export function toCardId(card): string { + const bookmark = card?.bookmark + const cohortDefinition = card?.cohortDefinition + const atlas = card?.atlasCohortDefinition + if (bookmark?.id) return `bookmark:${bookmark.id}` + if (cohortDefinition?.id) return `cohort:${cohortDefinition.id}` + if (atlas?.id) return `atlas:${atlas.id}` + return `name:${card?.displayName}` +} + const includes = (value: unknown, query: string): boolean => typeof value === 'string' || typeof value === 'number' ? String(value).toLowerCase().includes(query) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationSelection.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationSelection.ts new file mode 100644 index 0000000000..f563e1bc6b --- /dev/null +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationSelection.ts @@ -0,0 +1,40 @@ +/** + * Bulk-selection arithmetic for the Data Exploration grid. Pure functions, no + * Vue import, so the test does not have to load Vuetify or Pinia. + * + * Select-all acts on the current page only (`pageIds`); the selection itself + * spans every page the user has visited (`selectedIds`). See + * `docs/projects/vue-mri-ui/pr10/01-selection-store-and-toolbar.md` section 0.1. + */ + +/** True when every id on the page is selected. False for an empty page. */ +export function allSelected(pageIds: string[], selectedIds: string[]): boolean { + if (pageIds.length === 0) return false + const selected = new Set(selectedIds) + return pageIds.every(id => selected.has(id)) +} + +/** True when the page holds a mix of selected and unselected cards. */ +export function someSelected(pageIds: string[], selectedIds: string[]): boolean { + if (pageIds.length === 0) return false + const selected = new Set(selectedIds) + const selectedCount = pageIds.filter(id => selected.has(id)).length + return selectedCount > 0 && selectedCount < pageIds.length +} + +/** Keep only the ids that are still in `visibleIds`. Returns a new array. */ +export function retainIds(selectedIds: string[], visibleIds: string[]): string[] { + const visible = new Set(visibleIds) + return selectedIds.filter(id => visible.has(id)) +} + +/** Add every page id, or remove every page id. Returns a new array. */ +export function applyPageSelection(selectedIds: string[], pageIds: string[], selected: boolean): string[] { + if (selected) { + const set = new Set(selectedIds) + pageIds.forEach(id => set.add(id)) + return [...set] + } + const page = new Set(pageIds) + return selectedIds.filter(id => !page.has(id)) +} diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/lib/i18n.ts b/plugins/ui/apps/vue-mri-ui-lib/src/lib/i18n.ts index c8f22affc7..370541f000 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/lib/i18n.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/lib/i18n.ts @@ -439,6 +439,8 @@ export const i18n = { MRI_PA_EXPLORATIONS_DATASOURCE: 'Data source', MRI_PA_EXPLORATIONS_SEARCH: 'Search', MRI_PA_EXPLORATIONS_SELECT: 'Select exploration', + MRI_PA_EXPLORATIONS_SELECT_ALL: 'Select all', + MRI_PA_EXPLORATIONS_N_SELECTED: '{0} selected', MRI_PA_EXPLORATIONS_EMPTY: 'No explorations yet', MRI_PA_EXPLORATIONS_EMPTY_BODY: 'Your saved data exploration will appear here.', MRI_PA_EXPLORATIONS_EMPTY_SEARCH_TITLE: 'No matching explorations', @@ -1542,6 +1544,10 @@ export const i18n = { MRI_PA_EXPLORATIONS_DATASOURCE: 'Datenquelle', MRI_PA_EXPLORATIONS_SEARCH: 'Suchen', MRI_PA_EXPLORATIONS_SELECT: 'Exploration auswählen', + // TODO(i18n): needs native review. + MRI_PA_EXPLORATIONS_SELECT_ALL: 'Alle auswählen', + // TODO(i18n): needs native review. + MRI_PA_EXPLORATIONS_N_SELECTED: '{0} ausgewählt', MRI_PA_EXPLORATIONS_EMPTY: 'Noch keine Explorationen', MRI_PA_EXPLORATIONS_LOAD_ERROR: 'Explorationen konnten nicht geladen werden.', MRI_PA_BUTTON_NEW_EXPLORATION: 'Neue Exploration starten', @@ -2619,6 +2625,10 @@ export const i18n = { MRI_PA_EXPLORATIONS_DATASOURCE: '数据源', MRI_PA_EXPLORATIONS_SEARCH: '搜索', MRI_PA_EXPLORATIONS_SELECT: '选择探索', + // TODO(i18n): needs native review. + MRI_PA_EXPLORATIONS_SELECT_ALL: '全选', + // TODO(i18n): needs native review. + MRI_PA_EXPLORATIONS_N_SELECTED: '已选择 {0} 项', MRI_PA_EXPLORATIONS_EMPTY: '暂无探索', MRI_PA_EXPLORATIONS_LOAD_ERROR: '无法加载探索。', MRI_PA_BUTTON_NEW_EXPLORATION: '开始新探索', diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/stores/__tests__/explorations.test.ts b/plugins/ui/apps/vue-mri-ui-lib/src/stores/__tests__/explorations.test.ts new file mode 100644 index 0000000000..2a16abc33e --- /dev/null +++ b/plugins/ui/apps/vue-mri-ui-lib/src/stores/__tests__/explorations.test.ts @@ -0,0 +1,75 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' +import { useExplorationsStore } from '../explorations' + +describe('stores/explorations', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('toggle adds and removes a single id, building a new array', () => { + const store = useExplorationsStore() + + store.toggle('bookmark:1', true) + expect(store.selectedBookmarkIds).toEqual(['bookmark:1']) + + store.toggle('bookmark:1', false) + expect(store.selectedBookmarkIds).toEqual([]) + }) + + it('clear empties the selection', () => { + const store = useExplorationsStore() + store.toggle('bookmark:1', true) + + store.clear() + + expect(store.selectedBookmarkIds).toEqual([]) + }) + + it('setPageSelection adds every page id', () => { + const store = useExplorationsStore() + + store.setPageSelection(['a', 'b'], true) + + expect(store.selectedBookmarkIds).toEqual(expect.arrayContaining(['a', 'b'])) + expect(store.selectedBookmarkIds).toHaveLength(2) + }) + + it('setPageSelection clears only the page ids, keeping a selection from another page', () => { + const store = useExplorationsStore() + store.toggle('x', true) + store.setPageSelection(['a', 'b'], true) + + store.setPageSelection(['a', 'b'], false) + + expect(store.selectedBookmarkIds).toEqual(['x']) + }) + + it('retain drops ids outside the matched set', () => { + const store = useExplorationsStore() + store.setPageSelection(['a', 'b', 'c'], true) + + store.retain(['a', 'c']) + + expect(store.selectedBookmarkIds).toEqual(expect.arrayContaining(['a', 'c'])) + expect(store.selectedBookmarkIds).toHaveLength(2) + }) + + it('selectedCount follows the array length', () => { + const store = useExplorationsStore() + expect(store.selectedCount).toBe(0) + + store.setPageSelection(['a', 'b'], true) + + expect(store.selectedCount).toBe(2) + }) + + it('hasSelection follows the array', () => { + const store = useExplorationsStore() + expect(store.hasSelection).toBe(false) + + store.toggle('a', true) + + expect(store.hasSelection).toBe(true) + }) +}) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/stores/explorations.ts b/plugins/ui/apps/vue-mri-ui-lib/src/stores/explorations.ts index 5a788c29e7..f12ffddb2e 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/stores/explorations.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/stores/explorations.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import { applyPageSelection, retainIds } from '../components/helpers/explorationSelection' // Exploration-only UI state. Deliberately Pinia, not Vuex: Vuex module state is // shared across mounts (see the plan Appendix B), while Pinia is per-mount. @@ -14,6 +15,8 @@ export const useExplorationsStore = defineStore('explorations', { }), getters: { isSelected: state => (id: string) => state.selectedBookmarkIds.includes(id), + selectedCount: state => state.selectedBookmarkIds.length, + hasSelection: state => state.selectedBookmarkIds.length > 0, }, actions: { toggle(id: string, selected: boolean) { @@ -28,5 +31,13 @@ export const useExplorationsStore = defineStore('explorations', { clear() { this.selectedBookmarkIds = [] }, + /** Select or clear every id on the current page. */ + setPageSelection(pageIds: string[], selected: boolean) { + this.selectedBookmarkIds = applyPageSelection(this.selectedBookmarkIds, pageIds, selected) + }, + /** Drop ids that left the filtered set. Pass the matched ids, not the page. */ + retain(visibleIds: string[]) { + this.selectedBookmarkIds = retainIds(this.selectedBookmarkIds, visibleIds) + }, }, }) diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eExplorationCard.vue b/plugins/ui/libs/d2e-ui/src/components/D2eExplorationCard.vue index bba843a6a7..b9ef7c766e 100644 --- a/plugins/ui/libs/d2e-ui/src/components/D2eExplorationCard.vue +++ b/plugins/ui/libs/d2e-ui/src/components/D2eExplorationCard.vue @@ -1,7 +1,10 @@