refactor: uniform validation, fix create form UX, dedupe filter dropdowns
- Remove dead registerValidator (Auth/register.ts), unused since signupValidator is the one actually wired to NewAccountController - computers/create.vue: switch to useForm() + @submit.prevent so the form submits on Enter and shows a processing state, matching components/create.vue - components/edit.vue: drop the 'origin' field, which was never provided by the backend (edit() never shared it) and therefore always undefined - Extract FilterDropdown.vue and reuse it in ComponentsViewer/ComputersViewer, removing ~80 lines of duplicated dropdown markup/logic and fixing duplicate HTML ids (id="used" appeared twice in ComputersViewer) - Move MIGRATION_*.md artifacts into docs/migration-history/
This commit is contained in:
1 parent
3cc189c534
commit
3579c9dfec
10 files changed
+163
-262
No files matched your search
Vendored
+1
@@ -37,5 +37,6 @@ declare module '@adonisjs/inertia/types' {
|
|||||||
'widgets/SimpleGallery': ExtractProps<(typeof import('../../inertia/pages/widgets/SimpleGallery.vue'))['default']>
|
'widgets/SimpleGallery': ExtractProps<(typeof import('../../inertia/pages/widgets/SimpleGallery.vue'))['default']>
|
||||||
'widgets/TableComponent': ExtractProps<(typeof import('../../inertia/pages/widgets/TableComponent.vue'))['default']>
|
'widgets/TableComponent': ExtractProps<(typeof import('../../inertia/pages/widgets/TableComponent.vue'))['default']>
|
||||||
'widgets/ValidationError': ExtractProps<(typeof import('../../inertia/pages/widgets/ValidationError.vue'))['default']>
|
'widgets/ValidationError': ExtractProps<(typeof import('../../inertia/pages/widgets/ValidationError.vue'))['default']>
|
||||||
|
'widgets/FilterDropdown': ExtractProps<(typeof import('../../inertia/pages/widgets/FilterDropdown.vue'))['default']>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import vine from '@vinejs/vine'
|
|
||||||
|
|
||||||
export const registerValidator = vine.compile(
|
|
||||||
vine.object({
|
|
||||||
name: vine.string().minLength(3).maxLength(64),
|
|
||||||
email: vine
|
|
||||||
.string()
|
|
||||||
.email()
|
|
||||||
.unique(async (query, field) => {
|
|
||||||
const user = await query.from('users').where('email', field).first()
|
|
||||||
return !user
|
|
||||||
}),
|
|
||||||
password: vine.string().minLength(8).maxLength(32),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -3,14 +3,14 @@ import { reactive } from 'vue'
|
|||||||
import { Link } from '@adonisjs/inertia/vue'
|
import { Link } from '@adonisjs/inertia/vue'
|
||||||
import { usePage } from '@inertiajs/vue3'
|
import { usePage } from '@inertiajs/vue3'
|
||||||
import CustomSelect from '~/pages/widgets/CustomSelect.vue'
|
import CustomSelect from '~/pages/widgets/CustomSelect.vue'
|
||||||
|
import type { Component, ComponentType } from '~/pages/types/UITypes'
|
||||||
|
|
||||||
const { props } = usePage<{
|
const { props } = usePage<{
|
||||||
component: any
|
component: Component
|
||||||
types: any[]
|
types: ComponentType[]
|
||||||
origin: string
|
|
||||||
errors?: Record<string, string>
|
errors?: Record<string, string>
|
||||||
}>()
|
}>()
|
||||||
const { component, types, origin } = props
|
const { component, types } = props
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: component.id,
|
id: component.id,
|
||||||
@@ -18,7 +18,6 @@ const form = reactive({
|
|||||||
price: component.price,
|
price: component.price,
|
||||||
typeId: component.type.id,
|
typeId: component.type.id,
|
||||||
purchaseDate: component.purchaseDate,
|
purchaseDate: component.purchaseDate,
|
||||||
origin: origin,
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Link } from '@adonisjs/inertia/vue'
|
import { Link } from '@adonisjs/inertia/vue'
|
||||||
import { usePage } from '@inertiajs/vue3'
|
import { useForm, usePage } from '@inertiajs/vue3'
|
||||||
|
import type { Component, ComputerState } from '~/pages/types/UITypes'
|
||||||
|
|
||||||
const { props } = usePage<{
|
const { props } = usePage<{
|
||||||
components: any[]
|
components: Component[]
|
||||||
states: any[]
|
states: ComputerState[]
|
||||||
errors: Record<string, string>
|
errors: Record<string, string>
|
||||||
}>()
|
}>()
|
||||||
const { components, states } = props
|
const { components, states } = props
|
||||||
@@ -26,7 +27,7 @@ const componentTypeOrder = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const componentsByType = computed(() => {
|
const componentsByType = computed(() => {
|
||||||
const groups = new Map<string, any[]>()
|
const groups = new Map<string, Component[]>()
|
||||||
components.forEach((component) => {
|
components.forEach((component) => {
|
||||||
if (!groups.has(component.type.name)) {
|
if (!groups.has(component.type.name)) {
|
||||||
groups.set(component.type.name, [])
|
groups.set(component.type.name, [])
|
||||||
@@ -38,21 +39,35 @@ const componentsByType = computed(() => {
|
|||||||
.sort((a, b) => componentTypeOrder.indexOf(a.type) - componentTypeOrder.indexOf(b.type))
|
.sort((a, b) => componentTypeOrder.indexOf(a.type) - componentTypeOrder.indexOf(b.type))
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = reactive({
|
const form = useForm<{
|
||||||
|
title: string
|
||||||
|
sellPrice: number
|
||||||
|
gpuScore: number
|
||||||
|
cpuScore: number
|
||||||
|
stateId: number
|
||||||
|
globalScore: number
|
||||||
|
componentsId: number[]
|
||||||
|
pictures: Record<string, never>[]
|
||||||
|
}>({
|
||||||
title: '',
|
title: '',
|
||||||
sellPrice: 0,
|
sellPrice: 0,
|
||||||
gpuScore: 0,
|
gpuScore: 0,
|
||||||
cpuScore: 0,
|
cpuScore: 0,
|
||||||
stateId: 1,
|
stateId: 1,
|
||||||
globalScore: 0,
|
globalScore: 0,
|
||||||
componentsId: [] as number[],
|
componentsId: [],
|
||||||
|
pictures: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function submitForm() {
|
||||||
|
form.post('/computers')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl mx-auto p-8 bg-white shadow-lg rounded-lg">
|
<div class="max-w-4xl mx-auto p-8 bg-white shadow-lg rounded-lg">
|
||||||
<h1 class="text-2xl font-semibold mb-8 text-gray-800">Create New Computer</h1>
|
<h1 class="text-2xl font-semibold mb-8 text-gray-800">Create New Computer</h1>
|
||||||
<form class="space-y-6">
|
<form class="space-y-6" @submit.prevent="submitForm">
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="title">Title</label>
|
<label class="block text-gray-700 text-sm font-bold mb-2" for="title">Title</label>
|
||||||
<input
|
<input
|
||||||
@@ -149,16 +164,21 @@ const form = reactive({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<div class="flex gap-4">
|
||||||
href="/computers"
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
as="button"
|
:disabled="form.processing"
|
||||||
method="post"
|
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition disabled:opacity-50"
|
||||||
:data="form"
|
>
|
||||||
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition"
|
{{ form.processing ? 'Creating…' : 'Create' }}
|
||||||
>
|
</button>
|
||||||
Create
|
<Link
|
||||||
</Link>
|
href="/computers"
|
||||||
|
class="px-4 py-2 bg-gray-300 text-gray-800 rounded hover:bg-gray-400 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import TableComponent from '~/pages/widgets/TableComponent.vue'
|
import TableComponent from '~/pages/widgets/TableComponent.vue'
|
||||||
|
import FilterDropdown from '~/pages/widgets/FilterDropdown.vue'
|
||||||
import type Component from '#models/component'
|
import type Component from '#models/component'
|
||||||
import type ComponentType from '#models/component_type'
|
import type ComponentType from '#models/component_type'
|
||||||
import { usePage } from '@inertiajs/vue3'
|
import { usePage } from '@inertiajs/vue3'
|
||||||
@@ -8,19 +9,6 @@ import { usePage } from '@inertiajs/vue3'
|
|||||||
const { props } = usePage<{ components: Component[] }>()
|
const { props } = usePage<{ components: Component[] }>()
|
||||||
const { components } = props
|
const { components } = props
|
||||||
|
|
||||||
const usageDropdownOpen = ref(false)
|
|
||||||
const typeDropdownOpen = ref(false)
|
|
||||||
|
|
||||||
function toggleDropdown(dropdown: any) {
|
|
||||||
if (dropdown === 'usage') {
|
|
||||||
usageDropdownOpen.value = !usageDropdownOpen.value
|
|
||||||
typeDropdownOpen.value = false
|
|
||||||
} else if (dropdown === 'type') {
|
|
||||||
typeDropdownOpen.value = !typeDropdownOpen.value
|
|
||||||
usageDropdownOpen.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const componentTypes = computed(() => {
|
const componentTypes = computed(() => {
|
||||||
const types = new Map<number, ComponentType>()
|
const types = new Map<number, ComponentType>()
|
||||||
components.forEach((component) => {
|
components.forEach((component) => {
|
||||||
@@ -31,152 +19,51 @@ const componentTypes = computed(() => {
|
|||||||
return Array.from(types.values())
|
return Array.from(types.values())
|
||||||
})
|
})
|
||||||
|
|
||||||
// D├®finir le type de l'objet filters
|
const typeOptions = computed(() =>
|
||||||
interface Filters {
|
componentTypes.value.map((type) => ({ id: type.id, label: type.name }))
|
||||||
showUsed: boolean
|
)
|
||||||
showUnused: boolean
|
|
||||||
types: { [key: number]: boolean }
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = reactive<Filters>({
|
const usageFilters = ref<Record<string, boolean>>({
|
||||||
showUsed: false,
|
unused: true,
|
||||||
showUnused: true,
|
used: false,
|
||||||
types: componentTypes.value.reduce(
|
})
|
||||||
|
|
||||||
|
const typeFilters = ref<Record<number, boolean>>(
|
||||||
|
componentTypes.value.reduce(
|
||||||
(acc, type) => {
|
(acc, type) => {
|
||||||
acc[type.id] = true
|
acc[type.id] = true
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{} as { [key: number]: boolean }
|
{} as Record<number, boolean>
|
||||||
),
|
)
|
||||||
})
|
)
|
||||||
|
|
||||||
const filteredComponents = computed(() => {
|
const filteredComponents = computed(() => {
|
||||||
return components.filter((component) => {
|
return components.filter((component) => {
|
||||||
const isUsed = filters.showUsed && component.computerId !== null
|
const isUsed = usageFilters.value.used && component.computerId !== null
|
||||||
const isUnused = filters.showUnused && component.computerId === null
|
const isUnused = usageFilters.value.unused && component.computerId === null
|
||||||
const isTypeSelected = filters.types[component.type.id]
|
const isTypeSelected = typeFilters.value[component.type.id]
|
||||||
return (isUsed || isUnused) && isTypeSelected
|
return (isUsed || isUnused) && isTypeSelected
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Dropdown pour les filtres -->
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<h4 class="text-xl underline">Filters</h4>
|
<h4 class="text-xl underline">Filters</h4>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<!-- Usage Dropdown -->
|
<FilterDropdown
|
||||||
<div id="usage-selector" class="relative" style="width: fit-content">
|
v-model="usageFilters"
|
||||||
<button
|
label="Usage"
|
||||||
@click="toggleDropdown('usage')"
|
:options="[
|
||||||
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
|
{ id: 'unused', label: 'Unused' },
|
||||||
type="button"
|
{ id: 'used', label: 'Used' },
|
||||||
>
|
]"
|
||||||
Usage
|
/>
|
||||||
<svg
|
<FilterDropdown v-model="typeFilters" label="Types" :options="typeOptions" />
|
||||||
class="w-4 h-4 ml-2"
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Dropdown menu -->
|
|
||||||
<div
|
|
||||||
v-show="usageDropdownOpen"
|
|
||||||
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
|
|
||||||
>
|
|
||||||
<ul class="space-y-2 text-sm px-4 py-2">
|
|
||||||
<li class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="unused"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="filters.showUnused"
|
|
||||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="unused"
|
|
||||||
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
Unused
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="used"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="filters.showUsed"
|
|
||||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
|
||||||
/>
|
|
||||||
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Used
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type Dropdown -->
|
|
||||||
<div id="type-selector" class="relative" style="width: fit-content">
|
|
||||||
<button
|
|
||||||
@click="toggleDropdown('type')"
|
|
||||||
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Types
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 ml-2"
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Dropdown menu -->
|
|
||||||
<div
|
|
||||||
v-show="typeDropdownOpen"
|
|
||||||
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
|
|
||||||
>
|
|
||||||
<ul class="space-y-2 text-sm px-4 py-2">
|
|
||||||
<li class="flex items-center" v-for="type in componentTypes" :key="type.id">
|
|
||||||
<input
|
|
||||||
:id="`type-${type.id}`"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="filters.types[type.id]"
|
|
||||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
:for="`type-${type.id}`"
|
|
||||||
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tableau des composants filtr├®s -->
|
|
||||||
<TableComponent :components="filteredComponents" />
|
<TableComponent :components="filteredComponents" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -3,106 +3,45 @@ import { noImagePlaceholder } from '~/assets/images'
|
|||||||
import { usePage } from '@inertiajs/vue3'
|
import { usePage } from '@inertiajs/vue3'
|
||||||
import Computer from '#models/computer'
|
import Computer from '#models/computer'
|
||||||
import ComputerCard from '~/pages/widgets/ComputerCard.vue'
|
import ComputerCard from '~/pages/widgets/ComputerCard.vue'
|
||||||
import { computed, reactive, ref } from 'vue'
|
import FilterDropdown from '~/pages/widgets/FilterDropdown.vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const { computers } = usePage<{ computers: Computer[] }>().props
|
const { computers } = usePage<{ computers: Computer[] }>().props
|
||||||
const stateDropdownOpen = ref(false)
|
|
||||||
|
|
||||||
function toggleDropdown() {
|
const filters = ref<Record<string, boolean>>({
|
||||||
stateDropdownOpen.value = !stateDropdownOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = reactive({
|
|
||||||
onGoing: true,
|
onGoing: true,
|
||||||
finished: true,
|
finished: true,
|
||||||
sold: false,
|
sold: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const stateToFilterKey: Record<string, string> = {
|
||||||
|
ON_GOING: 'onGoing',
|
||||||
|
FINISHED: 'finished',
|
||||||
|
SOLD: 'sold',
|
||||||
|
}
|
||||||
|
|
||||||
const filteredComputers = computed(() => {
|
const filteredComputers = computed(() => {
|
||||||
return computers.filter((computer) => {
|
return computers.filter((computer) => {
|
||||||
const isOnGoing = filters.onGoing && computer.state.name == 'ON_GOING'
|
const key = stateToFilterKey[computer.state.name]
|
||||||
const isFinished = filters.finished && computer.state.name == 'FINISHED'
|
return key ? filters.value[key] : false
|
||||||
const isSold = filters.sold && computer.state.name == 'SOLD'
|
|
||||||
return isOnGoing || isFinished || isSold
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Dropdown pour les filtres -->
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<h4 class="text-xl underline">Filters</h4>
|
<h4 class="text-xl underline">Filters</h4>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<!-- Usage Dropdown -->
|
<FilterDropdown
|
||||||
<div id="usage-selector" class="relative" style="width: fit-content">
|
v-model="filters"
|
||||||
<button
|
label="Usage"
|
||||||
@click="toggleDropdown()"
|
:options="[
|
||||||
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
|
{ id: 'onGoing', label: 'On Going' },
|
||||||
type="button"
|
{ id: 'finished', label: 'Finished' },
|
||||||
>
|
{ id: 'sold', label: 'Sold' },
|
||||||
Usage
|
]"
|
||||||
<svg
|
/>
|
||||||
class="w-4 h-4 ml-2"
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Dropdown menu -->
|
|
||||||
<div
|
|
||||||
v-show="stateDropdownOpen"
|
|
||||||
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
|
|
||||||
>
|
|
||||||
<ul class="space-y-2 text-sm px-4 py-2">
|
|
||||||
<li class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="unused"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="filters.onGoing"
|
|
||||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="unused"
|
|
||||||
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
On Going
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="used"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="filters.finished"
|
|
||||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
|
||||||
/>
|
|
||||||
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Finished
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="used"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="filters.sold"
|
|
||||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
|
||||||
/>
|
|
||||||
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Sold
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 mb-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div class="mt-4 mb-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
<ComputerCard
|
<ComputerCard
|
||||||
@@ -119,5 +58,3 @@ const filteredComputers = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
id: string | number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
options: FilterOption[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<Record<string | number, boolean>>({ required: true })
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative" style="width: fit-content">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
aria-haspopup="true"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 ml-2"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="isOpen"
|
||||||
|
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
|
||||||
|
>
|
||||||
|
<ul class="space-y-2 text-sm px-4 py-2">
|
||||||
|
<li v-for="option in options" :key="option.id" class="flex items-center">
|
||||||
|
<input
|
||||||
|
:id="`filter-${label}-${option.id}`"
|
||||||
|
v-model="modelValue[option.id]"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
:for="`filter-${label}-${option.id}`"
|
||||||
|
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in new issue
Block a user