From 61d9da799001f25fa9ac0ea1f0fae89c1b7e736b Mon Sep 17 00:00:00 2001 From: Khairul Syazwan Date: Thu, 3 Sep 2026 23:26:43 +0800 Subject: [PATCH 1/6] feat(d2e-ui): add D2eDateField and a content slot on D2eMenu D2eDateField is a single date input with a leading calendar icon, for #3150's filter panel, which uses six of them in three From/To pairs. There is no range picker in the design. It composes VTextField, VMenu and VDatePicker rather than using the labs VDateInput. VDateInput exists in the pinned Vuetify 3.12.0, but vuetify's export map only exposes labs as vuetify/labs/, and the app aliases vuetify/components and vuetify/directives and nothing else. A bare labs import from libs/d2e-ui therefore resolves by plain node lookup and breaks the ATLAS isolated install, which puts vuetify under the app rather than above the library. The model value is an ISO YYYY-MM-DD string or null, in and out. The two conversion helpers are a plain .ts file so the test does not mount anything, and they read a Date's local parts instead of calling toISOString(), which returns the previous day east of UTC. D2eMenu gains a default slot so an activator can anchor arbitrary content. With no default slot the items path is unchanged. --- .../d2e-ui/src/__tests__/date-field.test.ts | 59 +++++++ .../src/components/D2eDateField.story.vue | 57 +++++++ .../d2e-ui/src/components/D2eDateField.vue | 156 ++++++++++++++++++ .../ui/libs/d2e-ui/src/components/D2eMenu.vue | 77 +++++---- .../d2e-ui/src/components/dateFieldFormat.ts | 52 ++++++ plugins/ui/libs/d2e-ui/src/index.ts | 2 + 6 files changed, 368 insertions(+), 35 deletions(-) create mode 100644 plugins/ui/libs/d2e-ui/src/__tests__/date-field.test.ts create mode 100644 plugins/ui/libs/d2e-ui/src/components/D2eDateField.story.vue create mode 100644 plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue create mode 100644 plugins/ui/libs/d2e-ui/src/components/dateFieldFormat.ts diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/date-field.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/date-field.test.ts new file mode 100644 index 0000000000..3b15b6fdc4 --- /dev/null +++ b/plugins/ui/libs/d2e-ui/src/__tests__/date-field.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { fromIsoDate, toIsoDate } from "../components/dateFieldFormat"; + +describe("toIsoDate", () => { + it("builds the string from local-date parts, not toISOString()", () => { + expect(toIsoDate(new Date(2026, 0, 31))).toBe("2026-01-31"); + }); + + it("zero-pads the month and day", () => { + expect(toIsoDate(new Date(2026, 8, 3))).toBe("2026-09-03"); + }); + + it("returns null for null and undefined", () => { + expect(toIsoDate(null)).toBeNull(); + expect(toIsoDate(undefined)).toBeNull(); + }); + + it("returns null for an invalid Date", () => { + expect(toIsoDate(new Date(NaN))).toBeNull(); + }); +}); + +describe("fromIsoDate", () => { + it("parses an ISO date to a Date with matching local parts", () => { + const date = fromIsoDate("2026-01-31"); + expect(date).not.toBeNull(); + expect(date?.getFullYear()).toBe(2026); + expect(date?.getMonth()).toBe(0); + expect(date?.getDate()).toBe(31); + }); + + it("returns null for null, empty, and malformed input", () => { + expect(fromIsoDate(null)).toBeNull(); + expect(fromIsoDate(undefined)).toBeNull(); + expect(fromIsoDate("")).toBeNull(); + expect(fromIsoDate("not a date")).toBeNull(); + expect(fromIsoDate("31-01-2026")).toBeNull(); + }); + + it("returns null for a syntactically valid but non-existent date", () => { + expect(fromIsoDate("2026-02-31")).toBeNull(); + }); +}); + +describe("round trip", () => { + const dates = [ + "2026-01-01", + "2026-12-31", + "2026-01-31", + "2024-02-29", // leap day + "2026-09-03", + ]; + + for (const iso of dates) { + it(`toIsoDate(fromIsoDate("${iso}")) === "${iso}"`, () => { + expect(toIsoDate(fromIsoDate(iso))).toBe(iso); + }); + } +}); diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eDateField.story.vue b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.story.vue new file mode 100644 index 0000000000..9871434f40 --- /dev/null +++ b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.story.vue @@ -0,0 +1,57 @@ + + + diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue new file mode 100644 index 0000000000..016310744d --- /dev/null +++ b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eMenu.vue b/plugins/ui/libs/d2e-ui/src/components/D2eMenu.vue index 38131d1105..04f0fdbb47 100644 --- a/plugins/ui/libs/d2e-ui/src/components/D2eMenu.vue +++ b/plugins/ui/libs/d2e-ui/src/components/D2eMenu.vue @@ -10,42 +10,49 @@ - + + + diff --git a/plugins/ui/libs/d2e-ui/src/components/dateFieldFormat.ts b/plugins/ui/libs/d2e-ui/src/components/dateFieldFormat.ts new file mode 100644 index 0000000000..7c9e84d381 --- /dev/null +++ b/plugins/ui/libs/d2e-ui/src/components/dateFieldFormat.ts @@ -0,0 +1,52 @@ +/** + * ISO date conversion helpers for `D2eDateField`. + * + * The model contract is an ISO `YYYY-MM-DD` string or `null`, never a `Date`. + * These two functions are the only place the component converts between the + * two. `Date#toISOString()` is deliberately never used: it converts to UTC + * first, so a local midnight date east of UTC serializes as the previous + * day. Every conversion here works from the `Date`'s local parts instead. + */ + +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** Local-date parts to "YYYY-MM-DD". Never uses toISOString(). */ +export function toIsoDate(date: Date | null | undefined): string | null { + if (!date || Number.isNaN(date.getTime())) return null; + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const yyyy = String(year).padStart(4, "0"); + const mm = String(month).padStart(2, "0"); + const dd = String(day).padStart(2, "0"); + + return `${yyyy}-${mm}-${dd}`; +} + +/** "YYYY-MM-DD" to a Date at local midnight. null for anything unparseable. */ +export function fromIsoDate(value: string | null | undefined): Date | null { + if (!value) return null; + + const match = ISO_DATE_PATTERN.exec(value); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + + const date = new Date(year, month - 1, day); + + // Reject a syntactically valid but non-existent date (e.g. 2026-02-31), + // which `Date` silently rolls forward into the next month. + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + + return date; +} diff --git a/plugins/ui/libs/d2e-ui/src/index.ts b/plugins/ui/libs/d2e-ui/src/index.ts index 71dada00cb..f7f67cadc6 100644 --- a/plugins/ui/libs/d2e-ui/src/index.ts +++ b/plugins/ui/libs/d2e-ui/src/index.ts @@ -10,6 +10,8 @@ export { default as D2eToolbar } from "./components/D2eToolbar.vue"; export { default as D2eExplorationCard } from "./components/D2eExplorationCard.vue"; export { default as D2eSelect } from "./components/D2eSelect.vue"; export { default as D2eCheckbox } from "./components/D2eCheckbox.vue"; +export { default as D2eDateField } from "./components/D2eDateField.vue"; +export { toIsoDate, fromIsoDate } from "./components/dateFieldFormat"; export { VARIANT_MAP, SIZE_MAP } from "./components/buttonVariants"; export type { D2eButtonVariant, From 691283c9dd3be5703dd0e5c6dafd863c7cb11d90 Mon Sep 17 00:00:00 2001 From: Khairul Syazwan Date: Thu, 3 Sep 2026 23:26:44 +0800 Subject: [PATCH 2/6] feat(vue-mri): filter the exploration list by author, status and date Closes the Filters button the exploration toolbar left space for. It opens a 420px panel below it, anchored to its right edge, holding an author multi-select, a materialization-status multi-select and three From/To date pairs. Filtering is client-side; the list endpoint takes no filter parameters and the whole list is already in memory. The order is filter, then search, then sort. The author option list reads the unfiltered list, or selecting one author would remove every other option and the filter could not be widened again. Materialization status is derived from the presence of a cohort definition, the same test getBookmarkType() makes. It yields two options; stale is not derivable today and belongs to #3117. The CREATED pair renders disabled with a note. A bookmark carries only dateModified, so there is nothing to compare against; the predicate honours the range anyway, so the filter starts working once the backend surfaces a creation timestamp. Filtering dateModified and labelling it created would give wrong results that look right. de and zh strings need native review. --- .../components/ExplorationFiltersPanel.vue | 258 ++++++++++++++++++ .../src/components/ExplorationsPage.vue | 60 +++- .../__tests__/explorationFilters.test.ts | 198 ++++++++++++++ .../components/helpers/explorationFilters.ts | 151 ++++++++++ .../icons/ExplorationFilterIcon.vue | 20 ++ .../ui/apps/vue-mri-ui-lib/src/lib/i18n.ts | 39 +++ 6 files changed, 724 insertions(+), 2 deletions(-) create mode 100644 plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue create mode 100644 plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts create mode 100644 plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts create mode 100644 plugins/ui/apps/vue-mri-ui-lib/src/components/icons/ExplorationFilterIcon.vue diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue b/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue new file mode 100644 index 0000000000..708f20f2c4 --- /dev/null +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue @@ -0,0 +1,258 @@ + + + + + + + + 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 3289ee3ae7..06a6b73cd8 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 @@ -35,6 +35,31 @@ :hide-details="true" data-testid="explorations-search" /> + + + + + + +
@@ -208,16 +233,19 @@ import { D2eButton, D2eExplorationCard, D2eIconButton, D2eMenu, D2eSelect, D2eTe import { useExplorationsStore } from '../stores/explorations' import { usePortalContext } from '../composables/usePortalContext' import { filterAndSort, type ExplorationSortKey } from './helpers/explorationList' +import { applyFilters, authorOptions, emptyFilters, type ExplorationFilters } from './helpers/explorationFilters' import { canModifyBookmark, getBookmarkType } from '../utils/BookmarkUtils' import ExplorationMaterializeIcon from './icons/ExplorationMaterializeIcon.vue' import ExplorationDataQualityIcon from './icons/ExplorationDataQualityIcon.vue' import ExplorationFilterSummaryIcon from './icons/ExplorationFilterSummaryIcon.vue' import ExplorationAnalyzeIcon from './icons/ExplorationAnalyzeIcon.vue' import ExplorationSortIcon from './icons/ExplorationSortIcon.vue' +import ExplorationFilterIcon from './icons/ExplorationFilterIcon.vue' import ExplorationMoreIcon from './icons/ExplorationMoreIcon.vue' import AddCohort from './AddCohort.vue' import RenameExplorationDialog from './RenameExplorationDialog.vue' import DeleteExplorationDialog from './DeleteExplorationDialog.vue' +import ExplorationFiltersPanel from './ExplorationFiltersPanel.vue' const emit = defineEmits<{ (e: 'open-exploration', bmkId: string, chartType: string | null): void @@ -259,6 +287,8 @@ const EMPTY_VALUE = '-' const searchQuery = ref('') const sortKey = ref('lastUpdated') +const filters = ref(emptyFilters()) +const filtersOpen = ref(false) const loading = computed(() => store.getters.getBookmarksLoading) const loadError = computed(() => store.getters.getBookmarksLoadError) @@ -289,9 +319,20 @@ const onSortSelect = (value: string): void => { sortKey.value = value as ExplorationSortKey } +/** The raw list, before filtering. Both the filter panel's option list and + the filter step read this, never the filtered result. */ +const allCards = computed(() => store.getters.getDisplayBookmarks(false, portalContext.username) || []) + +/** Every author in the dataset, not only the authors of the visible cards — + otherwise selecting one author removes every other option and the filter + cannot be widened again. */ +const authorNames = computed(() => authorOptions(allCards.value)) + const cards = computed(() => { - const all = store.getters.getDisplayBookmarks(false, portalContext.username) || [] - return filterAndSort(all, searchQuery.value, sortKey.value).map((card: BookmarkDisplay) => { + // Filter, then search, then sort. Searching inside a filtered set is what + // the user expects, and it is cheaper. + const filtered = applyFilters(allCards.value, filters.value) + return filterAndSort(filtered, searchQuery.value, sortKey.value).map((card: BookmarkDisplay) => { const bookmark = card.bookmark const cohortDefinition = card.cohortDefinition const atlas = card.atlasCohortDefinition @@ -607,6 +648,21 @@ const onMoreSelect = (card: { source: BookmarkDisplay }, value: string): void => } } + /* 101x40, 8px radius, 8px gap. `secondary` is outlined in the theme + `primary`; the frame outlines it in Primary/Light (Figma 2634:58660). */ + &__filters { + min-width: 101px; + padding: var(--d2e-spacing-xs) var(--d2e-spacing-xs-s); + + &.v-btn--variant-outlined { + border-color: var(--d2e-color-primary-light); + } + + :deep(.v-btn__prepend) { + margin-inline: 0 var(--d2e-spacing-xs); + } + } + /* Text button: 22px icon, 8px gap, 16px Medium neutral label, no box (Figma 2634:58663). */ &__sort { diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts new file mode 100644 index 0000000000..bfe13812d6 --- /dev/null +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest' +import { + EMPTY_FILTERS, + applyFilters, + authorOptions, + emptyFilters, + isEmpty, + matchesFilters, + type ExplorationFilters, +} from '../explorationFilters' + +const card = (over: Record = {}) => ({ displayName: 'card', ...over }) as never + +describe('emptyFilters / EMPTY_FILTERS', () => { + it('emptyFilters() matches every card and isEmpty is true', () => { + const filters = emptyFilters() + expect(isEmpty(filters)).toBe(true) + expect(matchesFilters(card(), filters)).toBe(true) + expect(matchesFilters(card({ bookmark: { username: 'alice' } }), filters)).toBe(true) + }) + + it('isEmpty is false once any one of the five fields is constrained', () => { + expect(isEmpty({ ...emptyFilters(), authors: ['alice'] })).toBe(false) + expect(isEmpty({ ...emptyFilters(), statuses: ['materialized'] })).toBe(false) + expect(isEmpty({ ...emptyFilters(), created: { from: '2026-01-01', to: null } })).toBe(false) + expect(isEmpty({ ...emptyFilters(), lastUpdated: { from: null, to: '2026-01-01' } })).toBe(false) + expect(isEmpty({ ...emptyFilters(), lastMaterialized: { from: '2026-01-01', to: '2026-01-02' } })).toBe(false) + }) + + it('returns independent objects on every call', () => { + const a = emptyFilters() + const b = emptyFilters() + a.created.from = '2026-01-01' + expect(b.created.from).toBeNull() + expect(EMPTY_FILTERS.created.from).toBeNull() + expect(isEmpty(b)).toBe(true) + }) + + it('EMPTY_FILTERS is frozen', () => { + expect(Object.isFrozen(EMPTY_FILTERS)).toBe(true) + expect(() => { + ;(EMPTY_FILTERS as ExplorationFilters).authors = ['x'] + }).toThrow() + }) +}) + +describe('authorOptions', () => { + it('returns distinct, sorted names, drops blanks and undefined, reads atlas username as fallback', () => { + const cards = [ + card({ bookmark: { username: 'Charlie' } }), + card({ bookmark: { username: 'alice' } }), + card({ bookmark: { username: '' } }), + card({}), + card({ atlasCohortDefinition: { username: 'bob' } }), + card({ bookmark: { username: 'alice' } }), + ] + expect(authorOptions(cards)).toEqual(['alice', 'bob', 'Charlie']) + }) + + it('reads the unfiltered list, not a filtered one', () => { + const cards = [card({ bookmark: { username: 'alice' } }), card({ bookmark: { username: 'bob' } })] + expect(authorOptions(cards)).toEqual(['alice', 'bob']) + }) +}) + +describe('author filter', () => { + const alice = card({ displayName: 'A', bookmark: { username: 'alice' } }) + const bob = card({ displayName: 'B', bookmark: { username: 'bob' } }) + const carol = card({ displayName: 'C', bookmark: { username: 'carol' } }) + + it('one author selected keeps only that owner', () => { + const filters = { ...emptyFilters(), authors: ['alice'] } + expect(applyFilters([alice, bob, carol], filters)).toEqual([alice]) + }) + + it('two authors selected keeps both (OR within the filter)', () => { + const filters = { ...emptyFilters(), authors: ['alice', 'bob'] } + expect(applyFilters([alice, bob, carol], filters)).toEqual([alice, bob]) + }) +}) + +describe('materialisation status filter', () => { + const materialized = card({ displayName: 'M', cohortDefinition: { id: '1' } }) + const notMaterialized = card({ displayName: 'N' }) + + it('materialized keeps only cards with a cohortDefinition', () => { + const filters = { ...emptyFilters(), statuses: ['materialized' as const] } + expect(applyFilters([materialized, notMaterialized], filters)).toEqual([materialized]) + }) + + it('not-materialized is the exact complement', () => { + const filters = { ...emptyFilters(), statuses: ['not-materialized' as const] } + expect(applyFilters([materialized, notMaterialized], filters)).toEqual([notMaterialized]) + }) + + it('both statuses selected is equivalent to neither selected', () => { + const filters = { ...emptyFilters(), statuses: ['materialized' as const, 'not-materialized' as const] } + expect(applyFilters([materialized, notMaterialized], filters)).toEqual([materialized, notMaterialized]) + expect(applyFilters([materialized, notMaterialized], emptyFilters())).toEqual([materialized, notMaterialized]) + }) +}) + +describe('date range semantics (lastMaterialized used as the representative range)', () => { + const dated = (day: string) => card({ displayName: day, cohortDefinition: { createdOn: `${day}T12:00:00` } }) + + it('is inclusive at both ends', () => { + const filters = { ...emptyFilters(), lastMaterialized: { from: '2026-01-01', to: '2026-01-31' } } + expect(matchesFilters(dated('2026-01-01'), filters)).toBe(true) + expect(matchesFilters(dated('2026-01-31'), filters)).toBe(true) + expect(matchesFilters(dated('2025-12-31'), filters)).toBe(false) + expect(matchesFilters(dated('2026-02-01'), filters)).toBe(false) + }) + + it('from alone means on or after', () => { + const filters = { ...emptyFilters(), lastMaterialized: { from: '2026-01-15', to: null } } + expect(matchesFilters(dated('2026-01-15'), filters)).toBe(true) + expect(matchesFilters(dated('2026-06-01'), filters)).toBe(true) + expect(matchesFilters(dated('2026-01-01'), filters)).toBe(false) + }) + + it('to alone means on or before', () => { + const filters = { ...emptyFilters(), lastMaterialized: { from: null, to: '2026-01-15' } } + expect(matchesFilters(dated('2026-01-15'), filters)).toBe(true) + expect(matchesFilters(dated('2025-01-01'), filters)).toBe(true) + expect(matchesFilters(dated('2026-02-01'), filters)).toBe(false) + }) + + it('a card with no materialisation date is dropped by an active range and kept when the range is empty', () => { + const neverMaterialized = card({ displayName: 'never' }) + const activeFilters = { ...emptyFilters(), lastMaterialized: { from: '2026-01-01', to: null } } + expect(matchesFilters(neverMaterialized, activeFilters)).toBe(false) + expect(matchesFilters(neverMaterialized, emptyFilters())).toBe(true) + }) +}) + +describe('AND across filters', () => { + it('author and a date range together are AND, not OR', () => { + const alice = card({ + displayName: 'alice-in-range', + bookmark: { username: 'alice' }, + cohortDefinition: { createdOn: '2026-01-10T00:00:00' }, + }) + const aliceOutOfRange = card({ + displayName: 'alice-out-of-range', + bookmark: { username: 'alice' }, + cohortDefinition: { createdOn: '2025-01-10T00:00:00' }, + }) + const bobInRange = card({ + displayName: 'bob-in-range', + bookmark: { username: 'bob' }, + cohortDefinition: { createdOn: '2026-01-10T00:00:00' }, + }) + + const filters = { + ...emptyFilters(), + authors: ['alice'], + lastMaterialized: { from: '2026-01-01', to: '2026-01-31' }, + } + + expect(applyFilters([alice, aliceOutOfRange, bobInRange], filters)).toEqual([alice]) + }) +}) + +describe('created filter (backend-blocked but plumbed)', () => { + it('works on a synthetic record carrying a creation date', () => { + const withCreated = card({ displayName: 'has-created', bookmark: { dateCreated: '2026-01-10T00:00:00' } }) + const filters = { ...emptyFilters(), created: { from: '2026-01-01', to: '2026-01-31' } } + expect(matchesFilters(withCreated, filters)).toBe(true) + + const outside = card({ displayName: 'outside', bookmark: { dateCreated: '2025-01-10T00:00:00' } }) + expect(matchesFilters(outside, filters)).toBe(false) + }) + + it('falls back to the atlas createdOn when there is no bookmark dateCreated', () => { + const atlas = card({ displayName: 'atlas', atlasCohortDefinition: { createdOn: '2026-01-10T00:00:00' } }) + const filters = { ...emptyFilters(), created: { from: '2026-01-01', to: '2026-01-31' } } + expect(matchesFilters(atlas, filters)).toBe(true) + }) + + it('a card with no created value fails an active created range', () => { + const filters = { ...emptyFilters(), created: { from: '2026-01-01', to: '2026-01-31' } } + expect(matchesFilters(card({ displayName: 'no-created' }), filters)).toBe(false) + }) +}) + +describe('applyFilters', () => { + it('preserves input order and does not mutate its input array', () => { + const a = card({ displayName: 'A', bookmark: { username: 'alice' } }) + const b = card({ displayName: 'B', bookmark: { username: 'bob' } }) + const c = card({ displayName: 'C', bookmark: { username: 'carol' } }) + const input = [c, a, b] + + const result = applyFilters(input, emptyFilters()) + + expect(result.map((x: any) => x.displayName)).toEqual(['C', 'A', 'B']) + expect(input.map((x: any) => x.displayName)).toEqual(['C', 'A', 'B']) + }) +}) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts new file mode 100644 index 0000000000..43498147e0 --- /dev/null +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts @@ -0,0 +1,151 @@ +/** + * Filter state and predicate for the Data Exploration list (PR 8, ticket + * OHDSI/Data2Evidence#3150). Pure functions, no Vue import, so the test does + * not have to load Vuetify. + * + * Record parameters are typed loosely (`unknown` with optional chaining). The + * store getter that produces list rows is untyped and `BookmarkDisplay` from + * `src/types.d.ts` is an ambient global, not an importable module — see + * `explorationList.ts` next door for the same reasoning. + */ + +import { lastUpdatedMs } from './explorationList' + +export type MaterializationStatus = 'materialized' | 'not-materialized' + +export interface DateRange { + from: string | null // ISO "YYYY-MM-DD", inclusive + to: string | null // ISO "YYYY-MM-DD", inclusive +} + +export interface ExplorationFilters { + authors: string[] // empty = no author constraint + statuses: MaterializationStatus[] // empty = no status constraint + created: DateRange + lastUpdated: DateRange + lastMaterialized: DateRange +} + +const emptyRange = (): DateRange => ({ from: null, to: null }) + +/** A fresh, fully-unconstrained filter set. Use this for every reset. */ +export const emptyFilters = (): ExplorationFilters => ({ + authors: [], + statuses: [], + created: emptyRange(), + lastUpdated: emptyRange(), + lastMaterialized: emptyRange(), +}) + +/** + * Frozen reference value. Never spread it — call `emptyFilters()` instead. + * + * A shallow spread (`{ ...EMPTY_FILTERS }`) shares the three nested + * `DateRange` objects by reference with this constant. The first + * `filters.created.from = x` then mutates this module-level object in place, + * after which `isEmpty()` never returns true again and "Clear all" stops + * clearing. `emptyFilters()` exists to avoid that; `Object.freeze` makes a + * future mutation attempt fail loudly instead of silently. + */ +export const EMPTY_FILTERS: Readonly = Object.freeze(emptyFilters()) + +const isEmptyRange = (range: DateRange): boolean => range.from == null && range.to == null + +/** True when nothing is constrained — drives the "Clear all" disabled state. */ +export function isEmpty(filters: ExplorationFilters): boolean { + return ( + filters.authors.length === 0 && + filters.statuses.length === 0 && + isEmptyRange(filters.created) && + isEmptyRange(filters.lastUpdated) && + isEmptyRange(filters.lastMaterialized) + ) +} + +const author = (card: unknown): string | undefined => + (card as any)?.bookmark?.username ?? (card as any)?.atlasCohortDefinition?.username + +/** Distinct owners across the loaded records, sorted with localeCompare, blanks dropped. */ +export function authorOptions(cards: readonly unknown[]): string[] { + const names = new Set() + for (const card of cards) { + const name = author(card) + if (typeof name === 'string' && name.trim() !== '') { + names.add(name) + } + } + return [...names].sort((a, b) => a.localeCompare(b)) +} + +/** + * Materialisation status. The canonical helper is `getBookmarkType()` in + * `src/utils/BookmarkUtils.ts` — `'M'`, `'A+M'` and `'D+M'` are materialised, + * `'A'` and `'D'` are not. Presence of `cohortDefinition` is the same test + * and is cheaper. + * + * There are exactly two statuses. *Stale* is not derivable from any field + * today and belongs to ticket #3117. + */ +const status = (card: unknown): MaterializationStatus => + (card as any)?.cohortDefinition ? 'materialized' : 'not-materialized' + +/** Local calendar day ("YYYY-MM-DD") for a timestamp, in the viewer's timezone. Empty when unknown. */ +const localDay = (value: unknown): string | null => { + if (!value) return null + const date = new Date(value as string) + if (Number.isNaN(date.getTime())) return null + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** + * True when `value`'s local calendar day falls inside `range`, inclusive at + * both ends. An empty range constrains nothing. A missing `value` fails an + * active range and passes an empty one. + */ +const inRange = (value: unknown, range: DateRange): boolean => { + if (isEmptyRange(range)) return true + const day = localDay(value) + if (day == null) return false + if (range.from != null && day < range.from) return false + if (range.to != null && day > range.to) return false + return true +} + +/** + * `created` accessor is backend-blocked: `Bookmark` carries only + * `dateModified`, there is no creation timestamp for a D2E exploration. The + * audit columns exist on the `user_artifact` row + * (`plugins/functions/portal/src/common/entity/audit.entity.ts`) but + * `formatUserArtifactData` does not expose them. Implemented anyway so that + * once the backend surfaces a creation timestamp the filter starts working + * with no logic change. + */ +const created = (card: unknown): unknown => + (card as any)?.bookmark?.dateCreated ?? (card as any)?.atlasCohortDefinition?.createdOn + +const lastMaterialized = (card: unknown): unknown => (card as any)?.cohortDefinition?.createdOn + +/** True when the card satisfies every active constraint. */ +export function matchesFilters(card: unknown, filters: ExplorationFilters): boolean { + if (filters.authors.length > 0) { + const owner = author(card) + if (owner == null || !filters.authors.includes(owner)) return false + } + + if (filters.statuses.length > 0 && !filters.statuses.includes(status(card))) { + return false + } + + if (!inRange(created(card), filters.created)) return false + if (!inRange(lastUpdatedMs(card) || null, filters.lastUpdated)) return false + if (!inRange(lastMaterialized(card), filters.lastMaterialized)) return false + + return true +} + +export function applyFilters(cards: readonly unknown[], filters: ExplorationFilters): unknown[] { + return cards.filter(card => matchesFilters(card, filters)) +} diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/icons/ExplorationFilterIcon.vue b/plugins/ui/apps/vue-mri-ui-lib/src/components/icons/ExplorationFilterIcon.vue new file mode 100644 index 0000000000..3b2a584288 --- /dev/null +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/icons/ExplorationFilterIcon.vue @@ -0,0 +1,20 @@ + + + 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 c2995b4ab0..d7e61a43e2 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 @@ -460,6 +460,19 @@ export const i18n = { MRI_PA_EXPLORATIONS_FILTER_SUMMARY: 'Filter summary', MRI_PA_EXPLORATIONS_ANALYZE: 'Analyze', MRI_PA_EXPLORATIONS_DUPLICATE: 'Duplicate', + MRI_PA_EXPLORATIONS_FILTERS: 'Filters', + MRI_PA_EXPLORATIONS_FILTERS_TITLE: 'Filters', + MRI_PA_EXPLORATIONS_FILTERS_CLEAR: 'Clear all selections', + MRI_PA_EXPLORATIONS_FILTER_AUTHOR: 'Author', + MRI_PA_EXPLORATIONS_FILTER_STATUS: 'Materialization status', + MRI_PA_EXPLORATIONS_FILTER_MATERIALIZED: 'Materialized', + MRI_PA_EXPLORATIONS_FILTER_NOT_MATERIALIZED: 'Not materialized', + MRI_PA_EXPLORATIONS_FILTER_CREATED: 'Created', + MRI_PA_EXPLORATIONS_FILTER_LAST_UPDATED: 'Last updated', + MRI_PA_EXPLORATIONS_FILTER_LAST_MATERIALIZED: 'Last materialized', + MRI_PA_EXPLORATIONS_FILTER_FROM: 'From', + MRI_PA_EXPLORATIONS_FILTER_TO: 'To', + MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: 'An exploration does not record a creation date yet.', MRI_PA_BOOKMARK_NO_COHORT_DEFINITION: 'No cohort definition available', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TITLE: 'Overwrite Saved Filter', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TEXT: 'A saved filter with this name already exists. Do you want to overwrite it?', @@ -1536,6 +1549,19 @@ export const i18n = { MRI_PA_EXPLORATIONS_FILTER_SUMMARY: 'Filterübersicht', MRI_PA_EXPLORATIONS_ANALYZE: 'Analysieren', MRI_PA_EXPLORATIONS_DUPLICATE: 'Duplizieren', + MRI_PA_EXPLORATIONS_FILTERS: 'Filter', + MRI_PA_EXPLORATIONS_FILTERS_TITLE: 'Filter', + MRI_PA_EXPLORATIONS_FILTERS_CLEAR: 'Alle Auswahlen aufheben', + MRI_PA_EXPLORATIONS_FILTER_AUTHOR: 'Autor', + MRI_PA_EXPLORATIONS_FILTER_STATUS: 'Materialisierungsstatus', + MRI_PA_EXPLORATIONS_FILTER_MATERIALIZED: 'Materialisiert', + MRI_PA_EXPLORATIONS_FILTER_NOT_MATERIALIZED: 'Nicht materialisiert', + MRI_PA_EXPLORATIONS_FILTER_CREATED: 'Erstellt', + MRI_PA_EXPLORATIONS_FILTER_LAST_UPDATED: 'Zuletzt aktualisiert', + MRI_PA_EXPLORATIONS_FILTER_LAST_MATERIALIZED: 'Zuletzt materialisiert', + MRI_PA_EXPLORATIONS_FILTER_FROM: 'Von', + MRI_PA_EXPLORATIONS_FILTER_TO: 'Bis', + MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: 'Eine Exploration speichert noch kein Erstellungsdatum.', MRI_PA_BOOKMARK_NO_COHORT_DEFINITION: 'Keine Kohortendefinition verfügbar', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TITLE: 'Gesicherten Filter überschreiben', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TEXT: @@ -2581,6 +2607,19 @@ export const i18n = { MRI_PA_EXPLORATIONS_FILTER_SUMMARY: '筛选摘要', MRI_PA_EXPLORATIONS_ANALYZE: '分析', MRI_PA_EXPLORATIONS_DUPLICATE: '复制', + MRI_PA_EXPLORATIONS_FILTERS: '筛选', + MRI_PA_EXPLORATIONS_FILTERS_TITLE: '筛选', + MRI_PA_EXPLORATIONS_FILTERS_CLEAR: '清除所有选择', + MRI_PA_EXPLORATIONS_FILTER_AUTHOR: '作者', + MRI_PA_EXPLORATIONS_FILTER_STATUS: '物化状态', + MRI_PA_EXPLORATIONS_FILTER_MATERIALIZED: '已物化', + MRI_PA_EXPLORATIONS_FILTER_NOT_MATERIALIZED: '未物化', + MRI_PA_EXPLORATIONS_FILTER_CREATED: '创建时间', + MRI_PA_EXPLORATIONS_FILTER_LAST_UPDATED: '最近更新', + MRI_PA_EXPLORATIONS_FILTER_LAST_MATERIALIZED: '最近物化', + MRI_PA_EXPLORATIONS_FILTER_FROM: '从', + MRI_PA_EXPLORATIONS_FILTER_TO: '到', + MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: '探索尚未记录创建日期。', MRI_PA_BOOKMARK_NO_COHORT_DEFINITION: '没有可用的群组定义', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TITLE: '覆盖已保存的过滤器', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TEXT: '具有此名称的已保存的过滤器已存在。是否要覆盖?', From a70714544beb96703eb4cb00087555a45858c5cb Mon Sep 17 00:00:00 2001 From: Khairul Syazwan Date: Thu, 3 Sep 2026 23:34:38 +0800 Subject: [PATCH 3/6] test(vue-mri): cover the lastUpdated range, which reads lastUpdatedMs The other range tests use lastMaterialized, which reads a date field directly. lastUpdated goes through lastUpdatedMs(), which returns milliseconds and falls back across three fields, so that path had no coverage. --- .../__tests__/explorationFilters.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts index bfe13812d6..d934eb0436 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts @@ -133,6 +133,32 @@ describe('date range semantics (lastMaterialized used as the representative rang }) }) +/** + * `lastUpdated` is the one range that does not read a date field directly: it + * goes through `lastUpdatedMs()`, which returns milliseconds and falls back + * across three fields. Its own tests, because the representative range above + * cannot exercise that path. + */ +describe('lastUpdated range', () => { + it('reads bookmark.dateModified', () => { + const filters = { ...emptyFilters(), lastUpdated: { from: '2026-01-01', to: '2026-01-31' } } + expect(matchesFilters(card({ bookmark: { dateModified: '2026-01-15T09:00:00' } }), filters)).toBe(true) + expect(matchesFilters(card({ bookmark: { dateModified: '2025-12-31T09:00:00' } }), filters)).toBe(false) + }) + + it('falls back to the atlas updatedOn when there is no bookmark', () => { + const filters = { ...emptyFilters(), lastUpdated: { from: '2026-01-01', to: '2026-01-31' } } + expect(matchesFilters(card({ atlasCohortDefinition: { updatedOn: '2026-01-15T09:00:00' } }), filters)).toBe(true) + expect(matchesFilters(card({ atlasCohortDefinition: { updatedOn: '2026-03-01T09:00:00' } }), filters)).toBe(false) + }) + + it('drops a card with no date anywhere, and keeps it when the range is empty', () => { + const undated = card({ displayName: 'undated' }) + expect(matchesFilters(undated, { ...emptyFilters(), lastUpdated: { from: '2026-01-01', to: null } })).toBe(false) + expect(matchesFilters(undated, emptyFilters())).toBe(true) + }) +}) + describe('AND across filters', () => { it('author and a date range together are AND, not OR', () => { const alice = card({ From 38087813a2fb3d8b7ce84fd4db14695aebd00a59 Mon Sep 17 00:00:00 2001 From: Khairul Syazwan Date: Thu, 3 Sep 2026 23:49:37 +0800 Subject: [PATCH 4/6] fix(vue-mri): close four defects found in code review D2eDateField stopped inheriting attrs. Its root is a VMenu, which does not stop inheritance either, so a fallthrough attr travelled on to VOverlay and was merged onto the teleported .v-overlay div: the story's width sized the popup instead of the field, and a data-testid existed on two nodes once the picker had opened. forwardAttrs already puts every attr on the field. D2eDateField gained an opt-in clearable, and the panel turns it on. VDatePickerMonth in single mode always assigns the clicked day and never deselects, so a picked date could not be dropped short of Clear all selections, which also wipes the author and status filters. EMPTY_FILTERS is frozen through its nested ranges, not only at the top level. The comment promised a loud failure; a shallow freeze left EMPTY_FILTERS.created writable, which is the exact object the bug it describes corrupts. Enter on Clear all selections now clears. D2eMenu sets close-on-content-click false, and Vuetify's VMenu keydown handler then calls preventDefault() on Enter, cancelling the browser's Enter-activates-a-button default. --- .../components/ExplorationFiltersPanel.vue | 8 +++++ .../__tests__/explorationFilters.test.ts | 12 +++++--- .../components/helpers/explorationFilters.ts | 18 ++++++++++-- .../d2e-ui/src/components/D2eDateField.vue | 29 +++++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue b/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue index 708f20f2c4..86ce62873d 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue @@ -5,12 +5,18 @@
{{ getText('MRI_PA_EXPLORATIONS_FILTERS_TITLE') }} + {{ getText('MRI_PA_EXPLORATIONS_FILTERS_CLEAR') }} @@ -51,6 +57,7 @@ :label="getText('MRI_PA_EXPLORATIONS_FILTER_FROM')" :max="modelValue[group.key].to" :disabled="group.disabled" + clearable :aria-label="`${getText(group.labelKey)} ${getText('MRI_PA_EXPLORATIONS_FILTER_FROM')}`" :data-testid="`explorations-filter-${group.key}-from`" @update:model-value="patchRange(group.key, { from: $event })" @@ -60,6 +67,7 @@ :label="getText('MRI_PA_EXPLORATIONS_FILTER_TO')" :min="modelValue[group.key].from" :disabled="group.disabled" + clearable :aria-label="`${getText(group.labelKey)} ${getText('MRI_PA_EXPLORATIONS_FILTER_TO')}`" :data-testid="`explorations-filter-${group.key}-to`" @update:model-value="patchRange(group.key, { to: $event })" diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts index d934eb0436..1a91075b3f 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts @@ -36,11 +36,15 @@ describe('emptyFilters / EMPTY_FILTERS', () => { expect(isEmpty(b)).toBe(true) }) - it('EMPTY_FILTERS is frozen', () => { + it('EMPTY_FILTERS is frozen all the way down', () => { expect(Object.isFrozen(EMPTY_FILTERS)).toBe(true) - expect(() => { - ;(EMPTY_FILTERS as ExplorationFilters).authors = ['x'] - }).toThrow() + // The nested ranges matter more than the top level: they are the objects a + // shallow spread shares, and the ones the corruption bug writes into. + expect(Object.isFrozen(EMPTY_FILTERS.created)).toBe(true) + expect(Object.isFrozen(EMPTY_FILTERS.lastUpdated)).toBe(true) + expect(Object.isFrozen(EMPTY_FILTERS.lastMaterialized)).toBe(true) + expect(Object.isFrozen(EMPTY_FILTERS.authors)).toBe(true) + expect(Object.isFrozen(EMPTY_FILTERS.statuses)).toBe(true) }) }) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts index 43498147e0..97261c77c9 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts @@ -44,10 +44,22 @@ export const emptyFilters = (): ExplorationFilters => ({ * `DateRange` objects by reference with this constant. The first * `filters.created.from = x` then mutates this module-level object in place, * after which `isEmpty()` never returns true again and "Clear all" stops - * clearing. `emptyFilters()` exists to avoid that; `Object.freeze` makes a - * future mutation attempt fail loudly instead of silently. + * clearing. `emptyFilters()` exists to avoid that; the freeze makes a future + * mutation attempt fail loudly instead of silently. + * + * The freeze reaches the three nested ranges as well. A top-level + * `Object.freeze` alone would leave `EMPTY_FILTERS.created` writable, which is + * precisely the object the bug above corrupts. */ -export const EMPTY_FILTERS: Readonly = Object.freeze(emptyFilters()) +export const EMPTY_FILTERS: Readonly = (() => { + const value = emptyFilters() + Object.freeze(value.authors) + Object.freeze(value.statuses) + Object.freeze(value.created) + Object.freeze(value.lastUpdated) + Object.freeze(value.lastMaterialized) + return Object.freeze(value) +})() const isEmptyRange = (range: DateRange): boolean => range.from == null && range.to == null diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue index 016310744d..9173e4d69a 100644 --- a/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue +++ b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue @@ -15,7 +15,9 @@ :model-value="modelValue" :placeholder="label" :disabled="disabled" + :clearable="clearable" :aria-label="ariaLabel" + @click:clear="onClear" v-bind="{ ...activatorProps, ...forwardAttrs }" /> @@ -35,6 +37,13 @@ import { VDatePicker, VMenu, VTextField } from "vuetify/components"; import { computed, ref, useAttrs } from "vue"; import { fromIsoDate, toIsoDate } from "./dateFieldFormat"; +// The root is a `VMenu`, which does not stop inheritance either, so a +// fallthrough attr travels on to `VOverlay` and is merged onto the teleported +// `.v-overlay` div rather than the field: `width` would size the popup, and a +// `data-testid` would exist on two nodes once the picker has opened. +// `forwardAttrs` below puts every attr on the field instead. +defineOptions({ inheritAttrs: false }); + interface Props { modelValue?: string | null; label?: string; @@ -42,6 +51,15 @@ interface Props { min?: string | null; max?: string | null; ariaLabel?: string; + /** + * Show a clear affordance once a date is picked. + * + * Off by default, because the Figma field has none. Turn it on wherever the + * field is one bound of a range: `VDatePickerMonth` in single mode always + * assigns the clicked day and never deselects, so without this a picked + * date cannot be dropped short of resetting the whole form. + */ + clearable?: boolean; } const props = withDefaults(defineProps(), { @@ -51,6 +69,7 @@ const props = withDefaults(defineProps(), { min: null, max: null, ariaLabel: undefined, + clearable: false, }); const emit = defineEmits<{ @@ -66,6 +85,7 @@ const forwardAttrs = computed(() => { min: _min, max: _max, ariaLabel: _ariaLabel, + clearable: _clearable, ...rest } = attrs as Record; void _modelValue; @@ -74,6 +94,7 @@ const forwardAttrs = computed(() => { void _min; void _max; void _ariaLabel; + void _clearable; return rest; }); @@ -85,6 +106,14 @@ const isOpen = computed({ }, }); +/** The clear icon sits on the activator, so swallow the click that would + otherwise reopen the picker the moment the value is dropped. */ +function onClear(event: Event) { + event.stopPropagation(); + emit("update:modelValue", null); + internalOpen.value = false; +} + function onPick(date: unknown) { emit("update:modelValue", toIsoDate(date instanceof Date ? date : null)); internalOpen.value = false; From 686ebfc38a2e09cd6d36fb636523d3eed8802573 Mon Sep 17 00:00:00 2001 From: Khairul Syazwan Date: Fri, 4 Sep 2026 10:07:02 +0800 Subject: [PATCH 5/6] fix(d2e-ui): centre field text, stop the notch striking the label, add typeahead Three defects reported from manual verification. The Author filter is now searchable, so a growing author list can be narrowed by typing. D2eSelect gained a searchable prop that renders a VAutocomplete in place of a VSelect; everything else about the component is unchanged, and the prop is off by default because a fixed two-item list is faster to click than to type. Field text was not vertically centred. Two separate causes. In D2eSelect the never inherited a font, so the UA's 13.33px Arial made the element 17px tall while its placeholder painted at 16px, and the two disagreed about the baseline; the input now takes the field's font and the input row fills the box with no vertical padding. In D2eDateField the field's own took its intrinsic 34px and sat flush against the top edge of the 40px box, leaving 6px below it. Both now measure zero drift against the field's centre. The outline notch drew a line through the floating 'Data source' label. D2eSelect restated Vuetify's notch borders and gave BOTH ::before and ::after a top border. Only ::before takes Vuetify's opacity: 0 when the label floats, so the reworked ::after survived and struck the label through. Vuetify's own rules already read --v-field-border-width, so the restatement is removed rather than corrected. D2eDateField carried a copy of the same rule and is cleaned up with it. --- .../components/ExplorationFiltersPanel.vue | 1 + .../d2e-ui/src/components/D2eDateField.vue | 24 ++++++---- .../libs/d2e-ui/src/components/D2eSelect.vue | 46 +++++++++++++++---- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue b/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue index 86ce62873d..8968a20b18 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/ExplorationFiltersPanel.vue @@ -31,6 +31,7 @@ :model-value="modelValue.authors" :placeholder="getText('MRI_PA_EXPLORATIONS_FILTER_AUTHOR')" prepend-icon="mdi-account-outline" + searchable data-testid="explorations-filter-author" @update:model-value="patch({ authors: ($event as string[]) ?? [] })" /> diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue index 9173e4d69a..70ecdee124 100644 --- a/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue +++ b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue @@ -125,21 +125,25 @@ function onPick(date: unknown) { // default radius is 4px and the focused border takes the theme `primary` // where the design uses `primary-light`. Corrected here the same way. .d2e-date-field { + --d2e-date-field-height: 40px; + font-family: var(--d2e-font-family); :deep(.v-field) { - min-height: 40px; + min-height: var(--d2e-date-field-height); border-radius: var(--d2e-radius-md); padding-inline: 14px; color: var(--d2e-color-neutral-black); } - // 24px content + 8px top + 8px bottom = the frame's 40px box. Do not copy - // D2eSelect's 16px here: that is what makes it render 56px tall. + // A text field's own IS `.v-field__input`. With vertical padding it + // takes its intrinsic 34px and sits flush against the field's top edge, + // leaving 6px below and reading as text riding high. Give it the full box + // instead; a browser centres an input's text in its content height. :deep(.v-field__input) { - min-height: 24px; - padding-top: var(--d2e-spacing-xs); - padding-bottom: var(--d2e-spacing-xs); + min-height: var(--d2e-date-field-height); + height: var(--d2e-date-field-height); + padding-block: 0; padding-inline: 0; font-size: var(--d2e-font-body1-size); font-weight: var(--d2e-font-body1-weight); @@ -166,10 +170,10 @@ function onPick(date: unknown) { color: var(--d2e-color-neutral-light); } - :deep(.v-field__outline .v-field__outline__notch::before), - :deep(.v-field__outline .v-field__outline__notch::after) { - border-width: var(--v-field-border-width) 0 0; - } + // Vuetify's own notch rules already read `--v-field-border-width`. Setting + // both `::before` and `::after` to a top border draws a line through a + // floating label, because only `::before` fades out when the label floats. + // This field has no floating label, but the rule is a landmine either way. :deep(.v-field--focused .v-field__outline) { --v-field-border-width: var(--d2e-border-width-md); diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eSelect.vue b/plugins/ui/libs/d2e-ui/src/components/D2eSelect.vue index 744a12afd5..8b0e9071a0 100644 --- a/plugins/ui/libs/d2e-ui/src/components/D2eSelect.vue +++ b/plugins/ui/libs/d2e-ui/src/components/D2eSelect.vue @@ -1,5 +1,6 @@ - + @@ -115,16 +126,30 @@ const forwardAttrs = computed(() => { color: var(--d2e-color-neutral-black); } + // Fill the box and let flex centring do the work. With vertical padding the + // content sits its own height plus 16px from the top, which reads as text + // riding high in the field rather than centred in it. :deep(.v-field__input) { - min-height: 24px; - padding-top: var(--d2e-spacing-s); - padding-bottom: var(--d2e-spacing-s); + align-items: center; + min-height: var(--d2e-select-height); + padding-block: 0; padding-inline: 0; font-size: var(--d2e-font-body1-size); font-weight: var(--d2e-font-body1-weight); color: var(--d2e-color-neutral-black); } + // An does not inherit font, so the UA's 13.33px Arial applied and + // the element was 17px tall while its placeholder painted at 16px. The two + // then disagreed about where the text baseline sits. + :deep(.v-field__input input) { + font-family: inherit; + font-size: var(--d2e-font-body1-size); + font-weight: var(--d2e-font-body1-weight); + line-height: normal; + color: var(--d2e-color-neutral-black); + } + :deep(.v-field__input input::placeholder) { font-size: var(--d2e-font-body1-size); font-weight: var(--d2e-font-body1-weight); @@ -141,10 +166,11 @@ const forwardAttrs = computed(() => { color: var(--d2e-color-neutral-light); } - :deep(.v-field__outline .v-field__outline__notch::before), - :deep(.v-field__outline .v-field__outline__notch::after) { - border-width: var(--v-field-border-width) 0 0; - } + // Vuetify already draws the notch's top rule on `::before` and its bottom + // rule on `::after`, both from `--v-field-border-width`, so neither needs + // restating. An earlier version set BOTH to a top border. Only `::before` + // gets Vuetify's `opacity: 0` once the label floats, so the reworked + // `::after` survived and drew a line straight through the floating label. :deep(.v-field--focused .v-field__outline) { --v-field-border-width: var(--d2e-border-width-md); From efb898812368cea3d161308debbb72edfad2d7fd Mon Sep 17 00:00:00 2001 From: Khairul Syazwan Date: Fri, 4 Sep 2026 10:41:58 +0800 Subject: [PATCH 6/6] fix(d2e-ui): close seven defects found in review VSelect no longer sits 9px above centre. Vuetify takes a VSelect's out of flex flow (position: absolute with align-self: flex-start), so align-items: center on the row cannot reach it and it hangs from the top of the content box; removing the vertical padding moved that anchor from 16px to 0 and made it worse, not better. An absolutely positioned flex child takes its static position from align-self, so that is what now centres it. Measured zero drift on the select, the autocomplete and the date field. The LAST UPDATED range filtered on a date the card never shows. lastUpdatedMs() falls through to cohortDefinition.createdOn so that sorting has a total order, but the card's Last updated row stops at the bookmark and Atlas fields. A materialised record with no bookmark reads '-' there and was still matched on its materialisation instant, which also made LAST UPDATED and LAST MATERIALIZED the same filter for those records. The filter now reads what the card reads. Enter works in the date fields. A VMenu with close-on-content-click false treats Enter on its content as move-to-next-focusable-then-close, so Enter on the last field closed the whole panel, and its preventDefault meant Enter never opened a picker. D2eDateField now handles the key itself and keeps it from the enclosing menu. The CREATED note claimed no creation date exists. Atlas cohort definitions do carry one. Reworded in all three locales to say the range is disabled because it cannot apply to every card. menuProps.contentClass replaced Vuetify's own content class rather than merging with it, because mergeProps special-cases only class, style and on*. Vuetify's class is carried through by hand. This PR is the first time a D2eSelect dropdown ever opens in the app. D2eDateField merges the activator props instead of spreading them, so a consumer's own click handler cannot silently replace the one that opens the picker. Date tests now cover a UTC timestamp, which is the format the store actually produces. --- .../__tests__/explorationFilters.test.ts | 57 +++++++++++++++++++ .../components/helpers/explorationFilters.ts | 19 ++++++- .../ui/apps/vue-mri-ui-lib/src/lib/i18n.ts | 8 ++- .../d2e-ui/src/components/D2eDateField.vue | 22 ++++++- .../libs/d2e-ui/src/components/D2eSelect.vue | 22 ++++++- 5 files changed, 120 insertions(+), 8 deletions(-) diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts index 1a91075b3f..7c27553346 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/__tests__/explorationFilters.test.ts @@ -156,6 +156,19 @@ describe('lastUpdated range', () => { expect(matchesFilters(card({ atlasCohortDefinition: { updatedOn: '2026-03-01T09:00:00' } }), filters)).toBe(false) }) + it('ignores the materialisation instant, which the card does not show as "last updated"', () => { + // A type-'M' record: materialised, with no bookmark and no Atlas + // definition. Its card renders "Last updated: -", so a LAST UPDATED range + // must not match it on cohortDefinition.createdOn. + const materializedOnly = card({ cohortDefinition: { createdOn: '2026-03-10T09:00:00' } }) + const march = { ...emptyFilters(), lastUpdated: { from: '2026-03-01', to: '2026-03-31' } } + expect(matchesFilters(materializedOnly, march)).toBe(false) + // The same record is still reachable through LAST MATERIALIZED. + expect( + matchesFilters(materializedOnly, { ...emptyFilters(), lastMaterialized: { from: '2026-03-01', to: '2026-03-31' } }), + ).toBe(true) + }) + it('drops a card with no date anywhere, and keeps it when the range is empty', () => { const undated = card({ displayName: 'undated' }) expect(matchesFilters(undated, { ...emptyFilters(), lastUpdated: { from: '2026-01-01', to: null } })).toBe(false) @@ -163,6 +176,50 @@ describe('lastUpdated range', () => { }) }) +/** + * Real timestamps are UTC: `getDisplayBookmarks` normalises + * `cohortDefinition.createdOn` with `.toISOString()`, and `processBookmarksData` + * does the same for the Atlas `createdOn` / `updatedOn`. The naive local + * strings used above never exercise that, and the whole point of comparing + * local calendar days is that a `Z` timestamp lands on the day the card + * displays. Expectations are derived from the running timezone so the + * assertion holds under any TZ. + */ +describe('UTC timestamps land on the viewer local day', () => { + const UTC_INSTANT = '2026-01-31T20:00:00.000Z' + const localDayOf = (iso: string) => { + const d = new Date(iso) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + } + const shiftDay = (day: string, by: number) => { + const [y, m, d] = day.split('-').map(Number) + return localDayOf(new Date(y, m - 1, d + by).toISOString()) + } + + it('a Z timestamp matches a range built from its own local day', () => { + const c = card({ cohortDefinition: { createdOn: UTC_INSTANT } }) + const day = localDayOf(UTC_INSTANT) + expect(matchesFilters(c, { ...emptyFilters(), lastMaterialized: { from: day, to: day } })).toBe(true) + }) + + it('a Z timestamp is excluded by the neighbouring local days', () => { + const c = card({ cohortDefinition: { createdOn: UTC_INSTANT } }) + const day = localDayOf(UTC_INSTANT) + const before = shiftDay(day, -1) + const after = shiftDay(day, 1) + expect(matchesFilters(c, { ...emptyFilters(), lastMaterialized: { from: null, to: before } })).toBe(false) + expect(matchesFilters(c, { ...emptyFilters(), lastMaterialized: { from: after, to: null } })).toBe(false) + }) + + it('agrees with the card display, which also reads local parts', () => { + // DateUtils.displayBookmarkDateFormat builds its label from getDate() / + // getMonth() / getFullYear(), so the filter and the card must land on the + // same calendar day for the same instant. + const d = new Date(UTC_INSTANT) + expect(localDayOf(UTC_INSTANT).endsWith(String(d.getDate()).padStart(2, '0'))).toBe(true) + }) +}) + describe('AND across filters', () => { it('author and a date range together are AND, not OR', () => { const alice = card({ diff --git a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts index 97261c77c9..afcd1fea6d 100644 --- a/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts +++ b/plugins/ui/apps/vue-mri-ui-lib/src/components/helpers/explorationFilters.ts @@ -9,7 +9,6 @@ * `explorationList.ts` next door for the same reasoning. */ -import { lastUpdatedMs } from './explorationList' export type MaterializationStatus = 'materialized' | 'not-materialized' @@ -138,6 +137,22 @@ const inRange = (value: unknown, range: DateRange): boolean => { const created = (card: unknown): unknown => (card as any)?.bookmark?.dateCreated ?? (card as any)?.atlasCohortDefinition?.createdOn +/** + * The card's own "Last updated" value. + * + * Deliberately NOT `lastUpdatedMs()` from `./explorationList`, though it looks + * like the obvious reuse. That helper carries a third fallback to + * `cohortDefinition.createdOn` so that sorting has a total order over every + * record. Filtering must agree with what the user can read on the card, and + * `ExplorationsPage`'s "Last updated" row stops at these two fields — a + * materialised record with no bookmark and no Atlas definition shows a dash + * there, so matching it on its materialisation instant would drop or keep it + * for a reason nothing on screen explains. It would also make LAST UPDATED and + * LAST MATERIALIZED silently the same filter for those records. + */ +const lastUpdated = (card: unknown): unknown => + (card as any)?.bookmark?.dateModified ?? (card as any)?.atlasCohortDefinition?.updatedOn + const lastMaterialized = (card: unknown): unknown => (card as any)?.cohortDefinition?.createdOn /** True when the card satisfies every active constraint. */ @@ -152,7 +167,7 @@ export function matchesFilters(card: unknown, filters: ExplorationFilters): bool } if (!inRange(created(card), filters.created)) return false - if (!inRange(lastUpdatedMs(card) || null, filters.lastUpdated)) return false + if (!inRange(lastUpdated(card), filters.lastUpdated)) return false if (!inRange(lastMaterialized(card), filters.lastMaterialized)) return false return true 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 d7e61a43e2..0838bf43ff 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 @@ -472,7 +472,8 @@ export const i18n = { MRI_PA_EXPLORATIONS_FILTER_LAST_MATERIALIZED: 'Last materialized', MRI_PA_EXPLORATIONS_FILTER_FROM: 'From', MRI_PA_EXPLORATIONS_FILTER_TO: 'To', - MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: 'An exploration does not record a creation date yet.', + MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: + 'This range is disabled. An exploration does not record a creation date, so the filter cannot apply to every card.', MRI_PA_BOOKMARK_NO_COHORT_DEFINITION: 'No cohort definition available', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TITLE: 'Overwrite Saved Filter', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TEXT: 'A saved filter with this name already exists. Do you want to overwrite it?', @@ -1561,7 +1562,8 @@ export const i18n = { MRI_PA_EXPLORATIONS_FILTER_LAST_MATERIALIZED: 'Zuletzt materialisiert', MRI_PA_EXPLORATIONS_FILTER_FROM: 'Von', MRI_PA_EXPLORATIONS_FILTER_TO: 'Bis', - MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: 'Eine Exploration speichert noch kein Erstellungsdatum.', + MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: + 'Dieser Bereich ist deaktiviert. Eine Exploration speichert kein Erstellungsdatum, daher kann der Filter nicht auf jede Karte angewendet werden.', MRI_PA_BOOKMARK_NO_COHORT_DEFINITION: 'Keine Kohortendefinition verfügbar', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TITLE: 'Gesicherten Filter überschreiben', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TEXT: @@ -2619,7 +2621,7 @@ export const i18n = { MRI_PA_EXPLORATIONS_FILTER_LAST_MATERIALIZED: '最近物化', MRI_PA_EXPLORATIONS_FILTER_FROM: '从', MRI_PA_EXPLORATIONS_FILTER_TO: '到', - MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: '探索尚未记录创建日期。', + MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE: '此范围已禁用。探索不记录创建日期,因此该筛选条件无法应用于每张卡片。', MRI_PA_BOOKMARK_NO_COHORT_DEFINITION: '没有可用的群组定义', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TITLE: '覆盖已保存的过滤器', MRI_PA_BOOKMARK_OVERWRITE_DIALOG_TEXT: '具有此名称的已保存的过滤器已存在。是否要覆盖?', diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue index 70ecdee124..58c0474442 100644 --- a/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue +++ b/plugins/ui/libs/d2e-ui/src/components/D2eDateField.vue @@ -18,7 +18,8 @@ :clearable="clearable" :aria-label="ariaLabel" @click:clear="onClear" - v-bind="{ ...activatorProps, ...forwardAttrs }" + v-bind="mergeProps(activatorProps, forwardAttrs)" + @keydown.enter.prevent.stop="onActivatorEnter" /> @@ -34,7 +35,7 @@