Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
<template>
<!-- Figma 2697:211983 — a 420x432 paper anchored under the Filters button.
Geometry and type sizes are in
docs/projects/vue-mri-ui/pr8/00-session-corrections.md section 2. -->
<div class="filters-panel" data-testid="explorations-filters-panel">
<header class="filters-panel__header">
<span class="filters-panel__title">{{ getText('MRI_PA_EXPLORATIONS_FILTERS_TITLE') }}</span>
<!-- `@keydown.enter` is not redundant: D2eMenu sets
`close-on-content-click: false`, and Vuetify's VMenu keydown handler
on the overlay root then calls preventDefault() on Enter, which
cancels the browser's Enter-activates-a-button default. `.prevent`
and `.stop` here keep that from firing the action twice. -->
<D2eButton
variant="ghost"
class="filters-panel__clear"
:disabled="isEmpty(modelValue)"
data-testid="explorations-filters-clear"
@click="$emit('clear')"
@keydown.enter.prevent.stop="$emit('clear')"
>
{{ getText('MRI_PA_EXPLORATIONS_FILTERS_CLEAR') }}
</D2eButton>
</header>

<div class="filters-panel__body">
<D2eSelect
size="sm"
multiple
hide-details
:items="authorItems"
: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[]) ?? [] })"
/>

<D2eSelect
size="sm"
multiple
hide-details
:items="statusItems"
:model-value="modelValue.statuses"
:placeholder="getText('MRI_PA_EXPLORATIONS_FILTER_STATUS')"
prepend-icon="mdi-check-circle-outline"
data-testid="explorations-filter-status"
@update:model-value="patch({ statuses: ($event as MaterializationStatus[]) ?? [] })"
/>

<div v-for="group in dateGroups" :key="group.key" class="filters-panel__group">
<span class="filters-panel__group-label">{{ getText(group.labelKey) }}</span>
<div class="filters-panel__range">
<!-- Cross-bound so the picker cannot produce an inverted range: the
From field's max is the current `to`, and the reverse. -->
<D2eDateField
:model-value="modelValue[group.key].from"
: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 })"
/>
<D2eDateField
:model-value="modelValue[group.key].to"
: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 })"
/>
</div>
<!-- CREATED is backend-blocked: a bookmark carries no creation
timestamp. The group renders so the panel matches the frame, and
says why it is inert rather than reading as an oversight. -->
<p v-if="group.disabled" class="filters-panel__note">
{{ getText('MRI_PA_EXPLORATIONS_FILTER_CREATED_UNAVAILABLE') }}
</p>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useStore } from 'vuex'
import { D2eButton, D2eDateField, D2eSelect } from '@d2e/ui'
import type { D2eSelectItem } from '@d2e/ui'
import { isEmpty, type DateRange, type ExplorationFilters, type MaterializationStatus } from './helpers/explorationFilters'

interface Props {
modelValue: ExplorationFilters
/** Distinct owners across the whole loaded list, not the filtered one. */
authors: string[]
}

const props = defineProps<Props>()

const emit = defineEmits<{
'update:modelValue': [value: ExplorationFilters]
clear: []
}>()

const store = useStore()

const getText = (key: string): string => {
const resolver = store.getters.getText
return typeof resolver === 'function' ? resolver(key) : key
}

/** The three date ranges, in the frame's order. Only CREATED is blocked. */
type DateGroupKey = 'created' | 'lastUpdated' | 'lastMaterialized'

const dateGroups: { key: DateGroupKey; labelKey: string; disabled: boolean }[] = [
{ key: 'created', labelKey: 'MRI_PA_EXPLORATIONS_FILTER_CREATED', disabled: true },
{ key: 'lastUpdated', labelKey: 'MRI_PA_EXPLORATIONS_FILTER_LAST_UPDATED', disabled: false },
{ key: 'lastMaterialized', labelKey: 'MRI_PA_EXPLORATIONS_FILTER_LAST_MATERIALIZED', disabled: false },
]

const authorItems = computed<D2eSelectItem[]>(() => props.authors.map(name => ({ label: name, value: name })))

const statusItems = computed<D2eSelectItem[]>(() => [
{ label: getText('MRI_PA_EXPLORATIONS_FILTER_MATERIALIZED'), value: 'materialized' },
{ label: getText('MRI_PA_EXPLORATIONS_FILTER_NOT_MATERIALIZED'), value: 'not-materialized' },
])

/**
* The panel is controlled and never mutates its prop: every change emits a
* new object. Mutating `modelValue` in place would leave the page's `cards`
* computed with no dependency change to react to.
*/
const patch = (part: Partial<ExplorationFilters>): void => {
emit('update:modelValue', { ...props.modelValue, ...part })
}

/** A range change replaces the range object too, never assigns into it. */
const patchRange = (key: DateGroupKey, part: Partial<DateRange>): void => {
patch({ [key]: { ...props.modelValue[key], ...part } } as Partial<ExplorationFilters>)
}
</script>

