diff --git a/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts b/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts index 028b818..4b6ec08 100644 --- a/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts +++ b/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts @@ -38,4 +38,5 @@ export function canToggleContentItem(item: ContentItem): boolean { export interface ContentFilterOption { id: string label: string + tooltip?: string } diff --git a/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.test.ts b/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.test.ts new file mode 100644 index 0000000..29fb59d --- /dev/null +++ b/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import test, { type TestContext } from 'node:test' + +import { effectScope, nextTick, ref } from 'vue' + +import type { ContentItem } from '../types.ts' + +// Keep the real Vue state, search, and filter pipeline; replace only the UI's +// translation provider, which normally requires an application context. +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === '#ui/composables/i18n' || specifier === '../composables/i18n') { + return { + url: `data:text/javascript,${encodeURIComponent('export const defineMessage=x=>x, defineMessages=x=>x, useVIntl=()=>({formatMessage:x=>x.defaultMessage})')}`, + shortCircuit: true, + } + } + if (specifier.startsWith('#ui/')) { + return nextResolve( + new URL(`../../../../${specifier.slice(4)}.ts`, import.meta.url).href, + context, + ) + } + if (specifier.startsWith('./') && !specifier.endsWith('.ts')) { + return nextResolve(`${specifier}.ts`, context) + } + return nextResolve(specifier, context) + }, +}) +const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') +Object.defineProperty(globalThis, 'window', { configurable: true, value: {} }) +const { useContentPipeline } = await import('./content-pipeline.ts') +hooks.deregister() +if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) +else Reflect.deleteProperty(globalThis, 'window') + +function item(id: string, overrides: Partial = {}): ContentItem { + return { + id, + project: { id, title: id, slug: id, icon_url: null }, + file_name: `${id}.jar`, + project_type: 'mod', + update: null, + origin_provider: null, + provider_refs: [], + enabled: true, + ...overrides, + } +} + +function fixture(t: TestContext) { + t.mock.timers.enable({ apis: ['setTimeout'] }) + const update = { + provider: 'modrinth' as const, + project_id: 'project', + current_version_id: 'v1', + target_version_id: 'v2', + } + const items = ref([ + item('ordinary'), + item('update', { update }), + item('warning', { environment: 'client_only' }), + item('both', { update, pack_client_depends: true }), + item('disabled', { enabled: false }), + ]) + const scope = effectScope() + t.after(() => scope.stop()) + const pipeline = scope.run(() => + useContentPipeline({ + items, + duplicateItems: ref([]), + sortItems: (items) => items, + getItemId: (item) => item.id, + showUpdateFilter: true, + showWarningsFilter: true, + }), + )! + return { + ...pipeline, + ids: () => pipeline.filteredItems.value.map((item) => item.id), + async flush() { + await nextTick() + t.mock.timers.tick(100) + await nextTick() + }, + } +} + +test('status filters update the visible list on every click without reloading items', async (t) => { + const p = fixture(t) + await p.flush() + assert.equal(p.ids().length, 5) + p.toggleStatusFilter('updates') + await p.flush() + assert.deepEqual(p.ids(), ['update', 'both']) + p.toggleStatusFilter('warnings') + await p.flush() + assert.deepEqual(p.ids(), ['both']) + p.toggleStatusFilter('updates') + await p.flush() + assert.deepEqual(p.ids(), ['warning', 'both']) + p.toggleStatusFilter('warnings') + await p.flush() + assert.equal(p.ids().length, 5) +}) + +test('All clears status filters and restores the visible list without a refresh', async (t) => { + const p = fixture(t) + await p.flush() + p.toggleStatusFilter('warnings') + await p.flush() + assert.deepEqual(p.ids(), ['warning', 'both']) + p.selectedStatusFilters.value = [] + await p.flush() + assert.equal(p.ids().length, 5) +}) + +test('enabled and disabled remain exclusive and can each be deselected', async (t) => { + const p = fixture(t) + await p.flush() + p.toggleStatusFilter('enabled') + await p.flush() + assert.deepEqual(p.ids(), ['ordinary', 'update', 'warning', 'both']) + p.toggleStatusFilter('enabled') + await p.flush() + assert.equal(p.ids().length, 5) + p.toggleStatusFilter('enabled') + p.toggleStatusFilter('disabled') + await p.flush() + assert.deepEqual(p.selectedStatusFilters.value, ['disabled']) + assert.deepEqual(p.ids(), ['disabled']) + p.toggleStatusFilter('disabled') + await p.flush() + assert.equal(p.ids().length, 5) +}) diff --git a/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.ts b/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.ts index 8cee041..dac72c5 100644 --- a/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.ts +++ b/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.ts @@ -62,9 +62,14 @@ const filterMessages = defineMessages({ id: 'content.filter.updates', defaultMessage: 'Update available', }, - warnings: { - id: 'content.filter.warnings', - defaultMessage: 'Warnings', + clientOnly: { + id: 'content.filter.client-only', + defaultMessage: 'Client only', + }, + clientOnlyDescription: { + id: 'content.filter.client-only-description', + defaultMessage: + 'Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers.', }, duplicates: { id: 'content.filter.duplicates', @@ -329,7 +334,11 @@ export function useContentPipeline(config: ContentPipelineConfig) { row2.push({ id: 'updates', label: formatMessage(filterMessages.updates) }) } if (showWarningsFilter && typeFiltered.some((m) => getClientWarningType(m) !== null)) { - row2.push({ id: 'warnings', label: formatMessage(filterMessages.warnings) }) + row2.push({ + id: 'warnings', + label: formatMessage(filterMessages.clientOnly), + tooltip: formatMessage(filterMessages.clientOnlyDescription), + }) } if (hasEnabled && hasDisabled) { @@ -472,26 +481,20 @@ export function useContentPipeline(config: ContentPipelineConfig) { } function toggleStatusFilter(filterId: string) { - if (filterId === 'enabled' || filterId === 'disabled') { - const index = selectedStatusFilters.value.indexOf(filterId) - const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled' - if (index === -1) { - selectedStatusFilters.value = [ - ...selectedStatusFilters.value.filter((f) => f !== otherStatusFilter), - filterId, - ] - } else { - selectedStatusFilters.value.splice(index, 1) - } + const filters = selectedStatusFilters.value + // Replace the array so both the pipeline and saved-filter watchers are notified. + if (filters.includes(filterId)) { + selectedStatusFilters.value = filters.filter((id) => id !== filterId) return } - const index = selectedStatusFilters.value.indexOf(filterId) - if (index === -1) { - selectedStatusFilters.value.push(filterId) - } else { - selectedStatusFilters.value.splice(index, 1) + if (filterId === 'enabled' || filterId === 'disabled') { + const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled' + selectedStatusFilters.value = [...filters.filter((id) => id !== otherStatusFilter), filterId] + return } + + selectedStatusFilters.value = [...filters, filterId] } return { diff --git a/packages/ui/src/layouts/shared/content-tab/layout.vue b/packages/ui/src/layouts/shared/content-tab/layout.vue index 28c0c3c..8fd3e38 100644 --- a/packages/ui/src/layouts/shared/content-tab/layout.vue +++ b/packages/ui/src/layouts/shared/content-tab/layout.vue @@ -1110,6 +1110,7 @@ const confirmUnlinkModal = ref>()