feat(navigation) : preserve list state in URL and use browser history for back buttons

Add useUrlState composable to sync page, search, sort and filter state
with URL query params. Back/forward navigation now restores the exact
list position. Replace hardcoded NuxtLink back buttons with
router.back() across all create/edit pages. Fix documents attachment
filter that checked non-existent ID fields instead of relation objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-11 16:48:40 +01:00
parent 185af65519
commit 480aaa24b2
18 changed files with 228 additions and 77 deletions

View File

@@ -92,11 +92,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
import { useHead, useRouter } from "#imports"; import { useHead, useRouter } from "#imports";
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue"; import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
import ModelTypesTable from "~/components/model-types/Table.vue"; import ModelTypesTable from "~/components/model-types/Table.vue";
import { useApi } from "~/composables/useApi"; import { useApi } from "~/composables/useApi";
import { useUrlState } from "~/composables/useUrlState";
import { extractCollection } from "~/shared/utils/apiHelpers"; import { extractCollection } from "~/shared/utils/apiHelpers";
import { import {
deleteModelType, deleteModelType,
@@ -125,11 +126,28 @@ const props = withDefaults(
const selectedCategory = ref<ModelCategory>(props.category); const selectedCategory = ref<ModelCategory>(props.category);
const searchInput = ref(""); const searchInput = ref("");
const searchTerm = ref("");
const sort = ref<"name" | "createdAt">("name"); // State synced with URL query params (preserved on back/forward navigation)
const dir = ref<"asc" | "desc">("asc"); const urlState = useUrlState({
const limit = ref(20); q: { default: '' },
const offset = ref(0); sort: { default: 'name' },
dir: { default: 'asc' },
limit: { default: 20, type: 'number' },
offset: { default: 0, type: 'number' },
}, {
onRestore: () => {
searchInput.value = urlState.q.value;
refresh();
},
});
const searchTerm = urlState.q;
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
const dir = urlState.dir as Ref<'asc' | 'desc'>;
const limit = urlState.limit;
const offset = urlState.offset;
// Initialize searchInput from URL (for direct navigation with ?q=...)
searchInput.value = searchTerm.value;
const items = ref<ModelType[]>([]); const items = ref<ModelType[]>([]);
const total = ref(0); const total = ref(0);

View File

@@ -0,0 +1,116 @@
import { ref, watch, nextTick, type Ref } from 'vue'
import { useRoute, useRouter } from '#imports'
interface ParamDef<T extends string | number = string | number> {
default: T
type?: 'string' | 'number'
/** Debounce URL writes (ms). Default: 0 (immediate). */
debounce?: number
}
type ParamDefs = Record<string, ParamDef>
type InferRef<D extends ParamDef> = D['default'] extends number ? Ref<number> : Ref<string>
type StateRefs<T extends ParamDefs> = {
[K in keyof T]: InferRef<T[K]>
}
interface UseUrlStateOptions {
/** Called when state is restored from URL (back/forward navigation). */
onRestore?: () => void
}
export function useUrlState<T extends ParamDefs>(
params: T,
options?: UseUrlStateOptions,
): StateRefs<T> {
const route = useRoute()
const router = useRouter()
const keys = Object.keys(params) as (keyof T & string)[]
const refs: Record<string, Ref<string | number>> = {}
const timers: Record<string, ReturnType<typeof setTimeout> | null> = {}
for (const key of keys) {
refs[key] = ref(parseValue(route.query[key], params[key]!))
timers[key] = null
}
let isProgrammatic = false
const buildQuery = (): Record<string, string> => {
const q: Record<string, string> = {}
for (const key of keys) {
const val = refs[key]!.value
if (val !== params[key]!.default) {
q[key] = String(val)
}
}
return q
}
const pushToUrl = () => {
if (isProgrammatic) return
isProgrammatic = true
const query = buildQuery()
router
.replace({ path: route.path, query })
.catch(() => {})
.finally(() => {
nextTick(() => {
isProgrammatic = false
})
})
}
for (const key of keys) {
const ms = params[key]!.debounce ?? 0
watch(refs[key]!, () => {
if (isProgrammatic) return
if (ms > 0) {
if (timers[key]) clearTimeout(timers[key]!)
timers[key] = setTimeout(pushToUrl, ms)
} else {
pushToUrl()
}
})
}
watch(
() => ({ ...route.query }),
(newQuery) => {
if (isProgrammatic) return
isProgrammatic = true
let changed = false
for (const key of keys) {
const parsed = parseValue(newQuery[key], params[key]!)
if (refs[key]!.value !== parsed) {
refs[key]!.value = parsed
changed = true
}
}
nextTick(() => {
isProgrammatic = false
if (changed && options?.onRestore) {
options.onRestore()
}
})
},
)
return refs as StateRefs<T>
}
function parseValue(
raw: unknown,
def: ParamDef,
): string | number {
const str = typeof raw === 'string' ? raw : null
if (str === null) return def.default
if (def.type === 'number' || typeof def.default === 'number') {
const n = Number(str)
return Number.isFinite(n) ? n : def.default
}
return str
}

View File

@@ -176,11 +176,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted } from 'vue'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort' import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue' import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
@@ -190,15 +190,28 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value) const loadingComposants = computed(() => loadingComposantsRef.value)
// Pagination state // State synced with URL query params (preserved on back/forward navigation)
const currentPage = ref(1) const {
const itemsPerPage = ref(30) page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchComposants(),
})
const composantsTotal = computed(() => total.value) const composantsTotal = computed(() => total.value)
const composantsOnPage = computed(() => composants.value.length) const composantsOnPage = computed(() => composants.value.length)
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1) const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
// Search state with debounce // Search debounce for API calls
const searchTerm = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => { const debouncedSearch = () => {
@@ -211,12 +224,6 @@ const debouncedSearch = () => {
}, 300) }, 300)
} }
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'component-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les composants avec les types de composants complets // Enrichir les composants avec les types de composants complets
const composantsList = computed(() => { const composantsList = computed(() => {
return (composants.value || []).map((composant) => { return (composants.value || []).map((composant) => {
@@ -234,7 +241,7 @@ const fetchComposants = async () => {
page: currentPage.value, page: currentPage.value,
itemsPerPage: itemsPerPage.value, itemsPerPage: itemsPerPage.value,
orderBy: sortField.value, orderBy: sortField.value,
orderDir: sortDirection.value, orderDir: sortDirection.value as 'asc' | 'desc',
force: true force: true
}) })
} }

View File

@@ -8,9 +8,9 @@
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants. Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/component-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>

View File

@@ -8,9 +8,9 @@
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie. Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/component-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>

View File

@@ -19,9 +19,9 @@
</p> </p>
</div> </div>
</div> </div>
<NuxtLink to="/component-catalog" class="btn btn-primary mt-6"> <button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto"> <section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
@@ -33,9 +33,9 @@
Mettez à jour les informations du composant et ses champs personnalisés. Mettez à jour les informations du composant et ses champs personnalisés.
</p> </p>
</div> </div>
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start"> <button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">

View File

@@ -7,9 +7,9 @@
Sélectionnez la catégorie cible puis complétez les informations du composant. Sélectionnez la catégorie cible puis complétez les informations du composant.
</p> </p>
</div> </div>
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start"> <button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</header> </header>
<section class="card border border-base-200 bg-base-100 shadow-sm"> <section class="card border border-base-200 bg-base-100 shadow-sm">

View File

@@ -132,6 +132,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useUrlState } from '~/composables/useUrlState'
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { formatFrenchDate } from '~/utils/date' import { formatFrenchDate } from '~/utils/date'
@@ -140,8 +141,10 @@ import IconLucideFileSearch from '~icons/lucide/file-search'
const { documents, loading, loadDocuments } = useDocuments() const { documents, loading, loadDocuments } = useDocuments()
const searchTerm = ref('') const { q: searchTerm, filter: attachmentFilter } = useUrlState({
const attachmentFilter = ref('all') q: { default: '', debounce: 300 },
filter: { default: 'all' },
})
const previewDocument = ref(null) const previewDocument = ref(null)
const previewVisible = ref(false) const previewVisible = ref(false)
@@ -156,10 +159,10 @@ const filteredDocuments = computed(() => {
return documents.value.filter((document) => { return documents.value.filter((document) => {
const matchesFilter = const matchesFilter =
filter === 'all' || filter === 'all' ||
(filter === 'site' && document.siteId) || (filter === 'site' && document.site) ||
(filter === 'machine' && document.machineId) || (filter === 'machine' && document.machine) ||
(filter === 'composant' && document.composantId) || (filter === 'composant' && document.composant) ||
(filter === 'piece' && document.pieceId) (filter === 'piece' && document.piece)
if (!matchesFilter) { return false } if (!matchesFilter) { return false }

View File

@@ -8,9 +8,9 @@
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations. Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/piece-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>

View File

@@ -8,9 +8,9 @@
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie. Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/piece-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>

View File

@@ -198,11 +198,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted } from 'vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort' import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue' import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
@@ -212,15 +212,28 @@ const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = us
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value) const loadingPieces = computed(() => loadingPiecesRef.value)
// Pagination state // State synced with URL query params (preserved on back/forward navigation)
const currentPage = ref(1) const {
const itemsPerPage = ref(30) page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchPieces(),
})
const piecesTotal = computed(() => total.value) const piecesTotal = computed(() => total.value)
const piecesOnPage = computed(() => pieces.value.length) const piecesOnPage = computed(() => pieces.value.length)
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1) const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
// Search state with debounce // Search debounce for API calls
const searchTerm = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => { const debouncedSearch = () => {
@@ -233,12 +246,6 @@ const debouncedSearch = () => {
}, 300) }, 300)
} }
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'pieces-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les pièces avec les types de pièces complets // Enrichir les pièces avec les types de pièces complets
const piecesList = computed(() => { const piecesList = computed(() => {
return (pieces.value || []).map((piece) => { return (pieces.value || []).map((piece) => {
@@ -256,7 +263,7 @@ const fetchPieces = async () => {
page: currentPage.value, page: currentPage.value,
itemsPerPage: itemsPerPage.value, itemsPerPage: itemsPerPage.value,
orderBy: sortField.value, orderBy: sortField.value,
orderDir: sortDirection.value, orderDir: sortDirection.value as 'asc' | 'desc',
force: true force: true
}) })
} }

View File

@@ -19,9 +19,9 @@
</p> </p>
</div> </div>
</div> </div>
<NuxtLink to="/pieces-catalog" class="btn btn-primary mt-6"> <button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto"> <section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
@@ -33,9 +33,9 @@
Ajustez les informations de la pièce et ses champs personnalisés. Ajustez les informations de la pièce et ses champs personnalisés.
</p> </p>
</div> </div>
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start"> <button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">

View File

@@ -7,9 +7,9 @@
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce. Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
</p> </p>
</div> </div>
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start"> <button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</header> </header>
<section class="card border border-base-200 bg-base-100 shadow-sm"> <section class="card border border-base-200 bg-base-100 shadow-sm">

View File

@@ -170,12 +170,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted } from 'vue'
import { useHead } from '#imports' import { useHead } from '#imports'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes' import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort' import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
@@ -195,11 +195,11 @@ const {
const { productTypes, loadProductTypes } = useProductTypes() const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast() const toast = useToast()
const searchTerm = ref('') const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>( q: { default: '', debounce: 300 },
'product-catalog', sort: { default: 'name' },
{ field: 'name', direction: 'asc' }, dir: { default: 'asc' },
) })
// Enrichir les produits avec les types de produits complets // Enrichir les produits avec les types de produits complets
const normalizedProducts = computed(() => { const normalizedProducts = computed(() => {

View File

@@ -8,9 +8,9 @@
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations. Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/product-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>

View File

@@ -8,9 +8,9 @@
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie. Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/product-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>

View File

@@ -19,9 +19,9 @@
</p> </p>
</div> </div>
</div> </div>
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6"> <button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto"> <section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
@@ -33,9 +33,9 @@
Mettez à jour les informations du produit et ses champs personnalisés. Mettez à jour les informations du produit et ses champs personnalisés.
</p> </p>
</div> </div>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start"> <button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">

View File

@@ -7,9 +7,9 @@
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue. Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
</p> </p>
</div> </div>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start"> <button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</header> </header>
<section class="card border border-base-200 bg-base-100 shadow-sm"> <section class="card border border-base-200 bg-base-100 shadow-sm">