|
|
@@ -0,0 +1,402 @@
|
|
|
+<!--suppress VueUnrecognizedSlot -->
|
|
|
+<template>
|
|
|
+ <Card class="mx-auto max-w-prose">
|
|
|
+ <template #content>
|
|
|
+ <div class="flex flex-col gap-2">
|
|
|
+ <Message
|
|
|
+ v-if="isPresent(formErrors)"
|
|
|
+ severity="error"
|
|
|
+ pt:root="border border-transparent"
|
|
|
+ >
|
|
|
+ {{ _join(formErrors, " ") }}
|
|
|
+ </Message>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-for="({ type }, field) in props.fields"
|
|
|
+ :key="field"
|
|
|
+ class="group flex flex-col"
|
|
|
+ >
|
|
|
+ <Swap
|
|
|
+ :class="props.action !== 'view' && 'cursor-pointer'"
|
|
|
+ :disabled="props.action === 'view'"
|
|
|
+ @active="focusFormControl(field, type)"
|
|
|
+ @inactive="validate(field)"
|
|
|
+ >
|
|
|
+ <template #inactive>
|
|
|
+ <div class="ml-1 block text-sm text-primary dark:text-primary">
|
|
|
+ {{ _startCase(field) }}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="type === 'richText'"
|
|
|
+ class="trix-content border border-transparent px-3 py-2"
|
|
|
+ :class="props.action !== 'view' && 'group-hover:bg-primary/15'"
|
|
|
+ :tabindex="0"
|
|
|
+ v-html="model?.[field] || ' '"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-else
|
|
|
+ class="border border-transparent px-3 py-2"
|
|
|
+ :class="props.action !== 'view' && 'group-hover:bg-primary/15'"
|
|
|
+ :tabindex="0"
|
|
|
+ >
|
|
|
+ {{ model?.[field] || " " }}
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template #active="{ deactivate }">
|
|
|
+ <div class="ml-1 block text-sm text-primary dark:text-primary">
|
|
|
+ {{ _startCase(field) }}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <TrixEditor
|
|
|
+ v-if="type === 'richText'"
|
|
|
+ v-model="model[field]"
|
|
|
+ :id="field"
|
|
|
+ :tabindex="0"
|
|
|
+ @blur="waitForAnimation(deactivate)"
|
|
|
+ :ref="(trixEditor) => (trixEditors[field] = trixEditor)"
|
|
|
+ />
|
|
|
+
|
|
|
+ <Select
|
|
|
+ v-else-if="type === 'select'"
|
|
|
+ v-model="model[field]"
|
|
|
+ :tabindex="0"
|
|
|
+ :labelId="field"
|
|
|
+ fluid
|
|
|
+ :options="options?.[field]"
|
|
|
+ @blur="waitForAnimation(deactivate)"
|
|
|
+ :ref="(select) => (selects[field] = select)"
|
|
|
+ />
|
|
|
+
|
|
|
+ <ComboBox
|
|
|
+ v-else-if="type === 'autocomplete'"
|
|
|
+ v-model="model[field]"
|
|
|
+ :tabindex="0"
|
|
|
+ :inputId="field"
|
|
|
+ :suggestions="suggestions?.[field]"
|
|
|
+ @blur="waitForAnimation(deactivate)"
|
|
|
+ />
|
|
|
+
|
|
|
+ <InputText
|
|
|
+ v-else
|
|
|
+ v-model="model[field]"
|
|
|
+ :id="field"
|
|
|
+ :tabindex="0"
|
|
|
+ fluid
|
|
|
+ @blur="waitForAnimation(deactivate)"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </Swap>
|
|
|
+
|
|
|
+ <Message
|
|
|
+ v-if="fieldErrors?.[field]"
|
|
|
+ severity="error"
|
|
|
+ size="small"
|
|
|
+ variant="simple"
|
|
|
+ class="ml-1"
|
|
|
+ >
|
|
|
+ {{ _join(fieldErrors?.[field], " ") }}
|
|
|
+ </Message>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <div class="mt-4 flex flex-row justify-end gap-3">
|
|
|
+ <template v-if="isEdited">
|
|
|
+ <Button
|
|
|
+ severity="warn"
|
|
|
+ @click="confirmReset"
|
|
|
+ >
|
|
|
+ Reset
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ <Button
|
|
|
+ :disabled="!valid"
|
|
|
+ @click="save"
|
|
|
+ >
|
|
|
+ Save
|
|
|
+ </Button>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <Button
|
|
|
+ v-if="props.action === 'update'"
|
|
|
+ severity="danger"
|
|
|
+ @click="confirmDelete"
|
|
|
+ >
|
|
|
+ Delete
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ <Button>
|
|
|
+ <!--suppress JSValidateTypes -->
|
|
|
+ <NuxtLink :to="props.redirectBack">Back</NuxtLink>
|
|
|
+ </Button>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </Card>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+const selects = ref({})
|
|
|
+const trixEditors = ref({})
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ name: {
|
|
|
+ type: String,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+
|
|
|
+ modelId: {
|
|
|
+ type: [String, Number],
|
|
|
+ required: false,
|
|
|
+ validator: (value, props) => props.action === "create" || isPresent(value)
|
|
|
+ },
|
|
|
+
|
|
|
+ action: {
|
|
|
+ type: String,
|
|
|
+ required: false,
|
|
|
+ default: "view",
|
|
|
+ validator: (value) => _includes(["create", "update", "view"], value)
|
|
|
+ },
|
|
|
+
|
|
|
+ fields: {
|
|
|
+ type: Object,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+
|
|
|
+ initialValue: {
|
|
|
+ type: Object,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+
|
|
|
+ options: {
|
|
|
+ type: Object,
|
|
|
+ default: {},
|
|
|
+ required: false
|
|
|
+ },
|
|
|
+
|
|
|
+ isSaved: {
|
|
|
+ type: Boolean,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+
|
|
|
+ redirectBack: {
|
|
|
+ type: [String, Object],
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+
|
|
|
+ schema: {
|
|
|
+ type: Object,
|
|
|
+ required: true
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const emit = defineEmits(["delete", "create", "update"])
|
|
|
+const confirm = useConfirm()
|
|
|
+const toast = useToast()
|
|
|
+
|
|
|
+const { original, model, editedFields, isEdited, revert } = useRevertible(
|
|
|
+ () => props.initialValue
|
|
|
+)
|
|
|
+
|
|
|
+const suggestions = ref(toValue(props.options))
|
|
|
+const valid = ref(true)
|
|
|
+const formErrors = ref([])
|
|
|
+const fieldErrors = ref({})
|
|
|
+
|
|
|
+onBeforeRouteLeave(async () => {
|
|
|
+ if (isEdited.value && !props.isSaved && !(await confirmLeave())) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+async function focusFormControl(field, type) {
|
|
|
+ await nextTick()
|
|
|
+ const control = document.getElementById(field)
|
|
|
+ control.focus()
|
|
|
+
|
|
|
+ switch (type) {
|
|
|
+ case "select": {
|
|
|
+ // noinspection JSUnresolvedReference
|
|
|
+ selects.value[field].show()
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ case "richText": {
|
|
|
+ await nextTick()
|
|
|
+ const length = control.editor.getDocument().getLength()
|
|
|
+ control.editor.setSelectedRange(length - 1)
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ default: {
|
|
|
+ const length = _isNull(model.value[field]) ? 0 : model.value[field].length
|
|
|
+
|
|
|
+ control.setSelectionRange(length, length)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function waitForAnimation(deactivate) {
|
|
|
+ await callAfterDelay(deactivate, 200)
|
|
|
+}
|
|
|
+
|
|
|
+async function reset() {
|
|
|
+ revert()
|
|
|
+ formErrors.value = []
|
|
|
+ fieldErrors.value = {}
|
|
|
+ await nextTick()
|
|
|
+
|
|
|
+ // noinspection JSUnresolvedReference
|
|
|
+ _forEach(_keys(_pickBy(props.fields, { type: "richText" })), (field) =>
|
|
|
+ trixEditors.value[field].reset()
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function validate(field = null) {
|
|
|
+ const result = props.schema.safeParse(
|
|
|
+ props.action === "create" ? model.value : editedFields.value
|
|
|
+ )
|
|
|
+
|
|
|
+ if (result.success) {
|
|
|
+ valid.value = true
|
|
|
+ formErrors.value = []
|
|
|
+ fieldErrors.value = {}
|
|
|
+ } else {
|
|
|
+ valid.value = false
|
|
|
+ const flattened = z.flattenError(result.error)
|
|
|
+ formErrors.value = flattened.formErrors
|
|
|
+
|
|
|
+ if (field) {
|
|
|
+ fieldErrors.value[field] = flattened.fieldErrors[field]
|
|
|
+ } else {
|
|
|
+ fieldErrors.value = flattened.fieldErrors
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+function save() {
|
|
|
+ const result = validate()
|
|
|
+
|
|
|
+ if (result.success) {
|
|
|
+ emit(props.action, result.data)
|
|
|
+ } else {
|
|
|
+ console.error(
|
|
|
+ "save() called with invalid data",
|
|
|
+ z.flattenError(result.error)
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function confirmDelete() {
|
|
|
+ confirm.require({
|
|
|
+ header: "Really?",
|
|
|
+ icon: "ph:warning-bold",
|
|
|
+ message: `Do you want to delete this ${props.name}?`,
|
|
|
+ defaultFocus: "reject",
|
|
|
+
|
|
|
+ acceptProps: {
|
|
|
+ label: "Delete",
|
|
|
+ severity: "danger"
|
|
|
+ },
|
|
|
+
|
|
|
+ rejectProps: { label: "Cancel" },
|
|
|
+ accept: () => emit("delete"),
|
|
|
+
|
|
|
+ reject: () =>
|
|
|
+ toast.add({
|
|
|
+ severity: "info",
|
|
|
+ summary: "Cancelled.",
|
|
|
+ detail: "Delete cancelled.",
|
|
|
+ life: 3000
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function confirmReset() {
|
|
|
+ // noinspection JSUnusedGlobalSymbols
|
|
|
+ const confirmOptions = {
|
|
|
+ header: "Reset?",
|
|
|
+ icon: "ph:warning-bold",
|
|
|
+ message: `Do you want to reset this ${props.name}?`,
|
|
|
+ defaultFocus: "reject",
|
|
|
+
|
|
|
+ acceptProps: {
|
|
|
+ label: "Reset",
|
|
|
+ severity: "warn"
|
|
|
+ },
|
|
|
+
|
|
|
+ rejectProps: { label: "Cancel" },
|
|
|
+ accept: async () => await reset(),
|
|
|
+
|
|
|
+ reject: () =>
|
|
|
+ toast.add({
|
|
|
+ severity: "info",
|
|
|
+ summary: "Cancelled.",
|
|
|
+ detail: "Reset cancelled.",
|
|
|
+ life: 3000
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (props.action === "create") {
|
|
|
+ _assign(confirmOptions, {
|
|
|
+ header: "Revert?",
|
|
|
+ message: "Do you want to revert your edits?",
|
|
|
+
|
|
|
+ acceptProps: {
|
|
|
+ label: "Revert",
|
|
|
+ severity: "warn"
|
|
|
+ },
|
|
|
+
|
|
|
+ reject: () =>
|
|
|
+ toast.add({
|
|
|
+ severity: "info",
|
|
|
+ summary: "Cancelled.",
|
|
|
+ detail: "Revert cancelled.",
|
|
|
+ life: 3000
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ confirm.require(confirmOptions)
|
|
|
+}
|
|
|
+
|
|
|
+async function confirmLeave() {
|
|
|
+ const modal = new Promise((resolve) => {
|
|
|
+ const confirmOptions = {
|
|
|
+ header: "Leave?",
|
|
|
+ icon: "ph:warning-bold",
|
|
|
+ message: `Do you want to abandon this ${props.name}?`,
|
|
|
+ defaultFocus: "reject",
|
|
|
+
|
|
|
+ acceptProps: {
|
|
|
+ label: "Leave",
|
|
|
+ severity: "danger"
|
|
|
+ },
|
|
|
+
|
|
|
+ rejectProps: { label: "Cancel" },
|
|
|
+
|
|
|
+ accept: () => resolve(true),
|
|
|
+ reject: () => resolve(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (props.action === "create") {
|
|
|
+ _assign(confirmOptions, {
|
|
|
+ message: "Do you want to abandon your edits?"
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ confirm.require(confirmOptions)
|
|
|
+ })
|
|
|
+
|
|
|
+ return await modal
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped></style>
|