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:
116
app/composables/useUrlState.ts
Normal file
116
app/composables/useUrlState.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user