<style scoped lang="scss">
.filters-panel {
width: 420px;
background: var(--d2e-color-white);
border-radius: var(--d2e-radius-md);
box-shadow: var(--d2e-elevation-e8);
font-family: var(--d2e-font-family);
overflow: hidden;

/* 420x52 with a 1px neutral rule beneath (Figma 2697:211985). */
&__header {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 var(--d2e-spacing-s);
border-bottom: var(--d2e-border-width-sm) solid var(--d2e-color-neutral);
}

/* 10px SemiBold Primary, 1px tracking, uppercased here and not in the
string so the translations stay readable (Figma 2697:211986). */
&__title {
font-size: var(--d2e-font-caption2-size);
font-weight: var(--d2e-font-subtitle1-weight);
line-height: 1.5;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--d2e-color-primary);
}

/* 139x29, 14px SemiBold Neutral/Light (Figma 2697:211987). `ghost` is the
nearest D2eButton variant; its 40px height and 16px Medium label are
wrong for this one place. */
&__clear {
height: 29px;
padding: var(--d2e-spacing-xxs) var(--d2e-spacing-xs);

:deep(.v-btn__content) {
font-size: var(--d2e-font-subtitle2-size);
font-weight: var(--d2e-font-subtitle2-weight);
line-height: 1.5;
letter-spacing: normal;
text-transform: none;
color: var(--d2e-color-neutral-light);
}
}

&__body {
display: flex;
flex-direction: column;
gap: var(--d2e-spacing-s);
padding: var(--d2e-spacing-s);
}

/* 388x68: a 24px heading row, then the 40px field row, 4px apart. */
&__group {
display: flex;
flex-direction: column;
gap: var(--d2e-spacing-xxs);
}

&__group-label {
display: flex;
align-items: center;
height: 24px;
font-size: var(--d2e-font-caption2-size);
font-weight: var(--d2e-font-subtitle1-weight);
line-height: 1.5;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--d2e-color-neutral);
}

/* Two 186px fields, 16px apart. */
&__range {
display: flex;
gap: var(--d2e-spacing-s);

> * {
flex: 1 1 0;
min-width: 0;
}
}

&__note {
margin: 0;
font-size: var(--d2e-font-caption1-size);
line-height: 1.4;
color: var(--d2e-color-neutral-light);
}
}
</style>

<!-- Not scoped: the panel is teleported into `.v-overlay-container`, a direct
child of <body>, so it sits outside `.mri-app-vue-container` and every
rule scoped to that container stops applying. Two of them matter here.
`ExplorationsPage.vue` carries the same kind of block for tooltips. -->
<style lang="scss">
/* `vuetify-settings.scss` sets `$reset: false`, so Vuetify's reset never runs
and every <input> keeps the browser's 2px inset border. `style.scss`
compensates for that, but only inside `.mri-app-vue-container`. Out here the
border comes back and draws a box around each placeholder. */
.filters-panel {
.v-field__input,
.v-field input,
.v-field select,
.v-field textarea {
background-color: transparent;
border-style: none;
}

/* The frame writes both placeholders in Neutral/Default, not the lighter
grey D2eSelect and D2eDateField use elsewhere (Figma 2697:211989 and
2697:211995). `.v-input` is in the selector only to outrank the two
components' own scoped rules, which carry a `[data-v-*]` attribute. */
.v-input .v-field__input::placeholder,
.v-input .v-field__input input::placeholder {
color: var(--d2e-color-neutral);
opacity: 1;
}
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@
:hide-details="true"
data-testid="explorations-search"
/>

<!-- The panel anchors bottom-end: node positions put its right edge on
the button's right edge, with no vertical offset (Figma 2634:58660
at x 524 w 101, panel 2697:211983 at x 205 w 420). -->
<D2eMenu v-model="filtersOpen" location="bottom end" :items="[]">
<template #activator="activatorProps">
<D2eButton
v-bind="activatorProps"
variant="secondary"
class="explorations-page__filters"
data-testid="explorations-filters-btn"
>
<template #prepend>
<ExplorationFilterIcon :size="22" />
</template>
{{ getText('MRI_PA_EXPLORATIONS_FILTERS') }}
</D2eButton>
</template>

<ExplorationFiltersPanel
v-model="filters"
:authors="authorNames"
@clear="filters = emptyFilters()"
/>
</D2eMenu>
</div>
<div class="explorations-page__toolbar-right">
<D2eMenu :width="220" location="bottom end" :items="sortItems" @select="onSortSelect">
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -259,6 +287,8 @@ const EMPTY_VALUE = '-'

const searchQuery = ref('')
const sortKey = ref<ExplorationSortKey>('lastUpdated')
const filters = ref<ExplorationFilters>(emptyFilters())
const filtersOpen = ref(false)

const loading = computed(() => store.getters.getBookmarksLoading)
const loadError = computed(() => store.getters.getBookmarksLoadError)
Expand Down Expand Up @@ -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<string[]>(() => 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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading