filter WIP

This commit is contained in:
Gauthier Roebroeck 2026-01-30 12:14:56 +08:00
parent 81a463a741
commit 558e1be0ee
6 changed files with 306 additions and 1 deletions

View file

@ -36,6 +36,8 @@ declare module 'vue' {
DialogSimpleInstance: typeof import('./components/dialog/DialogSimpleInstance.vue')['default']
EmptyStateConstruction: typeof import('./components/EmptyStateConstruction.vue')['default']
EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default']
FilterList: typeof import('./components/filter/List.vue')['default']
FilterTriState: typeof import('./components/filter/TriState.vue')['default']
FormattedMessage: typeof import('./components/FormattedMessage.ts')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
HistoryExpandBookConverted: typeof import('./components/history/expand/BookConverted.vue')['default']

View file

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import List from './List.vue'
import { fn } from 'storybook/test'
const meta = {
component: List,
render: (args: object) => ({
components: { List },
setup() {
return { args }
},
template: '<List />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: 'List of tri-state choices that allows multiple selection.',
},
},
},
args: {
'onUpdate:modelValue': fn(),
items: [
{ title: 'Tag 1', value: '+tag1', valueExclude: '-tag1' },
{ title: 'Tag 2', value: '+tag2', valueExclude: '-tag2' },
{ title: 'Tag 3', value: '+tag3', valueExclude: '-tag3' },
{ title: 'Tag include only', value: '+tag4' },
],
},
} satisfies Meta<typeof List>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const InitialValue: Story = {
args: {
modelValue: ['+tag1', '-tag2', 'crap'],
},
}
export const Color: Story = {
args: {
color: 'red',
},
}
export const Objects: Story = {
args: {
items: [
{ title: 'Tag 1', value: { include: 'tag1' }, valueExclude: { exclude: 'tag1' } },
{ title: 'Tag 2', value: { include: 'tag2' }, valueExclude: { exclude: 'tag2' } },
{ title: 'Tag 3', value: { include: 'tag3' }, valueExclude: { exclude: 'tag3' } },
{ title: 'Tag include only', value: { include: 'tag4' } },
],
},
}

View file

@ -0,0 +1,84 @@
<template>
<v-checkbox
:model-value="anyAll === 'allOf'"
label="All of"
@update:model-value="(v) => (anyAll = v ? 'allOf' : 'anyOf')"
/>
<v-list>
<FilterTriState
v-for="(item, i) in items"
:key="i"
v-model="internalModel[i]"
:label="item.title"
:tri-state="!!item.valueExclude"
:color="color"
/>
</v-list>
</template>
<script setup lang="ts">
import { syncRef } from '@vueuse/core'
import type { AnyAll } from '@/types/filter'
const model = defineModel<unknown[]>({ default: [] })
const anyAll = defineModel<AnyAll>('mode', { default: 'anyOf' })
//TODO: handle mode better
//TODO: add clear all?
const { items = [], color } = defineProps<{
items?: {
title: string
value: unknown
valueExclude?: unknown
}[]
color?: string
}>()
function initializeInternalModel() {
const c: Record<number, string | undefined> = {}
items.forEach((_it, i) => (c[i] = undefined))
return c
}
const internalModel = ref<Record<number, string | undefined>>(initializeInternalModel())
syncRef(model, internalModel, {
direction: 'both',
deep: true,
transform: {
ltr: (left) =>
left.reduce((acc, item) => {
const indexInclude = items.findIndex(
(it) => JSON.stringify(it.value) === JSON.stringify(item),
)
if (indexInclude >= 0)
return {
...(acc as Record<number, string | undefined>),
[indexInclude]: 'include',
}
const indexExclude = items.findIndex(
(it) => JSON.stringify(it.valueExclude) === JSON.stringify(item),
)
if (indexExclude >= 0)
return {
...(acc as Record<number, string | undefined>),
[indexExclude]: 'exclude',
}
return acc
}, {}) as Record<number, string | undefined>,
rtl: (right) =>
items
.map((it, i) =>
right[i] === 'include' ? it.value : right[i] === 'exclude' ? it.valueExclude : undefined,
)
.filter((it) => it != undefined),
},
})
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -0,0 +1,60 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TriState from './TriState.vue'
import { fn } from 'storybook/test'
const meta = {
component: TriState,
render: (args: object) => ({
components: { TriState },
setup() {
return { args }
},
template: '<TriState />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component:
'A tri-state component used for filtering. Can also be configured as a simple checkbox.',
},
},
},
args: {
label: 'tri state',
'onUpdate:modelValue': fn(),
},
} satisfies Meta<typeof TriState>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Color: Story = {
args: {
color: 'primary',
},
}
export const ValidModel: Story = {
args: {
modelValue: 'exclude',
},
}
export const InvalidModel: Story = {
args: {
modelValue: 'nope',
},
}
export const BiState: Story = {
args: {
label: 'bi state',
triState: false,
},
}

