fix(17): WR-07 open a singleton's record from its list route
A settings link to a singleton controller only worked on a plain left click; a new tab landed on the list route, which answers 404, and every non-singleton link paid an extra /singleton request. The settings index now links like any controller link, and the list view opens the controller's one record when its list answers 404 and the singleton route answers an id.
This commit is contained in:
@@ -31,7 +31,9 @@ import { showToast } from '../state/useToasts'
|
||||
// A restored query the admin did not ask for never breaks the list: if its
|
||||
// load fails for any reason, the stored query is forgotten and the plain list
|
||||
// loads once instead (WR-02). A failed URL query keeps the error, with a reset
|
||||
// back to the plain list next to it.
|
||||
// back to the plain list next to it. A singleton controller (D-11) has no
|
||||
// list: its list route answers 404, and the view opens the controller's one
|
||||
// record instead (a settings link opened in a new tab, a bookmark).
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const route = useRoute()
|
||||
@@ -176,6 +178,10 @@ async function loadList(restored = false): Promise<void> {
|
||||
void router.replace({ path: route.path, query: {} })
|
||||
return
|
||||
}
|
||||
if (!result?.data && result?.response?.status === 404 && (await openSingleton(current))) {
|
||||
// Keeps loading while the record opens; no error flashes.
|
||||
return
|
||||
}
|
||||
rows.value = result?.data?.data ?? []
|
||||
meta.value = result?.data?.meta ?? null
|
||||
listFailed.value = !result?.data
|
||||
@@ -192,6 +198,27 @@ async function loadList(restored = false): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the one record of a singleton controller (D-11) in place of its list,
|
||||
* which answers 404. Reports whether it took over: false when the controller
|
||||
* is not a singleton (its 404 stands).
|
||||
*/
|
||||
async function openSingleton(current: number): Promise<boolean> {
|
||||
try {
|
||||
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/singleton', { params: { path } })
|
||||
if (current !== generation) {
|
||||
return true
|
||||
}
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
await router.replace(`${basePath}/${data.data.id}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function fetchList() {
|
||||
return api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import type { SettingsEntry } from '../api/types'
|
||||
import { parseControllerId } from '../app/controllerRoutes'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { t } from '../app/i18n'
|
||||
import { iconFor } from '../app/icons'
|
||||
import { settingsGroups, settingsPath } from '../state/useSettings'
|
||||
@@ -11,37 +7,10 @@ import { settingsGroups, settingsPath } from '../state/useSettings'
|
||||
// Settings index (D-21): every permitted settings page, grouped by category,
|
||||
// with its icon, label and description. The server filters the list. A
|
||||
// singleton entry opens its settings form; a link entry opens its controller.
|
||||
// A link to a singleton controller (D-11) opens that controller's one record:
|
||||
// GET …/singleton answers its id (a controller that is not a singleton
|
||||
// answers 404 and the link opens its list).
|
||||
const router = useRouter()
|
||||
const opening = ref<string | null>(null)
|
||||
|
||||
async function open(event: MouseEvent, entry: SettingsEntry, navigate: (event: MouseEvent) => unknown): Promise<void> {
|
||||
const params = entry.controller ? parseControllerId(entry.controller) : null
|
||||
if (!params) {
|
||||
void navigate(event)
|
||||
return
|
||||
}
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
// A new tab or window opens the link as it is.
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (opening.value !== null) {
|
||||
return
|
||||
}
|
||||
opening.value = entry.code
|
||||
const target = settingsPath(entry)
|
||||
try {
|
||||
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/singleton', { params: { path: params } })
|
||||
await router.push(data ? `${target}/${data.data.id}` : target)
|
||||
} catch {
|
||||
await router.push(target)
|
||||
} finally {
|
||||
opening.value = null
|
||||
}
|
||||
}
|
||||
// A link to a singleton controller (D-11) points at the controller's list
|
||||
// route like any other, so a click, a new tab and a bookmark behave the
|
||||
// same; the list view opens the controller's one record when the list
|
||||
// answers 404 (ListView openSingleton).
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -70,9 +39,8 @@ async function open(event: MouseEvent, entry: SettingsEntry, navigate: (event: M
|
||||
<a
|
||||
:href="href"
|
||||
:data-settings="entry.code"
|
||||
:aria-busy="opening === entry.code ? 'true' : undefined"
|
||||
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
|
||||
@click="open($event, entry, navigate)"
|
||||
@click="navigate"
|
||||
>
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-inner bg-subtle text-muted">
|
||||
<component :is="iconFor(entry.icon)" :size="20" aria-hidden="true" />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { clone, companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, notFound, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 06 specs for the singleton form (D-11, UI-SPEC "Singleton
|
||||
// form"): a settings link to a pact.AdminSingleton controller opens its one
|
||||
// record through GET …/singleton; the form has no Delete, and back, Cancel
|
||||
// and Save and close return to the settings index.
|
||||
// record through GET …/singleton once the list answers 404 (WR-07: the same
|
||||
// for a click, a new tab and a bookmark); the form has no Delete, and back,
|
||||
// Cancel and Save and close return to the settings index.
|
||||
const PROFILES = `${API}/acme/companions/profiles`
|
||||
|
||||
function singletonSchema() {
|
||||
@@ -94,16 +95,40 @@ describe('SingletonForm', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
|
||||
it('a link to a controller that is not a singleton opens its list', async () => {
|
||||
it('a link to a controller that is not a singleton opens its list without asking for a record', async () => {
|
||||
const { wrapper, router, calls } = await mountApp(
|
||||
'/settings',
|
||||
routes({ [`GET ${PROFILES}/singleton`]: { status: 404, body: notFound } }),
|
||||
routes({
|
||||
[`GET ${PROFILES}/singleton`]: { status: 404, body: notFound },
|
||||
[`GET ${PROFILES}/schema/list`]: { body: listSchemaFixture },
|
||||
[`GET ${PROFILES}`]: { body: listFixture },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1)
|
||||
expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(0)
|
||||
expect(router.currentRoute.value.path).toBe('/acme/companions/profiles')
|
||||
expect(wrapper.find('[data-list-failed]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('the list route of a singleton, opened directly (a new tab), opens its record', async () => {
|
||||
const { wrapper, router, calls } = await mountApp('/acme/companions/profiles', routes(), { attach: true })
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9'))
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1)
|
||||
expect(wrapper.find<HTMLInputElement>('#field-title').element.value).toBe('Main site')
|
||||
})
|
||||
|
||||
it('a list that answers 404 for a controller that is not a singleton shows the failure', async () => {
|
||||
const { wrapper, router } = await mountApp(
|
||||
'/acme/companions/profiles',
|
||||
routes({ [`GET ${PROFILES}/singleton`]: { status: 404, body: notFound } }),
|
||||
{ attach: true },
|
||||
)
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/companions/profiles')
|
||||
expect(wrapper.find('[data-list-failed]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('a modified click opens the link as it is, without asking for the record', async () => {
|
||||
@@ -117,7 +142,11 @@ describe('SingletonForm', () => {
|
||||
it('a form without the singleton flag keeps Delete and returns to its list', async () => {
|
||||
const schema = singletonSchema()
|
||||
schema.data.singleton = false
|
||||
const { wrapper, router } = await mountApp('/acme/companions/profiles/9', routes({ [`GET ${PROFILES}/schema/form`]: { body: schema } }), { attach: true })
|
||||
const { wrapper, router } = await mountApp(
|
||||
'/acme/companions/profiles/9',
|
||||
routes({ [`GET ${PROFILES}/schema/form`]: { body: schema }, [`GET ${PROFILES}/singleton`]: { status: 404, body: notFound } }),
|
||||
{ attach: true },
|
||||
)
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||
await wrapper.find('[data-action="back"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -455,7 +455,7 @@ A controller that edits exactly one record, such as a site-wide definition, impl
|
||||
- The controller needs a `config_form.yaml` and no `config_list.yaml`. Its list, create, bulk and delete routes answer 404 `not_found`, and the singleton route answers 404 on any other controller.
|
||||
- A settings entry whose `Controller` names the singleton controller links to it; see [Settings](settings.md).
|
||||
|
||||
In the admin, clicking such a settings entry asks the singleton route for the id and opens the record's form directly. The form has no Delete button, and its back button, Cancel and Save and close return to the settings index. A settings link to a controller that is not a singleton gets a 404 from that route and opens the controller's list as before.
|
||||
In the admin, a settings entry links to the controller's list route like any other controller link, so a click, a new tab and a bookmark behave the same. The list view gets the 404 from the list route, asks the singleton route for the id and replaces the route with the record's form. The form has no Delete button, and its back button, Cancel and Save and close return to the settings index. A link to a controller that is not a singleton opens its list without asking the singleton route.
|
||||
|
||||
## Record actions
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ A link entry is not a singleton and needs no embedded admin tree. [cabana](../..
|
||||
|
||||
`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code.
|
||||
|
||||
A link entry may also open a [singleton controller](admin-controllers.md#singleton-controllers), one whose `pact.AdminSingleton` record the admin opens directly in its form, the way WinterCMS plugins auto-created one definition and redirected to it. The entry stays a link: the admin asks the controller's singleton route for the record and opens it.
|
||||
A link entry may also open a [singleton controller](admin-controllers.md#singleton-controllers), one whose `pact.AdminSingleton` record the admin opens directly in its form, the way WinterCMS plugins auto-created one definition and redirected to it. The entry stays a link to the controller's list route: since a singleton controller has no list, the admin asks its singleton route for the record and opens it, also when the link is opened in a new tab.
|
||||
|
||||
In the admin, the entry's card on the Settings page opens the controller's pages. When the controller has no main navigation item of its own, the admin treats it as part of Settings: the rail marks Settings as current and the breadcrumbs read Settings, then the entry's label.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user