EditorCard.vue 9.4 KB

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