- ReorderView: indented tree with drag handle, move up/down, outdent and indent (buttons and arrow keys on the handle), pointer drag before, after or inside a row and never into its own subtree; one POST with the whole node set 400 ms after the last move, flushed on leave; a failed save restores the saved tree, a 409 reloads; loading, empty, load-failure and 403 states; partition selector - ListView: outline Reorder button before New record, passing the filtered partition - SettingsIndexView opens a singleton controller's record through GET …/singleton; FormView in singleton mode has no Delete and returns to the settings index - reorder.* and messages.list.reorder phrase keys (en, pl), smoke spec, docs
111 lines
4.3 KiB
TypeScript
111 lines
4.3 KiB
TypeScript
import { createRouter, createWebHistory, type RouteLocationNormalized, type RouterHistory } from 'vue-router'
|
|
import { controllerIdFromParams } from './controllerRoutes'
|
|
import { activateStyles } from './pluginAssets'
|
|
import { runtime } from './runtime'
|
|
import { currentUser } from '../state/useAuth'
|
|
import { homePath } from '../state/useNavigation'
|
|
import LoginView from '../views/LoginView.vue'
|
|
import ListView from '../views/ListView.vue'
|
|
import FormView from '../views/FormView.vue'
|
|
import PreviewView from '../views/PreviewView.vue'
|
|
import ReorderView from '../views/ReorderView.vue'
|
|
import SettingsFormView from '../views/SettingsFormView.vue'
|
|
import SettingsIndexView from '../views/SettingsIndexView.vue'
|
|
import AccountView from '../views/AccountView.vue'
|
|
import NotFoundView from '../views/NotFoundView.vue'
|
|
|
|
declare module 'vue-router' {
|
|
interface RouteMeta {
|
|
/** Reachable without a session. */
|
|
public?: boolean
|
|
/** Rendered inside the navigation shell. */
|
|
shell?: boolean
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accepts a post-login redirect only when it is an in-app path: it must start
|
|
* with exactly one slash, which rejects absolute and protocol-relative URLs.
|
|
*/
|
|
export function safeRedirect(value: unknown): string | null {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
return null
|
|
}
|
|
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
|
|
return null
|
|
}
|
|
return value
|
|
}
|
|
|
|
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview', 'reorder'])
|
|
|
|
/**
|
|
* The controller a route shows, or '' for every other screen (settings,
|
|
* login, not found). Plugin stylesheets follow it (D-16, T-10.1-16).
|
|
*/
|
|
export function routeControllerId(route: RouteLocationNormalized): string {
|
|
if (typeof route.name !== 'string' || !CONTROLLER_ROUTES.has(route.name)) {
|
|
return ''
|
|
}
|
|
const { vendor, plugin, controller } = route.params
|
|
if (typeof vendor !== 'string' || typeof plugin !== 'string' || typeof controller !== 'string') {
|
|
return ''
|
|
}
|
|
return controllerIdFromParams({ vendor, plugin, controller })
|
|
}
|
|
|
|
export function createAdminRouter(history: RouterHistory = createWebHistory(runtime.base)) {
|
|
const router = createRouter({
|
|
history,
|
|
routes: [
|
|
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
|
{
|
|
path: '/',
|
|
name: 'home',
|
|
component: NotFoundView,
|
|
props: { home: true },
|
|
meta: { shell: true },
|
|
beforeEnter: () => homePath() ?? true,
|
|
},
|
|
{ path: '/settings', name: 'settings', component: SettingsIndexView, meta: { shell: true } },
|
|
{ path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } },
|
|
// My account: the signed-in admin's own profile and password.
|
|
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
|
|
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
|
|
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
|
|
// The tree reorder page of a list with config_reorder.yaml (D-09).
|
|
{ path: '/:vendor/:plugin/:controller/reorder', name: 'reorder', component: ReorderView, meta: { shell: true } },
|
|
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
|
|
// The read-only record screen of a form with a preview block (D-11).
|
|
{
|
|
path: '/:vendor/:plugin/:controller/:id(\\d+)/preview',
|
|
name: 'preview',
|
|
component: PreviewView,
|
|
meta: { shell: true },
|
|
},
|
|
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
|
|
],
|
|
})
|
|
router.beforeEach((to) => {
|
|
const signedIn = currentUser.value !== null
|
|
if (to.meta.public) {
|
|
if (signedIn && to.name === 'login') {
|
|
return safeRedirect(to.query.redirect) ?? '/'
|
|
}
|
|
return true
|
|
}
|
|
if (!signedIn) {
|
|
return { name: 'login', query: { redirect: to.fullPath } }
|
|
}
|
|
return true
|
|
})
|
|
// Plugin CSS is global: enable only the links of the controller on screen,
|
|
// and none on any other screen (D-16, T-10.1-16).
|
|
router.afterEach((to, _from, failure) => {
|
|
if (!failure) {
|
|
activateStyles(routeControllerId(to))
|
|
}
|
|
})
|
|
return router
|
|
}
|