Skip to content
Merged
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
143 changes: 123 additions & 20 deletions app/pages/org/[org].vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
<script setup lang="ts">
import type { FilterChip, SortOption } from '#shared/types/preferences'
import {
type ColumnConfig,
type FilterChip,
type SortOption,
type StructuredFilters,
DEFAULT_COLUMNS,
DEFAULT_FILTERS,
parseDownloadRange,
parseSearchScope,
parseSecurityFilter,
parseUpdatedWithin,
parseColumns,
serializeVisibleColumns,
} from '#shared/types/preferences'
import { normalizeSearchParam } from '#shared/utils/url'
import { debounce } from 'perfect-debounce'

Expand Down Expand Up @@ -39,9 +52,61 @@
const packageCount = computed(() => packages.value.length)

// Preferences (persisted to localStorage)
const { viewMode, paginationMode, pageSize, columns, toggleColumn, resetColumns } =
const { viewMode, paginationMode, pageSize, columns, toggleColumn, resetColumns, isHydrated } =
usePackageListPreferences()

const initialFilters: Partial<StructuredFilters> = {}
const searchScope = parseSearchScope(normalizeSearchParam(route.query.scope))
const downloadRange = parseDownloadRange(normalizeSearchParam(route.query.downloads))
const security = parseSecurityFilter(normalizeSearchParam(route.query.security))
const updatedWithin = parseUpdatedWithin(normalizeSearchParam(route.query.updated))

if (searchScope) initialFilters.searchScope = searchScope
if (downloadRange) initialFilters.downloadRange = downloadRange
if (security) initialFilters.security = security
if (updatedWithin) initialFilters.updatedWithin = updatedWithin

const columnOverride = shallowRef<ColumnConfig[] | null>(null)

watch(
[isHydrated, () => route.query.columns],
([hydrated]) => {
if (!hydrated) return
const ids = parseColumns(
route.query.columns === undefined ? undefined : normalizeSearchParam(route.query.columns),
)
if (!ids) {
columnOverride.value = null
return
}
columnOverride.value = columns.value.map(col => ({
...col,
visible: col.id === 'name' || ids.includes(col.id),
}))
},
{ immediate: true },
)

const viewColumns = computed(() => columnOverride.value ?? columns.value)

function handleToggleColumn(columnId: ColumnConfig['id']) {
if (!columnOverride.value) {
toggleColumn(columnId)
return
}
columnOverride.value = columnOverride.value.map(col =>
col.id === columnId ? { ...col, visible: !col.visible } : col,
)
}

function handleResetColumns() {
if (!columnOverride.value) {
resetColumns()
return
}
columnOverride.value = DEFAULT_COLUMNS.map(col => ({ ...col }))

Check warning on line 107 in app/pages/org/[org].vue

View workflow job for this annotation

GitHub Actions / 馃敔 Lint project

oxc(no-map-spread)

app/pages/org/[org].vue:107:42: Spreading to modify object properties in `map` calls is inefficient

Check warning on line 107 in app/pages/org/[org].vue

View workflow job for this annotation

GitHub Actions / 馃 Autofix code

oxc(no-map-spread)

app/pages/org/[org].vue:107:42: Spreading to modify object properties in `map` calls is inefficient
}

// Structured filters and sorting
const {
filters,
Expand All @@ -61,6 +126,7 @@
} = useStructuredFilters({
packages,
initialSort: (normalizeSearchParam(route.query.sort) as SortOption) ?? DEFAULT_SORT,
initialFilters,
})

// Pagination state
Expand All @@ -84,24 +150,61 @@
})

// Debounced URL update for filter/sort
const updateUrl = debounce((updates: { filter?: string; sort?: string }) => {
router.replace({
query: {
...route.query,
q: updates.filter || undefined,
sort: updates.sort && updates.sort !== DEFAULT_SORT ? updates.sort : undefined,
},
})
}, 300)

// Update URL when filter/sort changes (debounced)
const updateUrl = debounce(
(updates: {
filter?: string
sort?: string
scope?: string
downloads?: string
security?: string
updated?: string
}) => {
router.replace({
query: {
...route.query,
q: updates.filter || undefined,
sort: updates.sort && updates.sort !== DEFAULT_SORT ? updates.sort : undefined,
columns: serializeVisibleColumns(viewColumns.value),
scope:
updates.scope && updates.scope !== DEFAULT_FILTERS.searchScope
? updates.scope
: undefined,
downloads:
updates.downloads && updates.downloads !== DEFAULT_FILTERS.downloadRange
? updates.downloads
: undefined,
security:
updates.security && updates.security !== DEFAULT_FILTERS.security
? updates.security
: undefined,
updated:
updates.updated && updates.updated !== DEFAULT_FILTERS.updatedWithin
? updates.updated
: undefined,
},
})
},
300,
)

