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:
Kevin L'Hereec committed 2026-07-23 11:10:17 +02:00
1 parent 3cc189c534
commit 3579c9dfec
10 files changed
+163 -262

No files matched your search

+1
View File
@@ -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']>
} }
} }
-15
View File
@@ -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.
+4 -5
View File
@@ -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>
+38 -18
View File
@@ -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>
+27 -140
View File
@@ -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>
+21 -84
View File
@@ -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>
+72
View File
@@ -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>