| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- <!--suppress VueUnrecognizedSlot, JSUnresolvedReference -->
- <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 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 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]"
- :inputId="field"
- :tabindex="0"
- :suggestions="suggestions?.[field]"
- @blur="waitForAnimation(deactivate)"
- />
- <PasswordField
- v-else-if="type === 'password'"
- v-model="model[field]"
- :inputId="field"
- :tabindex="0"
- @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,
- 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)
- // noinspection JSUnresolvedReference
- 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>
|