feat(16.2-01): host plugin field type elements in the admin SPA
- loadFieldTypes fetches GET /field-types after sign-in, loads each script once and adds the styles as global links activateStyles never disables - PluginField creates the type's element, sets the value property and the contract attributes, takes summer-change as the form value and never writes back a value the element just emitted - rendererForField and isRegisteredField route any field with an element to PluginField in forms, previews and the save payload - rebuilt modules/boardwalk/dist
This commit is contained in:
@@ -54,6 +54,8 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
||||
export type AdminActionResult = Schemas['cabana.AdminActionResult']
|
||||
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
|
||||
export type ControllerAssets = Schemas['cabana.ControllerAssets']
|
||||
/** A plugin field type: alias, element tag, translatable flag and its asset URLs. */
|
||||
export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
|
||||
/** A registered toolbar action the admin may run, with its localized label. */
|
||||
export type ToolbarAction = Schemas['cabana.ToolbarAction']
|
||||
/** One node of a rendered partial: an allowlisted element or a text node. */
|
||||
|
||||
52
admin/src/app/fieldTypes.ts
Normal file
52
admin/src/app/fieldTypes.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Plugin field types (pact.HasFormFieldTypes, D-13). After sign-in the SPA
|
||||
// asks GET {api}/field-types once and loads every type's scripts as module
|
||||
// scripts (each URL once, through loadScript) and its stylesheets as global
|
||||
// links that no navigation disables, so a type's element works in any
|
||||
// controller's form. Nothing here ever rejects: a script that fails to load
|
||||
// surfaces later as that field's failure state.
|
||||
import { api } from '../api/client'
|
||||
import type { FieldTypeAssets } from '../api/types'
|
||||
import { loadGlobalStyles, loadScript } from './pluginAssets'
|
||||
|
||||
// Element tag -> the script URLs of its type that failed to load.
|
||||
const scriptsByElement = new Map<string, Promise<string[]>>()
|
||||
|
||||
/**
|
||||
* Fetches the registered field types, starts loading their scripts and
|
||||
* enables their styles. Resolves with the list (empty on any failure)
|
||||
* without waiting for the scripts.
|
||||
*/
|
||||
export async function loadFieldTypes(): Promise<FieldTypeAssets[]> {
|
||||
let types: FieldTypeAssets[] = []
|
||||
try {
|
||||
const { data, response } = await api.GET('/field-types')
|
||||
types = response.ok && data ? data.data : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
for (const type of types) {
|
||||
loadGlobalStyles(type.styles ?? [])
|
||||
const urls = type.scripts ?? []
|
||||
scriptsByElement.set(
|
||||
type.element,
|
||||
Promise.allSettled(urls.map((url) => loadScript(url))).then((results) =>
|
||||
urls.filter((_, index) => results[index]?.status === 'rejected'),
|
||||
),
|
||||
)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the scripts of the field type that defines `element` have
|
||||
* loaded or failed, with the URLs that failed; at once with none when the
|
||||
* element belongs to no loaded field type.
|
||||
*/
|
||||
export function fieldTypeScripts(element: string): Promise<string[]> {
|
||||
return scriptsByElement.get(element) ?? Promise.resolve([])
|
||||
}
|
||||
|
||||
/** Forgets the loaded field types (tests and a new session). */
|
||||
export function resetFieldTypes(): void {
|
||||
scriptsByElement.clear()
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
// every navigation to another controller or to a screen that is not a
|
||||
// controller view, so plugin CSS never styles a view it was not written for.
|
||||
// Only the router picks the enabled controller; a schema response that
|
||||
// arrives after its view was left adds its links disabled.
|
||||
// arrives after its view was left adds its links disabled. Stylesheets of
|
||||
// plugin field types are global instead (GLOBAL_STYLE_OWNER): a field type's
|
||||
// element can appear in any controller's form, so its links are never
|
||||
// disabled on navigation.
|
||||
// Files load only through script and link elements; nothing here fetches.
|
||||
import type { ControllerAssets } from '../api/types'
|
||||
import { runtime } from './runtime'
|
||||
@@ -15,6 +18,9 @@ import { runtime } from './runtime'
|
||||
/** Attribute naming the controller that owns a plugin stylesheet link. */
|
||||
export const OWNER_ATTRIBUTE = 'data-summer-controller'
|
||||
|
||||
/** Owner of stylesheet links that stay enabled on every screen (field types). */
|
||||
export const GLOBAL_STYLE_OWNER = '*'
|
||||
|
||||
const scripts = new Map<string, Promise<void>>()
|
||||
// Links by controller and URL: two controllers of one plugin may share a
|
||||
// file, and each keeps its own link so disabling one never hides the other.
|
||||
@@ -90,7 +96,7 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
|
||||
link.disabled = controllerId !== active
|
||||
link.disabled = !styleEnabled(controllerId)
|
||||
link.href = url
|
||||
styles.set(key, { owner: controllerId, link })
|
||||
document.head.appendChild(link)
|
||||
@@ -105,10 +111,23 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
|
||||
export function activateStyles(controllerId: string): void {
|
||||
active = controllerId
|
||||
for (const { owner, link } of styles.values()) {
|
||||
link.disabled = owner !== active
|
||||
link.disabled = !styleEnabled(owner)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether links of this owner are enabled now: the active controller's and the global ones. */
|
||||
function styleEnabled(owner: string): boolean {
|
||||
return owner === GLOBAL_STYLE_OWNER || owner === active
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds stylesheet links that stay enabled on every screen: the styles of
|
||||
* plugin field types, whose elements may render in any controller's form.
|
||||
*/
|
||||
export function loadGlobalStyles(urls: readonly string[]): void {
|
||||
loadStyles(GLOBAL_STYLE_OWNER, urls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the controller's stylesheet links and loads its scripts. The links
|
||||
* are enabled only while the router shows that controller, so a response
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { rendererFor, type FieldControlProps } from './registry'
|
||||
import { rendererForField, type FieldControlProps } from './registry'
|
||||
|
||||
// Picks the control for field.type from the registry (D-05) and forwards the
|
||||
// Picks the control for the field from the registry (D-05; a plugin field
|
||||
// type's element host when the field names an element) and forwards the
|
||||
// shared control props and the value update.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
|
||||
|
||||
const control = computed(() => rendererFor(props.field.type))
|
||||
const control = computed(() => rendererForField(props.field))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
||||
import FieldRenderer from './FieldRenderer.vue'
|
||||
import { groupLabelled, ownsLabel } from './registry'
|
||||
import { groupLabelled, isPluginField, ownsLabel } from './registry'
|
||||
|
||||
// One form row: label (600, red aria-hidden asterisk when required), the
|
||||
// control, the comment and the error line linked by aria-describedby.
|
||||
@@ -25,9 +25,10 @@ const commentId = computed(() => `${base.value}-comment`)
|
||||
const errorId = computed(() => `${base.value}-error`)
|
||||
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
|
||||
const selfLabelled = computed(() => ownsLabel(props.field.type))
|
||||
// A widget or partial row labels a role="group" host, so its label is a span
|
||||
// the host names with aria-labelledby, not a label for an input.
|
||||
const labelsGroup = computed(() => groupLabelled(props.field.type))
|
||||
// A widget, partial or plugin field type row labels a role="group" host, so
|
||||
// its label is a span the host names with aria-labelledby, not a label for
|
||||
// an input.
|
||||
const labelsGroup = computed(() => groupLabelled(props.field.type) || isPluginField(props.field))
|
||||
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
|
||||
|
||||
const describedBy = computed(() =>
|
||||
|
||||
@@ -3,13 +3,14 @@ import { computed } from 'vue'
|
||||
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import { toggleOn } from './control'
|
||||
import { rendererFor } from './registry'
|
||||
import { isPluginField, rendererForField } from './registry'
|
||||
|
||||
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
|
||||
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
|
||||
// contrast and are read in order. Controls that already have a read-only
|
||||
// mode (datepicker, fileupload, permissioneditor) and partials are rendered
|
||||
// through their own component with the field marked read-only.
|
||||
// mode (datepicker, fileupload, permissioneditor), partials and plugin field
|
||||
// types (their element gets `disabled`) are rendered through their own
|
||||
// component with the field marked read-only.
|
||||
const props = defineProps<{
|
||||
field: FormField
|
||||
value: unknown
|
||||
@@ -27,7 +28,7 @@ const labelId = computed(() => `${controlId.value}-label`)
|
||||
|
||||
const kind = computed(() => {
|
||||
const type = props.field.type
|
||||
if (ownControl.has(type)) {
|
||||
if (ownControl.has(type) || isPluginField(props.field)) {
|
||||
return 'control'
|
||||
}
|
||||
if (type === 'checkbox' || type === 'switch') {
|
||||
@@ -41,7 +42,7 @@ const kind = computed(() => {
|
||||
|
||||
/** The field as its own control sees it on preview: always read-only. */
|
||||
const readOnlyField = computed<FormField>(() => ({ ...props.field, readOnly: true }))
|
||||
const control = computed(() => rendererFor(props.field.type))
|
||||
const control = computed(() => rendererForField(props.field))
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
return value === null || value === undefined || value === ''
|
||||
|
||||
175
admin/src/components/form/fields/PluginField.vue
Normal file
175
admin/src/components/form/fields/PluginField.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { fieldTypeScripts } from '../../../app/fieldTypes'
|
||||
import { currentLocale, t } from '../../../app/i18n'
|
||||
import ExtensionFailure from '../../ui/ExtensionFailure.vue'
|
||||
import type { FieldControlProps } from '../control'
|
||||
import { FIELD_CHANGE_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../formContext'
|
||||
|
||||
// A field whose type a plugin registers (pact.HasFormFieldTypes, D-13). The
|
||||
// type's custom element is created imperatively inside a node Vue never
|
||||
// renders children into. The SPA hands it the field value through the
|
||||
// `value` property and plain attributes (field-name, record-id, locale,
|
||||
// ui-locale, options, disabled, invalid); never a token, a cookie, a
|
||||
// function or a Vue instance. The element reports an edit with a bubbling
|
||||
// summer-change event whose detail.value becomes the form value. The SPA
|
||||
// sets `value` only when the form value differs from the element's current
|
||||
// value, so the value the element just emitted never resets it.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
|
||||
|
||||
const formLocale = inject(FORM_LOCALE, null)
|
||||
|
||||
/** The plugin element: an HTMLElement with a `value` property. */
|
||||
type ValueElement = HTMLElement & { value?: unknown }
|
||||
|
||||
const status = ref<'loading' | 'ready' | 'failed'>('loading')
|
||||
const mountPoint = ref<HTMLElement | null>(null)
|
||||
let element: ValueElement | null = null
|
||||
let unmounted = false
|
||||
|
||||
const uiLocale = computed(() => formLocale?.value || currentLocale.value)
|
||||
const optionsJson = computed(() => JSON.stringify(props.field.typeOptions ?? {}))
|
||||
const disabled = computed(() => props.field.readOnly === true)
|
||||
|
||||
/** Equal values: the same primitive, or objects with the same JSON. */
|
||||
function sameValue(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) {
|
||||
return true
|
||||
}
|
||||
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function syncValue(target: ValueElement, value: unknown): void {
|
||||
if (!sameValue(target.value, value)) {
|
||||
target.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(target: HTMLElement, name: string, on: boolean): void {
|
||||
if (on) {
|
||||
target.setAttribute(name, '')
|
||||
} else {
|
||||
target.removeAttribute(name)
|
||||
}
|
||||
}
|
||||
|
||||
function applyAttributes(target: HTMLElement): void {
|
||||
target.setAttribute('field-name', props.field.name)
|
||||
target.setAttribute('record-id', props.recordId == null ? '' : String(props.recordId))
|
||||
target.setAttribute('locale', '')
|
||||
target.setAttribute('ui-locale', uiLocale.value)
|
||||
target.setAttribute('options', optionsJson.value)
|
||||
toggle(target, 'disabled', disabled.value)
|
||||
toggle(target, 'invalid', props.invalid === true)
|
||||
}
|
||||
|
||||
function timeout(ms: number): { promise: Promise<never>; cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const promise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('field type element was not defined in time')), ms)
|
||||
})
|
||||
return { promise, cancel: () => clearTimeout(timer) }
|
||||
}
|
||||
|
||||
async function defined(tag: string): Promise<void> {
|
||||
const failed = await fieldTypeScripts(tag)
|
||||
if (customElements.get(tag)) {
|
||||
return
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
throw new Error(`plugin script failed: ${failed.join(', ')}`)
|
||||
}
|
||||
const limit = timeout(WIDGET_TIMEOUT)
|
||||
try {
|
||||
await Promise.race([customElements.whenDefined(tag), limit.promise])
|
||||
} finally {
|
||||
limit.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(event: Event): void {
|
||||
const detail: unknown = (event as CustomEvent<unknown>).detail
|
||||
if (detail !== null && typeof detail === 'object' && Object.hasOwn(detail, 'value')) {
|
||||
emit('update:modelValue', (detail as { value: unknown }).value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const tag = props.field.element ?? ''
|
||||
try {
|
||||
if (tag === '') {
|
||||
throw new Error('plugin field without an element')
|
||||
}
|
||||
await defined(tag)
|
||||
if (unmounted || !mountPoint.value) {
|
||||
return
|
||||
}
|
||||
const created = document.createElement(tag) as ValueElement
|
||||
applyAttributes(created)
|
||||
syncValue(created, props.modelValue)
|
||||
created.addEventListener(FIELD_CHANGE_EVENT, onChange)
|
||||
mountPoint.value.append(created)
|
||||
element = created
|
||||
status.value = 'ready'
|
||||
} catch {
|
||||
if (!unmounted) {
|
||||
status.value = 'failed'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (element) {
|
||||
syncValue(element, value)
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
watch(
|
||||
() => [props.recordId, props.invalid, disabled.value, uiLocale.value, optionsJson.value],
|
||||
() => {
|
||||
if (element) {
|
||||
applyAttributes(element)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
element?.removeEventListener(FIELD_CHANGE_EVENT, onChange)
|
||||
element = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ExtensionFailure
|
||||
v-if="status === 'failed'"
|
||||
:id="controlId"
|
||||
data-plugin-field-failed
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:text="t('backend::lang.extension.widget_failed')"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:id="controlId"
|
||||
role="group"
|
||||
data-plugin-field
|
||||
:aria-labelledby="field.label ? `${controlId}-label` : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:aria-busy="status === 'loading' ? 'true' : undefined"
|
||||
class="flex min-h-input min-w-0 flex-col"
|
||||
>
|
||||
<span v-if="status === 'loading'" data-plugin-field-skeleton class="h-[42px] w-full rounded-control bg-skel" aria-hidden="true" />
|
||||
<div ref="mountPoint" class="contents" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -92,5 +92,11 @@ export const WIDGET_EVENT = 'summer-action'
|
||||
*/
|
||||
export const WIDGET_RESULT_EVENT = 'summer-result'
|
||||
|
||||
/**
|
||||
* Event a plugin field type's element dispatches (bubbling) when the
|
||||
* administrator edits its value; detail.value is the new form value.
|
||||
*/
|
||||
export const FIELD_CHANGE_EVENT = 'summer-change'
|
||||
|
||||
/** How long a widget waits for its custom element to be defined. */
|
||||
export const WIDGET_TIMEOUT = 5000
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
||||
import { permissionValues } from './control'
|
||||
import { isMLFieldType, localeRecord } from './mlLocale'
|
||||
import { isRegistered } from './registry'
|
||||
import { isRegisteredField } from './registry'
|
||||
|
||||
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLFieldType, localeRecord } from './mlLocale'
|
||||
|
||||
@@ -53,7 +53,7 @@ export function contextAllows(field: FormField, mode: FormMode): boolean {
|
||||
export function editablePayload(fields: FormField[], values: AdminRecord, mode: FormMode = 'create'): AdminRecord {
|
||||
const out: AdminRecord = {}
|
||||
for (const field of fields) {
|
||||
if (field.readOnly || !isRegistered(field.type)) {
|
||||
if (field.readOnly || !isRegisteredField(field)) {
|
||||
continue
|
||||
}
|
||||
const value = values[field.name]
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
// The ML controls (mltext, mltextarea, mlmarkdown) are thin shells over one
|
||||
// locale-switching wrapper, MLField, around the text, textarea and markdown
|
||||
// controls; each holds a locale -> text map, labelled as a group.
|
||||
// Phase 16.2 adds plugin field types (D-13): a field whose schema carries an
|
||||
// `element` renders PluginField whatever its type alias, holds a form value
|
||||
// and is labelled as a group. Look such fields up with rendererForField and
|
||||
// isRegisteredField, which know the field, not only its type.
|
||||
import type { Component } from 'vue'
|
||||
import type { FormField } from '../../api/types'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
import DatepickerField from './fields/DatepickerField.vue'
|
||||
import DropdownField from './fields/DropdownField.vue'
|
||||
@@ -25,6 +30,7 @@ import FileuploadField from './fields/FileuploadField.vue'
|
||||
import NumberField from './fields/NumberField.vue'
|
||||
import PartialField from './fields/PartialField.vue'
|
||||
import PasswordField from './fields/PasswordField.vue'
|
||||
import PluginField from './fields/PluginField.vue'
|
||||
import PermissionEditorField from './fields/PermissionEditorField.vue'
|
||||
import RelationManager from '../relation/RelationManager.vue'
|
||||
import RelationField from './fields/RelationField.vue'
|
||||
@@ -101,6 +107,24 @@ export function isRegistered(type: string): boolean {
|
||||
return (renderers.has(type) || lazyRenderers.has(type)) && !valueless.has(type)
|
||||
}
|
||||
|
||||
/** Whether a field's type is registered by a plugin: its schema names an element. */
|
||||
export function isPluginField(field: Pick<FormField, 'element'>): boolean {
|
||||
return typeof field.element === 'string' && field.element !== ''
|
||||
}
|
||||
|
||||
/** The control of one field: a plugin field type's host, else rendererFor its type. */
|
||||
export function rendererForField(field: FormField): Component {
|
||||
if (isPluginField(field)) {
|
||||
return PluginField
|
||||
}
|
||||
return rendererFor(field.type)
|
||||
}
|
||||
|
||||
/** Whether the SPA edits this field's value: a plugin field type always does. */
|
||||
export function isRegisteredField(field: FormField): boolean {
|
||||
return isPluginField(field) || isRegistered(field.type)
|
||||
}
|
||||
|
||||
/** Whether a field type renders only on an existing record (relation manager). */
|
||||
export function needsRecord(type: string): boolean {
|
||||
return recordBound.has(type)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { loadFieldTypes } from './app/fieldTypes'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { applyColorScheme } from './app/theme'
|
||||
import { expireSession, me } from './state/useAuth'
|
||||
@@ -13,13 +14,16 @@ async function boot(): Promise<void> {
|
||||
// Dark mode follows the system preference before anything renders (A6).
|
||||
applyColorScheme()
|
||||
// Boot order (research Pattern 7): the public strings bundle first, so the
|
||||
// login screen is translated, then the session, then navigation.
|
||||
// login screen is translated, then the session, then navigation and the
|
||||
// plugin field types.
|
||||
await loadStrings().catch(() => undefined)
|
||||
// A 401 here only means "not signed in"; the router guard sends the
|
||||
// visitor to the login route with the requested path as redirect.
|
||||
const user = await me().catch(() => null)
|
||||
if (user) {
|
||||
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => [])])
|
||||
// Plugin field types load once per session: their scripts define the
|
||||
// elements any controller's form may use, their styles stay enabled.
|
||||
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => []), loadFieldTypes()])
|
||||
}
|
||||
const router = createAdminRouter()
|
||||
// A session that cannot be recovered ends here: the profile and every
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { CircleAlert, Sun } from '@lucide/vue'
|
||||
import { loadFieldTypes } from '../app/fieldTypes'
|
||||
import { t } from '../app/i18n'
|
||||
import { safeRedirect } from '../app/router'
|
||||
import { login, me } from '../state/useAuth'
|
||||
@@ -29,7 +30,7 @@ async function submit(): Promise<void> {
|
||||
return
|
||||
}
|
||||
password.value = ''
|
||||
await Promise.all([loadNavigation(), loadSettings().catch(() => [])])
|
||||
await Promise.all([loadNavigation(), loadSettings().catch(() => []), loadFieldTypes()])
|
||||
await router.replace(safeRedirect(route.query.redirect) ?? '/')
|
||||
} finally {
|
||||
busy.value = false
|
||||
|
||||
Reference in New Issue
Block a user