From 6850c45d6302c4e729945ee0bbc0068e831adfb0 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Tue, 9 Dec 2025 13:52:33 -0800 Subject: [PATCH] [feat] Add ownership filter to model browser (#7201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a dropdown filter to the model browser that allows users to filter assets by ownership (All, My models, Public models), based on the `is_immutable` property. ## Changes - **Filter UI**: Added ownership dropdown in [AssetFilterBar.vue](src/platform/assets/components/AssetFilterBar.vue#L30-L38) that only appears when user has uploaded models - **Filter Logic**: Implemented `filterByOwnership` function in [useAssetBrowser.ts](src/platform/assets/composables/useAssetBrowser.ts#L38-L45) to filter by `is_immutable` property - **i18n**: Added translation strings for ownership filter options - **Tests**: Added comprehensive tests for ownership filtering in both composable and component test files ## Review Focus - The ownership filter visibility logic correctly checks for mutable assets (`!is_immutable`) - Default filter value is 'all' to show all models initially - Filter integrates cleanly with existing file format and base model filters 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7201-feat-Add-ownership-filter-to-model-browser-2c16d73d365081f280f6d1e42e5400af) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Co-authored-by: GitHub Action --- src/locales/en/main.json | 6 +- .../assets/components/AssetBrowserModal.vue | 1 + .../assets/components/AssetFilterBar.vue | 44 +++- .../assets/composables/useAssetBrowser.ts | 15 +- .../assets/fixtures/ui-mock-assets.ts | 8 +- .../assets/components/AssetFilterBar.test.ts | 222 +++++++++++++----- .../composables/useAssetBrowser.test.ts | 106 ++++++++- 7 files changed, 328 insertions(+), 74 deletions(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 9f21a6d2f..34f963d23 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2160,15 +2160,19 @@ "noModelsInFolder": "No {type} available in this folder", "notSureLeaveAsIs": "Not sure? Just leave this as is", "onlyCivitaiUrlsSupported": "Only Civitai URLs are supported", + "ownership": "Ownership", + "ownershipAll": "All", + "ownershipMyModels": "My models", + "ownershipPublicModels": "Public models", "selectFrameworks": "Select Frameworks", "selectModelType": "Select model type", "selectProjects": "Select Projects", "sortAZ": "A-Z", "sortBy": "Sort by", - "sortingType": "Sorting Type", "sortPopular": "Popular", "sortRecent": "Recent", "sortZA": "Z-A", + "sortingType": "Sorting Type", "tags": "Tags", "tagsHelp": "Separate tags with commas", "tagsPlaceholder": "e.g., models, checkpoint", diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index ef85ce5f9..e59386177 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -48,6 +48,7 @@ diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue index a86a7a1a8..0ffcba32d 100644 --- a/src/platform/assets/components/AssetFilterBar.vue +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -26,6 +26,16 @@ data-component-id="asset-filter-base-models" @update:model-value="handleFilterChange" /> + +
@@ -46,21 +56,16 @@ diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts index e5913e94a..799834614 100644 --- a/src/platform/assets/composables/useAssetBrowser.ts +++ b/src/platform/assets/composables/useAssetBrowser.ts @@ -11,6 +11,8 @@ import { getAssetDescription } from '@/platform/assets/utils/assetMetadataUtils' +export type OwnershipOption = 'all' | 'my-models' | 'public-models' + function filterByCategory(category: string) { return (asset: AssetItem) => { return category === 'all' || asset.tags.includes(category) @@ -35,6 +37,15 @@ function filterByBaseModels(models: string[]) { } } +function filterByOwnership(ownership: OwnershipOption) { + return (asset: AssetItem) => { + if (ownership === 'all') return true + if (ownership === 'my-models') return asset.is_immutable === false + if (ownership === 'public-models') return asset.is_immutable === true + return true + } +} + type AssetBadge = { label: string type: 'type' | 'base' | 'size' @@ -65,7 +76,8 @@ export function useAssetBrowser( const filters = ref({ sortBy: 'recent', fileFormats: [], - baseModels: [] + baseModels: [], + ownership: 'all' }) // Transform API asset to display asset @@ -176,6 +188,7 @@ export function useAssetBrowser( const filtered = searchFiltered.value .filter(filterByFileFormats(filters.value.fileFormats)) .filter(filterByBaseModels(filters.value.baseModels)) + .filter(filterByOwnership(filters.value.ownership)) const sortedAssets = [...filtered] sortedAssets.sort((a, b) => { diff --git a/src/platform/assets/fixtures/ui-mock-assets.ts b/src/platform/assets/fixtures/ui-mock-assets.ts index 482a48af9..a31c7b859 100644 --- a/src/platform/assets/fixtures/ui-mock-assets.ts +++ b/src/platform/assets/fixtures/ui-mock-assets.ts @@ -146,9 +146,15 @@ export function createAssetWithoutUserMetadata() { return asset } -export function createAssetWithSpecificExtension(extension: string) { +export function createAssetWithSpecificExtension( + extension: string, + isImmutable?: boolean +) { const asset = createMockAssets(1)[0] asset.name = `test-model.${extension}` + if (isImmutable !== undefined) { + asset.is_immutable = isImmutable + } return asset } diff --git a/tests-ui/platform/assets/components/AssetFilterBar.test.ts b/tests-ui/platform/assets/components/AssetFilterBar.test.ts index d9762e978..49d58b55b 100644 --- a/tests-ui/platform/assets/components/AssetFilterBar.test.ts +++ b/tests-ui/platform/assets/components/AssetFilterBar.test.ts @@ -73,51 +73,83 @@ function mountAssetFilterBar(props = {}) { }) } +// Helper functions to find filters by user-facing attributes +function findFileFormatsFilter( + wrapper: ReturnType +) { + return wrapper.findComponent( + '[data-component-id="asset-filter-file-formats"]' + ) +} + +function findBaseModelsFilter(wrapper: ReturnType) { + return wrapper.findComponent('[data-component-id="asset-filter-base-models"]') +} + +function findOwnershipFilter(wrapper: ReturnType) { + return wrapper.findComponent('[data-component-id="asset-filter-ownership"]') +} + +function findSortFilter(wrapper: ReturnType) { + return wrapper.findComponent('[data-component-id="asset-filter-sort"]') +} + describe('AssetFilterBar', () => { describe('Filter State Management', () => { it('handles multiple simultaneous filter changes correctly', async () => { // Provide assets with options so filters are visible const assets = [ createAssetWithSpecificExtension('safetensors'), - createAssetWithSpecificBaseModel('sd15') + createAssetWithSpecificExtension('ckpt'), + createAssetWithSpecificBaseModel('sd15'), + createAssetWithSpecificBaseModel('sdxl') ] const wrapper = mountAssetFilterBar({ assets }) // Update file formats - const fileFormatSelect = wrapper.findAllComponents({ - name: 'MultiSelect' - })[0] - await fileFormatSelect.vm.$emit('update:modelValue', [ - { name: '.ckpt', value: 'ckpt' }, - { name: '.safetensors', value: 'safetensors' } - ]) + const fileFormatSelect = findFileFormatsFilter(wrapper) + const fileFormatSelectElement = fileFormatSelect.find('select') + const options = fileFormatSelectElement.findAll('option') + const ckptOption = options.find((o) => o.element.value === 'ckpt')! + const safetensorsOption = options.find( + (o) => o.element.value === 'safetensors' + )! + ckptOption.element.selected = true + safetensorsOption.element.selected = true + await fileFormatSelectElement.trigger('change') await nextTick() // Update base models - const baseModelSelect = wrapper.findAllComponents({ - name: 'MultiSelect' - })[1] - await baseModelSelect.vm.$emit('update:modelValue', [ - { name: 'SD XL', value: 'sdxl' } - ]) + const baseModelSelect = findBaseModelsFilter(wrapper) + const baseModelSelectElement = baseModelSelect.find('select') + const sdxlOption = baseModelSelectElement + .findAll('option') + .find((o) => o.element.value === 'sdxl') + sdxlOption!.element.selected = true + await baseModelSelectElement.trigger('change') await nextTick() // Update sort - const sortSelect = wrapper.findComponent({ name: 'SingleSelect' }) - await sortSelect.vm.$emit('update:modelValue', 'popular') + const sortSelect = findSortFilter(wrapper) + const sortSelectElement = sortSelect.find('select') + sortSelectElement.element.value = 'name-desc' + await sortSelectElement.trigger('change') await nextTick() const emitted = wrapper.emitted('filterChange') - expect(emitted).toHaveLength(3) + expect(emitted).toBeTruthy() + expect(emitted!.length).toBeGreaterThanOrEqual(3) // Check final state - const finalState: FilterState = emitted![2][0] as FilterState + const finalState: FilterState = emitted![ + emitted!.length - 1 + ][0] as FilterState expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors']) expect(finalState.baseModels).toEqual(['sdxl']) - expect(finalState.sortBy).toBe('popular') + expect(finalState.sortBy).toBe('name-desc') }) it('ensures FilterState interface compliance', async () => { @@ -128,12 +160,11 @@ describe('AssetFilterBar', () => { ] const wrapper = mountAssetFilterBar({ assets }) - const fileFormatSelect = wrapper.findAllComponents({ - name: 'MultiSelect' - })[0] - await fileFormatSelect.vm.$emit('update:modelValue', [ - { name: '.ckpt', value: 'ckpt' } - ]) + const fileFormatSelect = findFileFormatsFilter(wrapper) + const fileFormatSelectElement = fileFormatSelect.find('select') + const ckptOption = fileFormatSelectElement.findAll('option')[0] + ckptOption.element.selected = true + await fileFormatSelectElement.trigger('change') await nextTick() @@ -165,10 +196,11 @@ describe('AssetFilterBar', () => { const wrapper = mountAssetFilterBar({ assets }) - const fileFormatSelect = wrapper.findAllComponents({ - name: 'MultiSelect' - })[0] - expect(fileFormatSelect.props('options')).toEqual([ + const fileFormatSelect = findFileFormatsFilter(wrapper) + const options = fileFormatSelect.findAll('option') + expect( + options.map((o) => ({ name: o.text(), value: o.element.value })) + ).toEqual([ { name: '.ckpt', value: 'ckpt' }, { name: '.pt', value: 'pt' }, { name: '.safetensors', value: 'safetensors' } @@ -184,10 +216,11 @@ describe('AssetFilterBar', () => { const wrapper = mountAssetFilterBar({ assets }) - const baseModelSelect = wrapper.findAllComponents({ - name: 'MultiSelect' - })[1] - expect(baseModelSelect.props('options')).toEqual([ + const baseModelSelect = findBaseModelsFilter(wrapper) + const options = baseModelSelect.findAll('option') + expect( + options.map((o) => ({ name: o.text(), value: o.element.value })) + ).toEqual([ { name: 'sd15', value: 'sd15' }, { name: 'sd35', value: 'sd35' }, { name: 'sdxl', value: 'sdxl' } @@ -200,26 +233,16 @@ describe('AssetFilterBar', () => { const assets: AssetItem[] = [] // No assets = no file format options const wrapper = mountAssetFilterBar({ assets }) - const fileFormatSelects = wrapper - .findAllComponents({ name: 'MultiSelect' }) - .filter( - (component) => component.props('label') === 'assetBrowser.fileFormats' - ) - - expect(fileFormatSelects).toHaveLength(0) + const fileFormatSelect = findFileFormatsFilter(wrapper) + expect(fileFormatSelect.exists()).toBe(false) }) it('hides base model filter when no options available', () => { const assets = [createAssetWithoutBaseModel()] // Asset without base model = no base model options const wrapper = mountAssetFilterBar({ assets }) - const baseModelSelects = wrapper - .findAllComponents({ name: 'MultiSelect' }) - .filter( - (component) => component.props('label') === 'assetBrowser.baseModels' - ) - - expect(baseModelSelects).toHaveLength(0) + const baseModelSelect = findBaseModelsFilter(wrapper) + expect(baseModelSelect.exists()).toBe(false) }) it('shows both filters when options are available', () => { @@ -229,23 +252,106 @@ describe('AssetFilterBar', () => { ] const wrapper = mountAssetFilterBar({ assets }) - const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' }) - const fileFormatSelect = multiSelects.find( - (component) => component.props('label') === 'assetBrowser.fileFormats' - ) - const baseModelSelect = multiSelects.find( - (component) => component.props('label') === 'assetBrowser.baseModels' - ) + const fileFormatSelect = findFileFormatsFilter(wrapper) + const baseModelSelect = findBaseModelsFilter(wrapper) - expect(fileFormatSelect).toBeDefined() - expect(baseModelSelect).toBeDefined() + expect(fileFormatSelect.exists()).toBe(true) + expect(baseModelSelect.exists()).toBe(true) }) it('hides both filters when no assets provided', () => { const wrapper = mountAssetFilterBar() - const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' }) - expect(multiSelects).toHaveLength(0) + const fileFormatSelect = findFileFormatsFilter(wrapper) + const baseModelSelect = findBaseModelsFilter(wrapper) + + expect(fileFormatSelect.exists()).toBe(false) + expect(baseModelSelect.exists()).toBe(false) + }) + + it('hides ownership filter when no mutable assets', () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', true) // immutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const ownershipSelect = findOwnershipFilter(wrapper) + expect(ownershipSelect.exists()).toBe(false) + }) + + it('shows ownership filter when mutable assets exist', () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const ownershipSelect = findOwnershipFilter(wrapper) + expect(ownershipSelect.exists()).toBe(true) + }) + + it('shows ownership filter when mixed assets exist', () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', true), // immutable + createAssetWithSpecificExtension('ckpt', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const ownershipSelect = findOwnershipFilter(wrapper) + expect(ownershipSelect.exists()).toBe(true) + }) + + it('shows ownership filter with allAssets when provided', () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', true) // immutable + ] + const allAssets = [ + createAssetWithSpecificExtension('safetensors', true), // immutable + createAssetWithSpecificExtension('ckpt', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets, allAssets }) + + const ownershipSelect = findOwnershipFilter(wrapper) + expect(ownershipSelect.exists()).toBe(true) + }) + }) + + describe('Ownership Filter', () => { + it('emits ownership filter changes', async () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const ownershipSelect = findOwnershipFilter(wrapper) + expect(ownershipSelect.exists()).toBe(true) + + const ownershipSelectElement = ownershipSelect.find('select') + ownershipSelectElement.element.value = 'my-models' + await ownershipSelectElement.trigger('change') + await nextTick() + + const emitted = wrapper.emitted('filterChange') + expect(emitted).toBeTruthy() + + const filterState = emitted![emitted!.length - 1][0] as FilterState + expect(filterState.ownership).toBe('my-models') + }) + + it('ownership filter defaults to "all"', async () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const sortSelect = findSortFilter(wrapper) + const sortSelectElement = sortSelect.find('select') + sortSelectElement.element.value = 'recent' + await sortSelectElement.trigger('change') + await nextTick() + + const emitted = wrapper.emitted('filterChange') + const filterState = emitted![0][0] as FilterState + expect(filterState.ownership).toBe('all') }) }) }) diff --git a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts index 92ea78c33..9c724f32b 100644 --- a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts +++ b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts @@ -249,7 +249,8 @@ describe('useAssetBrowser', () => { updateFilters({ sortBy: 'name-asc', fileFormats: ['safetensors'], - baseModels: [] + baseModels: [], + ownership: 'all' }) await nextTick() @@ -284,7 +285,8 @@ describe('useAssetBrowser', () => { updateFilters({ sortBy: 'name-asc', fileFormats: [], - baseModels: ['SDXL'] + baseModels: ['SDXL'], + ownership: 'all' }) await nextTick() @@ -335,7 +337,12 @@ describe('useAssetBrowser', () => { const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) - updateFilters({ sortBy: 'name', fileFormats: [], baseModels: [] }) + updateFilters({ + sortBy: 'name', + fileFormats: [], + baseModels: [], + ownership: 'all' + }) await nextTick() const names = filteredAssets.value.map((asset) => asset.name) @@ -355,7 +362,12 @@ describe('useAssetBrowser', () => { const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) - updateFilters({ sortBy: 'recent', fileFormats: [], baseModels: [] }) + updateFilters({ + sortBy: 'recent', + fileFormats: [], + baseModels: [], + ownership: 'all' + }) await nextTick() const dates = filteredAssets.value.map((asset) => asset.created_at) @@ -367,6 +379,92 @@ describe('useAssetBrowser', () => { }) }) + describe('Ownership filtering', () => { + it('filters by ownership - all', async () => { + const assets = [ + createApiAsset({ name: 'my-model.safetensors', is_immutable: false }), + createApiAsset({ + name: 'public-model.safetensors', + is_immutable: true + }), + createApiAsset({ + name: 'another-my-model.safetensors', + is_immutable: false + }) + ] + + const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) + + updateFilters({ + sortBy: 'name-asc', + fileFormats: [], + baseModels: [], + ownership: 'all' + }) + await nextTick() + + expect(filteredAssets.value).toHaveLength(3) + }) + + it('filters by ownership - my models only', async () => { + const assets = [ + createApiAsset({ name: 'my-model.safetensors', is_immutable: false }), + createApiAsset({ + name: 'public-model.safetensors', + is_immutable: true + }), + createApiAsset({ + name: 'another-my-model.safetensors', + is_immutable: false + }) + ] + + const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) + + updateFilters({ + sortBy: 'name-asc', + fileFormats: [], + baseModels: [], + ownership: 'my-models' + }) + await nextTick() + + expect(filteredAssets.value).toHaveLength(2) + expect(filteredAssets.value.every((asset) => !asset.is_immutable)).toBe( + true + ) + }) + + it('filters by ownership - public models only', async () => { + const assets = [ + createApiAsset({ name: 'my-model.safetensors', is_immutable: false }), + createApiAsset({ + name: 'public-model.safetensors', + is_immutable: true + }), + createApiAsset({ + name: 'another-public-model.safetensors', + is_immutable: true + }) + ] + + const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) + + updateFilters({ + sortBy: 'name-asc', + fileFormats: [], + baseModels: [], + ownership: 'public-models' + }) + await nextTick() + + expect(filteredAssets.value).toHaveLength(2) + expect(filteredAssets.value.every((asset) => asset.is_immutable)).toBe( + true + ) + }) + }) + describe('Dynamic Category Extraction', () => { it('extracts categories from asset tags', () => { const assets = [