diff --git a/.github/pr-screenshots/device-filters-modal-full.webp b/.github/pr-screenshots/device-filters-modal-full.webp new file mode 100644 index 0000000000..b59c1d87a1 Binary files /dev/null and b/.github/pr-screenshots/device-filters-modal-full.webp differ diff --git a/.github/pr-screenshots/device-filters-modal.webp b/.github/pr-screenshots/device-filters-modal.webp new file mode 100644 index 0000000000..769a8555af Binary files /dev/null and b/.github/pr-screenshots/device-filters-modal.webp differ diff --git a/messages/en.json b/messages/en.json index afad39e708..f5f427c640 100644 --- a/messages/en.json +++ b/messages/en.json @@ -273,7 +273,9 @@ "production-plugin-device": "Production plugin device", "production-plugin-device-within-7-days": "Production Plugin Device (within 7 days)", "all-billing-cycles": "All billing cycles", + "all-bundles": "All bundles", "all-plans": "All plans", + "all-platforms": "All platforms", "access-required-description": "You don't have access to this section. Ask one of the people below, who can manage it for your organization.", "access-contact-people": "People who can help", "audit-access-required": "Audit log access required", @@ -779,6 +781,7 @@ "choose-which-channel-to-link-this-bundle-to": "Choose which channel to link this bundle to", "cli-doc": "CLI doc", "cli-version": "CLI version", + "clear-filters": "Clear filters", "close": "Close", "cloud-replication-delay": "Changes will take up to 60 seconds to propagate globally", "commands": "commands", @@ -1125,6 +1128,7 @@ "detailed-usage-plan": "Detailed usage", "details": "Details", "device": "Device", + "done": "Done", "device-id": "Device ID", "device-id-placeholder": "00000000-0000-0000-0000-000000000000", "device-injected": "This device has been created manually to add an overwrite.", @@ -1272,6 +1276,8 @@ "feel-magic-of-capgo": "Feel the magic of:", "fetching-release-data": "Fetching release data...", "filter-actions": "Actions", + "filter-options": "Options", + "filter-modal-subtitle": "Narrow results by platform, bundle, or status.", "filter-shortcut-all-fail": "All failures", "first-name": "First name", "first-name-required": "First name required", diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts index 018ff6372c..0b43c204b0 100644 --- a/playwright/visual-diff.config.ts +++ b/playwright/visual-diff.config.ts @@ -19,7 +19,25 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'apps', path: '/apps', auth: true }, { slug: 'app-overview', path: '/app/com.demo.app', auth: true }, { slug: 'channels', path: '/app/com.demo.app/channels', auth: true }, - { slug: 'devices', path: '/app/com.demo.app/devices', auth: true }, + { + slug: 'devices', + path: '/app/com.demo.app/devices', + auth: true, + prepare: async (page) => { + // Open Filters so reviewers see platform/bundle controls on head. + // Base still uses the legacy dropdown, so fall back only when modal is absent. + await page.getByRole('button', { name: /filters/i }).click() + const modal = page.locator('[data-test="data-table-filters-modal"]') + try { + await modal.waitFor({ state: 'visible', timeout: 3000 }) + } + catch { + await page.getByText('Override', { exact: true }).waitFor({ state: 'visible' }) + return + } + await page.locator('[data-test="device-platform-filter"]').waitFor({ state: 'visible' }) + }, + }, { slug: 'observe', path: '/app/com.demo.app/observe', auth: true }, { slug: 'observe-plugins', path: '/app/com.demo.app/observe/plugins', auth: true }, { diff --git a/src/components/DataTable.vue b/src/components/DataTable.vue index ae111ee4c3..b94a4f6ced 100644 --- a/src/components/DataTable.vue +++ b/src/components/DataTable.vue @@ -6,14 +6,17 @@ import DOMPurify from 'dompurify' import { computed, defineComponent, + nextTick, onMounted, onUnmounted, ref, + useId, + useSlots, watch, } from 'vue' import { useI18n } from 'vue-i18n' import IconTrash from '~icons/heroicons/trash' -import IconDown from '~icons/ic/round-keyboard-arrow-down' +import IconClose from '~icons/heroicons/x-mark' import IconPrev from '~icons/ic/round-keyboard-arrow-left' import IconNext from '~icons/ic/round-keyboard-arrow-right' import IconFastBackward from '~icons/ic/round-keyboard-double-arrow-left' @@ -31,6 +34,8 @@ interface Props { filterText?: string filters?: { [key: string]: boolean } filterLabels?: { [key: string]: string } + /** Extra active filters contributed by the filter-extras slot (e.g. selects). */ + extraFilterCount?: number searchPlaceholder?: string showAdd?: boolean addButtonTestId?: string @@ -49,6 +54,7 @@ interface Props { const props = withDefaults(defineProps(), { autoReload: true, mobileFixedPagination: true, + extraFilterCount: 0, }) const emit = defineEmits([ 'add', @@ -65,7 +71,13 @@ const emit = defineEmits([ 'plusClick', 'selectRow', 'massDelete', + 'clearExtraFilters', ]) +const isFilterModalOpen = ref(false) +const filterModalBoxRef = ref(null) +const filterOpenButtonRef = ref(null) +const filterModalTitleId = `${useId()}-filters-title` +const slots = useSlots() const { t } = useI18n() const searchVal = ref(props.search ?? '') const pendingReset = ref(false) @@ -95,19 +107,97 @@ const filterList = computed(() => { return Object.keys(props.filters) }) const filterActivated = computed(() => { - if (!props.filters) - return [] - return Object.keys(props.filters).reduce((acc, key) => { - if (props.filters![key]) - acc += 1 - return acc - }, 0) + const booleanCount = props.filters + ? Object.keys(props.filters).reduce((acc, key) => { + if (props.filters![key]) + acc += 1 + return acc + }, 0) + : 0 + return booleanCount + (props.extraFilterCount ?? 0) }) +const showFilterMenu = computed(() => + Boolean(props.filterText && (filterList.value.length || slots['filter-extras'])), +) + function getFilterLabel(filter: string) { return props.filterLabels?.[filter] ?? t(filter) } +function openFilterModal() { + isFilterModalOpen.value = true +} + +function closeFilterModal() { + isFilterModalOpen.value = false + nextTick(() => { + filterOpenButtonRef.value?.focus() + }) +} + +function getFilterModalFocusable() { + const root = filterModalBoxRef.value + if (!root) + return [] as HTMLElement[] + return Array.from(root.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + )).filter(el => !el.hasAttribute('disabled') && el.offsetParent !== null) +} + +function onFilterModalKeydown(e: KeyboardEvent) { + if (!isFilterModalOpen.value) + return + if (e.key === 'Escape') { + e.preventDefault() + closeFilterModal() + return + } + if (e.key !== 'Tab') + return + const focusable = getFilterModalFocusable() + if (!focusable.length) + return + const first = focusable[0]! + const last = focusable[focusable.length - 1]! + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } + else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } +} + +watch(isFilterModalOpen, async (open) => { + if (open) { + window.addEventListener('keydown', onFilterModalKeydown) + await nextTick() + const focusable = getFilterModalFocusable() + focusable[0]?.focus() + } + else { + window.removeEventListener('keydown', onFilterModalKeydown) + } +}) + +onUnmounted(() => { + window.removeEventListener('keydown', onFilterModalKeydown) +}) + +function clearAllFilters() { + // Emit a new filters object so DataTable's filters watcher performs one reload. + // Extra filters are cleared without scheduling a second reload. + if (props.filters) { + const cleared = Object.fromEntries( + Object.keys(props.filters).map(key => [key, false]), + ) as { [key: string]: boolean } + emit('update:filters', cleared) + } + emit('clearExtraFilters') +} + function sortClick(key: number) { if (!props.columns[key].sortable) return @@ -497,10 +587,15 @@ const paginationClass = computed(() => props.mobileFixedPagination -
+
-
    -
  • + +
  • -
+
+
+