// Update URL when filter/sort/columns change (debounced)
watch(
[() => filters.value.text, () => filters.value.keywords, () => sortOption.value] as const,
([text, keywords, sort]) => {
[
() => filters.value.text,
() => filters.value.keywords,
() => sortOption.value,
() => filters.value.searchScope,
() => filters.value.downloadRange,
() => filters.value.security,
() => filters.value.updatedWithin,
// serialize so visibility toggles (same array ref) still trigger
() => serializeVisibleColumns(viewColumns.value),
] as const,
([text, keywords, sort, scope, downloads, security, updated]) => {

Check warning on line 203 in app/pages/org/[org].vue

View workflow job for this annotation

GitHub Actions / 馃敔 Lint project

eslint(no-shadow)

app/pages/org/[org].vue:203:45: 'security' is already declared in the upper scope.

Check warning on line 203 in app/pages/org/[org].vue

View workflow job for this annotation

GitHub Actions / 馃 Autofix code

eslint(no-shadow)

app/pages/org/[org].vue:203:45: 'security' is already declared in the upper scope.
const filter = [text, ...keywords.map(keyword => `keyword:${keyword}`)]
.filter(Boolean)
.join(' ')
updateUrl({ filter, sort })
updateUrl({ filter, sort, scope, downloads, security, updated })
},
)

Expand Down Expand Up @@ -298,15 +401,15 @@
:filters="filters"
v-model:sort-option="sortOption"
v-model:view-mode="viewMode"
:columns="columns"
:columns="viewColumns"
v-model:pagination-mode="paginationMode"
v-model:page-size="pageSize"
:total-count="packageCount"
:filtered-count="filteredCount"
:available-keywords="availableKeywords"
:active-filters="activeFilters"
@toggle-column="toggleColumn"
@reset-columns="resetColumns"
@toggle-column="handleToggleColumn"
@reset-columns="handleResetColumns"
@clear-filter="handleClearFilter"
@clear-all-filters="clearAllFilters"
@update:text="setTextFilter"
Expand All @@ -328,7 +431,7 @@
<PackageList
:results="sortedPackages"
:view-mode="viewMode"
:columns="columns"
:columns="viewColumns"
:filters="filters"
v-model:sort-option="sortOption"
:pagination-mode="paginationMode"
Expand Down
50 changes: 50 additions & 0 deletions shared/types/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,38 @@ export const DEFAULT_COLUMNS: ColumnConfig[] = [
},
]

function visibleColumnIdsParam(columns: ColumnConfig[]): string {
const visible = new Set(
columns
.filter(col => col.visible && col.id !== 'selection' && col.id !== 'name')
.map(col => col.id),
)
return DEFAULT_COLUMNS.filter(col => visible.has(col.id))
.map(col => col.id)
.join(',')
}

/** Comma-separated visible column ids, or undefined when they match the default set. `name` is omitted (always visible). */
export function serializeVisibleColumns(columns: ColumnConfig[]): string | undefined {
const visibleColumnIds = visibleColumnIdsParam(columns)
return visibleColumnIds === visibleColumnIdsParam(DEFAULT_COLUMNS) ? undefined : visibleColumnIds
}

function isColumnId(id: string): id is ColumnId {
return DEFAULT_COLUMNS.some(col => col.id === id)
}

export function parseColumns(value: string | undefined): ColumnId[] | undefined {
if (value === undefined) return undefined
if (value === '') return []

const ids = value
.split(',')
.map(id => id.trim())
.filter(isColumnId)
return ids.length > 0 ? ids : undefined
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Sort keys (without direction)
export type SortKey =
| 'downloads-week'
Expand Down Expand Up @@ -203,6 +235,22 @@ export type SearchScope = 'name' | 'description' | 'keywords' | 'all'
/** Search scope values - labels are in i18n under filters.scope_* */
export const SEARCH_SCOPE_VALUES: SearchScope[] = ['name', 'description', 'keywords', 'all']

export function parseSearchScope(value: string): SearchScope | undefined {
return SEARCH_SCOPE_VALUES.find(scope => scope === value)
}

export function parseDownloadRange(value: string): DownloadRange | undefined {
return DOWNLOAD_RANGES.find(range => range.value === value)?.value
}

export function parseSecurityFilter(value: string): SecurityFilter | undefined {
return SECURITY_FILTER_VALUES.find(filter => filter === value)
}

export function parseUpdatedWithin(value: string): UpdatedWithin | undefined {
return UPDATED_WITHIN_OPTIONS.find(option => option.value === value)?.value
}

// Structured filters state
export interface StructuredFilters {
text: string
Expand All @@ -211,6 +259,7 @@ export interface StructuredFilters {
keywords: string[]
security: SecurityFilter
updatedWithin: UpdatedWithin
visibleColumns: ColumnId[]
}

export const DEFAULT_FILTERS: StructuredFilters = {
Expand All @@ -220,6 +269,7 @@ export const DEFAULT_FILTERS: StructuredFilters = {
keywords: [],
security: 'all',
updatedWithin: 'any',
visibleColumns: DEFAULT_COLUMNS.map(c => c.id),
}

// Pagination modes
Expand Down
4 changes: 3 additions & 1 deletion test/nuxt/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ColumnConfig, FilterChip } from '#shared/types/preferences'
import { DEFAULT_FILTERS, type ColumnConfig, type FilterChip } from '#shared/types/preferences'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import type { VueWrapper } from '@vue/test-utils'
import 'axe-core'
Expand Down Expand Up @@ -2180,6 +2180,7 @@ describe('component accessibility audits', () => {
updatedWithin: 'any' as const,
security: 'all' as const,
keywords: [],
visibleColumns: DEFAULT_FILTERS.visibleColumns,
}

it('should have no accessibility violations (collapsed)', async () => {
Expand Down Expand Up @@ -2213,6 +2214,7 @@ describe('component accessibility audits', () => {
updatedWithin: 'any' as const,
security: 'all' as const,
keywords: [],
visibleColumns: DEFAULT_FILTERS.visibleColumns,
}

const mockColumns: ColumnConfig[] = [
Expand Down
Loading
Loading