View file

@ -0,0 +1,57 @@
<template>
<v-list-item
:title="label"
@click="cycle()"
><template #prepend
><v-icon
:icon="icon"
:color="!!model ? color : undefined" /></template
></v-list-item>
</template>
<script setup lang="ts">
const model = defineModel<string>()
const icon = computed(() => states.value.find((it) => it.value === model.value)?.icon)
const {
label,
triState = true,
color,
} = defineProps<{
label: string
triState?: boolean
color?: string
}>()
const states = computed(() => [
{
icon: 'i-mdi:checkbox-marked',
value: 'include',
},
...(triState
? [
{
icon: 'i-mdi:close-box',
value: 'exclude',
},
]
: []),
{
icon: 'i-mdi:checkbox-blank-outline',
value: undefined,
},
])
if (!states.value.some((it) => it.value === model.value)) model.value = undefined
function cycle() {
const index = states.value.findIndex((x) => x.value === model.value)
const newIndex = (index + 1) % states.value.length
model.value = states.value[newIndex]!.value
}
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -27,6 +27,17 @@
/>
</v-app-bar>
<div>FILTER</div>
<div>{{ filter }}</div>
<div>CONDITION</div>
<div>{{ conds }}</div>
<FilterList
v-model="filter.v"
v-model:mode="filter.m"
:items="filterStatusItems"
/>
<template v-if="series">
<v-data-iterator
v-model="selectedItems"
@ -101,6 +112,10 @@ import { useSearchConditionLibraries } from '@/composables/search'
import { storeToRefs } from 'pinia'
import { useSelectionStore } from '@/stores/selection'
import { useDisplay } from 'vuetify'
import { schemaFilterSeriesStatusToConditions } from '@/functions/filter'
import * as v from 'valibot'
import { SchemaFilterSeriesStatus } from '@/types/filter'
import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema'
const route = useRoute('/libraries/[id]/series')
const libraryId = route.params.id
@ -122,9 +137,19 @@ const selectionStore = useSelectionStore()
const { selection: selectedItems } = storeToRefs(selectionStore)
const preSelect = computed(() => selectedItems.value.length > 0)
const { data: filter } = useRouteQuerySchema('status', SchemaFilterSeriesStatus)
const conds = computed(() => ({
allOf: [
librariesCondition.value as components['schemas']['AnyOfSeries'],
schemaFilterSeriesStatusToConditions(filter),
],
}))
const { data: series } = useQuery(seriesListQuery, () => {
const search: components['schemas']['SeriesSearch'] = {
condition: librariesCondition.value as components['schemas']['AnyOfSeries'],
// condition: librariesCondition.value as components['schemas']['AnyOfSeries'],
condition: conds.value as components['schemas']['AllOfSeries'],
}
return {
@ -136,6 +161,21 @@ const { data: series } = useQuery(seriesListQuery, () => {
watch(series, (newSeries) => {
if (newSeries) pageCount.value = newSeries.totalPages ?? 0
})
const filterStatusItems: {
title: string
value: v.InferOutput<typeof SeriesStatus>
valueExclude?: v.InferOutput<typeof SeriesStatus>
}[] = [
{ title: 'Ended', value: { i: 'i', v: 'ENDED' }, valueExclude: { i: 'e', v: 'ENDED' } },
{ title: 'Ongoing', value: { i: 'i', v: 'ONGOING' }, valueExclude: { i: 'e', v: 'ONGOING' } },
{ title: 'Hiatus', value: { i: 'i', v: 'HIATUS' }, valueExclude: { i: 'e', v: 'HIATUS' } },
{
title: 'Abandoned',
value: { i: 'i', v: 'ABANDONED' },
valueExclude: { i: 'e', v: 'ABANDONED' },
},
]
</script>
<route lang="yaml">