EditorCard.vue 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. <!--suppress VueUnrecognizedSlot -->
  2. <template>
  3. <Card class="mx-auto max-w-prose">
  4. <template #content>
  5. <div class="flex flex-col gap-2">
  6. <Message
  7. v-if="isPresent(formErrors)"
  8. severity="error"
  9. pt:root="border border-transparent"
  10. >
  11. {{ _join(formErrors, " ") }}
  12. </Message>
  13. <div
  14. v-for="({ type }, field) in props.fields"
  15. :key="field"
  16. class="group flex flex-col"
  17. >
  18. <Swap
  19. :class="props.action !== 'view' && 'cursor-pointer'"
  20. :disabled="props.action === 'view'"
  21. @active="focusFormControl(field, type)"
  22. @inactive="validate(field)"
  23. >
  24. <template #inactive>
  25. <div class="ml-1 block text-sm text-primary dark:text-primary">
  26. {{ _startCase(field) }}
  27. </div>
  28. <div
  29. v-if="type === 'richText'"
  30. class="trix-content border border-transparent px-3 py-2"
  31. :class="props.action !== 'view' && 'group-hover:bg-primary/15'"
  32. :tabindex="0"
  33. v-html="model?.[field] || '&nbsp;'"
  34. />
  35. <div
  36. v-else
  37. class="border border-transparent px-3 py-2"
  38. :class="props.action !== 'view' && 'group-hover:bg-primary/15'"
  39. :tabindex="0"
  40. >
  41. {{ model?.[field] || "&nbsp;" }}
  42. </div>
  43. </template>
  44. <template #active="{ deactivate }">
  45. <div class="ml-1 block text-sm text-primary dark:text-primary">
  46. {{ _startCase(field) }}
  47. </div>
  48. <TrixEditor
  49. v-if="type === 'richText'"
  50. v-model="model[field]"
  51. :id="field"
  52. :tabindex="0"
  53. @blur="waitForAnimation(deactivate)"
  54. :ref="(trixEditor) => (trixEditors[field] = trixEditor)"
  55. />
  56. <Select
  57. v-else-if="type === 'select'"
  58. v-model="model[field]"
  59. :tabindex="0"
  60. :labelId="field"
  61. fluid
  62. :options="options?.[field]"
  63. @blur="waitForAnimation(deactivate)"
  64. :ref="(select) => (selects[field] = select)"
  65. />
  66. <ComboBox
  67. v-else-if="type === 'autocomplete'"
  68. v-model="model[field]"
  69. :tabindex="0"
  70. :inputId="field"
  71. :suggestions="suggestions?.[field]"
  72. @blur="waitForAnimation(deactivate)"
  73. />
  74. <InputText
  75. v-else
  76. v-model="model[field]"
  77. :id="field"
  78. :tabindex="0"
  79. fluid
  80. @blur="waitForAnimation(deactivate)"
  81. />
  82. </template>
  83. </Swap>
  84. <Message
  85. v-if="fieldErrors?.[field]"
  86. severity="error"
  87. size="small"
  88. variant="simple"
  89. class="ml-1"
  90. >
  91. {{ _join(fieldErrors?.[field], " ") }}
  92. </Message>
  93. </div>
  94. </div>
  95. </template>
  96. <template #footer>
  97. <div class="mt-4 flex flex-row justify-end gap-3">
  98. <template v-if="isEdited">
  99. <Button
  100. severity="warn"
  101. @click="confirmReset"
  102. >
  103. Reset
  104. </Button>
  105. <Button
  106. :disabled="!valid"
  107. @click="save"
  108. >
  109. Save
  110. </Button>
  111. </template>
  112. <template v-else>
  113. <Button
  114. v-if="props.action === 'update'"
  115. severity="danger"
  116. @click="confirmDelete"
  117. >
  118. Delete
  119. </Button>
  120. <Button>
  121. <!--suppress JSValidateTypes -->
  122. <NuxtLink :to="props.redirectBack">Back</NuxtLink>
  123. </Button>
  124. </template>
  125. </div>
  126. </template>
  127. </Card>
  128. </template>
  129. <script setup>
  130. const selects = ref({})
  131. const trixEditors = ref({})
  132. const props = defineProps({
  133. name: {
  134. type: String,
  135. required: true
  136. },
  137. modelId: {
  138. type: [String, Number],
  139. required: false,
  140. validator: (value, props) => props.action === "create" || isPresent(value)
  141. },
  142. action: {
  143. type: String,
  144. required: false,
  145. default: "view",
  146. validator: (value) => _includes(["create", "update", "view"], value)
  147. },
  148. fields: {
  149. type: Object,
  150. required: true
  151. },
  152. initialValue: {
  153. type: Object,
  154. required: true
  155. },
  156. options: {
  157. type: Object,
  158. default: {},
  159. required: false
  160. },
  161. isSaved: {
  162. type: Boolean,
  163. required: true
  164. },
  165. redirectBack: {
  166. type: [String, Object],
  167. required: true
  168. },
  169. schema: {
  170. type: Object,
  171. required: true
  172. }
  173. })
  174. const emit = defineEmits(["delete", "create", "update"])
  175. const confirm = useConfirm()
  176. const toast = useToast()
  177. const { original, model, editedFields, isEdited, revert } = useRevertible(
  178. () => props.initialValue
  179. )
  180. const suggestions = ref(toValue(props.options))
  181. const valid = ref(true)
  182. const formErrors = ref([])
  183. const fieldErrors = ref({})
  184. onBeforeRouteLeave(async () => {
  185. if (isEdited.value && !props.isSaved && !(await confirmLeave())) {
  186. return false
  187. }
  188. })
  189. async function focusFormControl(field, type) {
  190. await nextTick()
  191. const control = document.getElementById(field)
  192. control.focus()
  193. switch (type) {
  194. case "select": {
  195. // noinspection JSUnresolvedReference
  196. selects.value[field].show()
  197. break
  198. }
  199. case "richText": {
  200. await nextTick()
  201. const length = control.editor.getDocument().getLength()
  202. control.editor.setSelectedRange(length - 1)
  203. break
  204. }
  205. default: {
  206. const length = _isNull(model.value[field]) ? 0 : model.value[field].length
  207. control.setSelectionRange(length, length)
  208. }
  209. }
  210. }
  211. async function waitForAnimation(deactivate) {
  212. await callAfterDelay(deactivate, 200)
  213. }
  214. async function reset() {
  215. revert()
  216. formErrors.value = []
  217. fieldErrors.value = {}
  218. await nextTick()
  219. // noinspection JSUnresolvedReference
  220. _forEach(_keys(_pickBy(props.fields, { type: "richText" })), (field) =>
  221. trixEditors.value[field].reset()
  222. )
  223. }
  224. function validate(field = null) {
  225. const result = props.schema.safeParse(
  226. props.action === "create" ? model.value : editedFields.value
  227. )
  228. if (result.success) {
  229. valid.value = true
  230. formErrors.value = []
  231. fieldErrors.value = {}
  232. } else {
  233. valid.value = false
  234. const flattened = z.flattenError(result.error)
  235. formErrors.value = flattened.formErrors
  236. if (field) {
  237. fieldErrors.value[field] = flattened.fieldErrors[field]
  238. } else {
  239. fieldErrors.value = flattened.fieldErrors
  240. }
  241. }
  242. return result
  243. }
  244. function save() {
  245. const result = validate()
  246. if (result.success) {
  247. emit(props.action, result.data)
  248. } else {
  249. console.error(
  250. "save() called with invalid data",
  251. z.flattenError(result.error)
  252. )
  253. }
  254. }
  255. function confirmDelete() {
  256. confirm.require({
  257. header: "Really?",
  258. icon: "ph:warning-bold",
  259. message: `Do you want to delete this ${props.name}?`,
  260. defaultFocus: "reject",
  261. acceptProps: {
  262. label: "Delete",
  263. severity: "danger"
  264. },
  265. rejectProps: { label: "Cancel" },
  266. accept: () => emit("delete"),
  267. reject: () =>
  268. toast.add({
  269. severity: "info",
  270. summary: "Cancelled.",
  271. detail: "Delete cancelled.",
  272. life: 3000
  273. })
  274. })
  275. }
  276. function confirmReset() {
  277. // noinspection JSUnusedGlobalSymbols
  278. const confirmOptions = {
  279. header: "Reset?",
  280. icon: "ph:warning-bold",
  281. message: `Do you want to reset this ${props.name}?`,
  282. defaultFocus: "reject",
  283. acceptProps: {
  284. label: "Reset",
  285. severity: "warn"
  286. },
  287. rejectProps: { label: "Cancel" },
  288. accept: async () => await reset(),
  289. reject: () =>
  290. toast.add({
  291. severity: "info",
  292. summary: "Cancelled.",
  293. detail: "Reset cancelled.",
  294. life: 3000
  295. })
  296. }
  297. if (props.action === "create") {
  298. _assign(confirmOptions, {
  299. header: "Revert?",
  300. message: "Do you want to revert your edits?",
  301. acceptProps: {
  302. label: "Revert",
  303. severity: "warn"
  304. },
  305. reject: () =>
  306. toast.add({
  307. severity: "info",
  308. summary: "Cancelled.",
  309. detail: "Revert cancelled.",
  310. life: 3000
  311. })
  312. })
  313. }
  314. confirm.require(confirmOptions)
  315. }
  316. async function confirmLeave() {
  317. const modal = new Promise((resolve) => {
  318. const confirmOptions = {
  319. header: "Leave?",
  320. icon: "ph:warning-bold",
  321. message: `Do you want to abandon this ${props.name}?`,
  322. defaultFocus: "reject",
  323. acceptProps: {
  324. label: "Leave",
  325. severity: "danger"
  326. },
  327. rejectProps: { label: "Cancel" },
  328. accept: () => resolve(true),
  329. reject: () => resolve(false)
  330. }
  331. if (props.action === "create") {
  332. _assign(confirmOptions, {
  333. message: "Do you want to abandon your edits?"
  334. })
  335. }
  336. confirm.require(confirmOptions)
  337. })
  338. return await modal
  339. }
  340. </script>
  341. <style scoped></style>