@kinetis/vector-ui 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +18 -0
  3. package/README.md +72 -2
  4. package/app/app.config.ts +63 -0
  5. package/app/app.vue +11 -0
  6. package/app/components/Vx/Action/Dialog.vue +107 -0
  7. package/app/components/Vx/Cell/Boolean.vue +10 -0
  8. package/app/components/Vx/Cell/Color.vue +12 -0
  9. package/app/components/Vx/Cell/Count.vue +12 -0
  10. package/app/components/Vx/Cell/Date.vue +10 -0
  11. package/app/components/Vx/Cell/DateTime.vue +10 -0
  12. package/app/components/Vx/Cell/Email.vue +9 -0
  13. package/app/components/Vx/Cell/Enum.vue +14 -0
  14. package/app/components/Vx/Cell/File.vue +30 -0
  15. package/app/components/Vx/Cell/Image.vue +38 -0
  16. package/app/components/Vx/Cell/Json.vue +18 -0
  17. package/app/components/Vx/Cell/KeyValue.vue +25 -0
  18. package/app/components/Vx/Cell/Markdown.vue +18 -0
  19. package/app/components/Vx/Cell/Money.vue +16 -0
  20. package/app/components/Vx/Cell/Number.vue +16 -0
  21. package/app/components/Vx/Cell/Relation.vue +16 -0
  22. package/app/components/Vx/Cell/RichText.vue +41 -0
  23. package/app/components/Vx/Cell/Text.vue +13 -0
  24. package/app/components/Vx/Cell/Url.vue +11 -0
  25. package/app/components/Vx/Cell.vue +25 -0
  26. package/app/components/Vx/CommandPalette.vue +118 -0
  27. package/app/components/Vx/Confirm.vue +19 -0
  28. package/app/components/Vx/Dashboard.vue +27 -0
  29. package/app/components/Vx/FieldsForm.vue +46 -0
  30. package/app/components/Vx/Import/Dialog.vue +131 -0
  31. package/app/components/Vx/Inbox.vue +49 -0
  32. package/app/components/Vx/Input/Boolean.vue +10 -0
  33. package/app/components/Vx/Input/Currency.vue +25 -0
  34. package/app/components/Vx/Input/DateTime.vue +38 -0
  35. package/app/components/Vx/Input/Enum.vue +21 -0
  36. package/app/components/Vx/Input/KeyValue.vue +78 -0
  37. package/app/components/Vx/Input/Markdown.vue +120 -0
  38. package/app/components/Vx/Input/Money.vue +47 -0
  39. package/app/components/Vx/Input/Number.vue +24 -0
  40. package/app/components/Vx/Input/Relation.vue +127 -0
  41. package/app/components/Vx/Input/Repeater.vue +134 -0
  42. package/app/components/Vx/Input/RichText.vue +182 -0
  43. package/app/components/Vx/Input/Text.vue +21 -0
  44. package/app/components/Vx/Input/Textarea.vue +20 -0
  45. package/app/components/Vx/Input/Upload.vue +89 -0
  46. package/app/components/Vx/Layout.vue +111 -0
  47. package/app/components/Vx/List/BulkBar.vue +44 -0
  48. package/app/components/Vx/List/BulkEdit.vue +91 -0
  49. package/app/components/Vx/List/ColumnManager.vue +74 -0
  50. package/app/components/Vx/List/FilterBar.vue +224 -0
  51. package/app/components/Vx/List/ViewMenu.vue +115 -0
  52. package/app/components/Vx/Notifications.vue +9 -0
  53. package/app/components/Vx/Resource/Audit.vue +82 -0
  54. package/app/components/Vx/Resource/Form.vue +351 -0
  55. package/app/components/Vx/Resource/RelationPanel.vue +217 -0
  56. package/app/components/Vx/Resource/Show.vue +35 -0
  57. package/app/components/Vx/Resource/Table.vue +513 -0
  58. package/app/components/Vx/Widget/Card.vue +80 -0
  59. package/app/components/Vx/Widget/Partition.vue +21 -0
  60. package/app/components/Vx/Widget/Progress.vue +22 -0
  61. package/app/components/Vx/Widget/Stat.vue +22 -0
  62. package/app/components/Vx/Widget/Table.vue +31 -0
  63. package/app/components/Vx/Widget/Trend.vue +37 -0
  64. package/app/composables/useConfirm.ts +34 -0
  65. package/app/composables/useLeaveGuard.ts +21 -0
  66. package/app/composables/useVectorApi.ts +74 -0
  67. package/app/composables/useVectorAuthStore.ts +252 -0
  68. package/app/composables/useVectorBranding.ts +45 -0
  69. package/app/composables/useVectorDashboards.ts +15 -0
  70. package/app/composables/useVectorExtensions.ts +44 -0
  71. package/app/composables/useVectorFields.ts +68 -0
  72. package/app/composables/useVectorForm.ts +36 -0
  73. package/app/composables/useVectorIdle.ts +39 -0
  74. package/app/composables/useVectorImpersonation.ts +78 -0
  75. package/app/composables/useVectorInputs.ts +61 -0
  76. package/app/composables/useVectorJobs.ts +62 -0
  77. package/app/composables/useVectorLocale.ts +70 -0
  78. package/app/composables/useVectorNotifications.ts +24 -0
  79. package/app/composables/useVectorNotificationsInbox.ts +26 -0
  80. package/app/composables/useVectorPreferences.ts +30 -0
  81. package/app/composables/useVectorResource.ts +305 -0
  82. package/app/composables/useVectorRuntime.ts +42 -0
  83. package/app/composables/useVectorSchema.ts +56 -0
  84. package/app/composables/useVectorT.ts +9 -0
  85. package/app/composables/useVectorViews.ts +25 -0
  86. package/app/i18n/ar/actions.ts +11 -0
  87. package/app/i18n/ar/common.ts +23 -0
  88. package/app/i18n/ar/dashboard.ts +28 -0
  89. package/app/i18n/ar/fields.ts +77 -0
  90. package/app/i18n/ar/index.ts +12 -0
  91. package/app/i18n/ar/jobs.ts +36 -0
  92. package/app/i18n/ar/list.ts +160 -0
  93. package/app/i18n/ar/record.ts +62 -0
  94. package/app/i18n/ar/settings.ts +10 -0
  95. package/app/i18n/ar/shell.ts +54 -0
  96. package/app/i18n/de/actions.ts +11 -0
  97. package/app/i18n/de/common.ts +23 -0
  98. package/app/i18n/de/dashboard.ts +28 -0
  99. package/app/i18n/de/fields.ts +77 -0
  100. package/app/i18n/de/index.ts +12 -0
  101. package/app/i18n/de/jobs.ts +36 -0
  102. package/app/i18n/de/list.ts +160 -0
  103. package/app/i18n/de/record.ts +62 -0
  104. package/app/i18n/de/settings.ts +10 -0
  105. package/app/i18n/de/shell.ts +54 -0
  106. package/app/i18n/en/actions.ts +11 -0
  107. package/app/i18n/en/common.ts +23 -0
  108. package/app/i18n/en/dashboard.ts +28 -0
  109. package/app/i18n/en/fields.ts +77 -0
  110. package/app/i18n/en/index.ts +12 -0
  111. package/app/i18n/en/jobs.ts +36 -0
  112. package/app/i18n/en/list.ts +160 -0
  113. package/app/i18n/en/record.ts +62 -0
  114. package/app/i18n/en/settings.ts +10 -0
  115. package/app/i18n/en/shell.ts +54 -0
  116. package/app/i18n/es/actions.ts +11 -0
  117. package/app/i18n/es/common.ts +23 -0
  118. package/app/i18n/es/dashboard.ts +28 -0
  119. package/app/i18n/es/fields.ts +77 -0
  120. package/app/i18n/es/index.ts +12 -0
  121. package/app/i18n/es/jobs.ts +36 -0
  122. package/app/i18n/es/list.ts +160 -0
  123. package/app/i18n/es/record.ts +62 -0
  124. package/app/i18n/es/settings.ts +10 -0
  125. package/app/i18n/es/shell.ts +54 -0
  126. package/app/i18n/fr/actions.ts +11 -0
  127. package/app/i18n/fr/common.ts +23 -0
  128. package/app/i18n/fr/dashboard.ts +28 -0
  129. package/app/i18n/fr/fields.ts +77 -0
  130. package/app/i18n/fr/index.ts +12 -0
  131. package/app/i18n/fr/jobs.ts +36 -0
  132. package/app/i18n/fr/list.ts +160 -0
  133. package/app/i18n/fr/record.ts +62 -0
  134. package/app/i18n/fr/settings.ts +10 -0
  135. package/app/i18n/fr/shell.ts +54 -0
  136. package/app/i18n/he/actions.ts +11 -0
  137. package/app/i18n/he/common.ts +23 -0
  138. package/app/i18n/he/dashboard.ts +28 -0
  139. package/app/i18n/he/fields.ts +77 -0
  140. package/app/i18n/he/index.ts +12 -0
  141. package/app/i18n/he/jobs.ts +36 -0
  142. package/app/i18n/he/list.ts +160 -0
  143. package/app/i18n/he/record.ts +62 -0
  144. package/app/i18n/he/settings.ts +10 -0
  145. package/app/i18n/he/shell.ts +54 -0
  146. package/app/i18n/index.ts +111 -0
  147. package/app/i18n/it/actions.ts +11 -0
  148. package/app/i18n/it/common.ts +23 -0
  149. package/app/i18n/it/dashboard.ts +28 -0
  150. package/app/i18n/it/fields.ts +77 -0
  151. package/app/i18n/it/index.ts +12 -0
  152. package/app/i18n/it/jobs.ts +36 -0
  153. package/app/i18n/it/list.ts +160 -0
  154. package/app/i18n/it/record.ts +62 -0
  155. package/app/i18n/it/settings.ts +10 -0
  156. package/app/i18n/it/shell.ts +54 -0
  157. package/app/i18n/ja/actions.ts +11 -0
  158. package/app/i18n/ja/common.ts +23 -0
  159. package/app/i18n/ja/dashboard.ts +28 -0
  160. package/app/i18n/ja/fields.ts +77 -0
  161. package/app/i18n/ja/index.ts +12 -0
  162. package/app/i18n/ja/jobs.ts +36 -0
  163. package/app/i18n/ja/list.ts +160 -0
  164. package/app/i18n/ja/record.ts +62 -0
  165. package/app/i18n/ja/settings.ts +10 -0
  166. package/app/i18n/ja/shell.ts +54 -0
  167. package/app/i18n/ko/actions.ts +11 -0
  168. package/app/i18n/ko/common.ts +23 -0
  169. package/app/i18n/ko/dashboard.ts +28 -0
  170. package/app/i18n/ko/fields.ts +77 -0
  171. package/app/i18n/ko/index.ts +12 -0
  172. package/app/i18n/ko/jobs.ts +36 -0
  173. package/app/i18n/ko/list.ts +160 -0
  174. package/app/i18n/ko/record.ts +62 -0
  175. package/app/i18n/ko/settings.ts +10 -0
  176. package/app/i18n/ko/shell.ts +54 -0
  177. package/app/i18n/nl/actions.ts +11 -0
  178. package/app/i18n/nl/common.ts +23 -0
  179. package/app/i18n/nl/dashboard.ts +28 -0
  180. package/app/i18n/nl/fields.ts +77 -0
  181. package/app/i18n/nl/index.ts +12 -0
  182. package/app/i18n/nl/jobs.ts +36 -0
  183. package/app/i18n/nl/list.ts +160 -0
  184. package/app/i18n/nl/record.ts +62 -0
  185. package/app/i18n/nl/settings.ts +10 -0
  186. package/app/i18n/nl/shell.ts +54 -0
  187. package/app/i18n/pl/actions.ts +11 -0
  188. package/app/i18n/pl/common.ts +23 -0
  189. package/app/i18n/pl/dashboard.ts +28 -0
  190. package/app/i18n/pl/fields.ts +77 -0
  191. package/app/i18n/pl/index.ts +12 -0
  192. package/app/i18n/pl/jobs.ts +36 -0
  193. package/app/i18n/pl/list.ts +160 -0
  194. package/app/i18n/pl/record.ts +62 -0
  195. package/app/i18n/pl/settings.ts +10 -0
  196. package/app/i18n/pl/shell.ts +54 -0
  197. package/app/i18n/pt/actions.ts +11 -0
  198. package/app/i18n/pt/common.ts +23 -0
  199. package/app/i18n/pt/dashboard.ts +28 -0
  200. package/app/i18n/pt/fields.ts +77 -0
  201. package/app/i18n/pt/index.ts +12 -0
  202. package/app/i18n/pt/jobs.ts +36 -0
  203. package/app/i18n/pt/list.ts +160 -0
  204. package/app/i18n/pt/record.ts +62 -0
  205. package/app/i18n/pt/settings.ts +10 -0
  206. package/app/i18n/pt/shell.ts +54 -0
  207. package/app/i18n/pt-BR/actions.ts +11 -0
  208. package/app/i18n/pt-BR/common.ts +23 -0
  209. package/app/i18n/pt-BR/dashboard.ts +28 -0
  210. package/app/i18n/pt-BR/fields.ts +77 -0
  211. package/app/i18n/pt-BR/index.ts +12 -0
  212. package/app/i18n/pt-BR/jobs.ts +36 -0
  213. package/app/i18n/pt-BR/list.ts +160 -0
  214. package/app/i18n/pt-BR/record.ts +62 -0
  215. package/app/i18n/pt-BR/settings.ts +10 -0
  216. package/app/i18n/pt-BR/shell.ts +54 -0
  217. package/app/i18n/ru/actions.ts +11 -0
  218. package/app/i18n/ru/common.ts +23 -0
  219. package/app/i18n/ru/dashboard.ts +28 -0
  220. package/app/i18n/ru/fields.ts +77 -0
  221. package/app/i18n/ru/index.ts +12 -0
  222. package/app/i18n/ru/jobs.ts +36 -0
  223. package/app/i18n/ru/list.ts +160 -0
  224. package/app/i18n/ru/record.ts +62 -0
  225. package/app/i18n/ru/settings.ts +10 -0
  226. package/app/i18n/ru/shell.ts +54 -0
  227. package/app/i18n/tr/actions.ts +11 -0
  228. package/app/i18n/tr/common.ts +23 -0
  229. package/app/i18n/tr/dashboard.ts +28 -0
  230. package/app/i18n/tr/fields.ts +77 -0
  231. package/app/i18n/tr/index.ts +12 -0
  232. package/app/i18n/tr/jobs.ts +36 -0
  233. package/app/i18n/tr/list.ts +160 -0
  234. package/app/i18n/tr/record.ts +62 -0
  235. package/app/i18n/tr/settings.ts +10 -0
  236. package/app/i18n/tr/shell.ts +54 -0
  237. package/app/i18n/uk/actions.ts +11 -0
  238. package/app/i18n/uk/common.ts +23 -0
  239. package/app/i18n/uk/dashboard.ts +28 -0
  240. package/app/i18n/uk/fields.ts +77 -0
  241. package/app/i18n/uk/index.ts +12 -0
  242. package/app/i18n/uk/jobs.ts +36 -0
  243. package/app/i18n/uk/list.ts +160 -0
  244. package/app/i18n/uk/record.ts +62 -0
  245. package/app/i18n/uk/settings.ts +10 -0
  246. package/app/i18n/uk/shell.ts +54 -0
  247. package/app/i18n/zh-Hans/actions.ts +11 -0
  248. package/app/i18n/zh-Hans/common.ts +23 -0
  249. package/app/i18n/zh-Hans/dashboard.ts +28 -0
  250. package/app/i18n/zh-Hans/fields.ts +77 -0
  251. package/app/i18n/zh-Hans/index.ts +12 -0
  252. package/app/i18n/zh-Hans/jobs.ts +36 -0
  253. package/app/i18n/zh-Hans/list.ts +160 -0
  254. package/app/i18n/zh-Hans/record.ts +62 -0
  255. package/app/i18n/zh-Hans/settings.ts +10 -0
  256. package/app/i18n/zh-Hans/shell.ts +54 -0
  257. package/app/layouts/default.vue +8 -0
  258. package/app/layouts/vector.vue +238 -0
  259. package/app/middleware/vector-auth.ts +16 -0
  260. package/app/pages/d/[name].vue +12 -0
  261. package/app/pages/index.vue +22 -0
  262. package/app/pages/jobs/[id].vue +52 -0
  263. package/app/pages/jobs/index.vue +42 -0
  264. package/app/pages/login.vue +109 -0
  265. package/app/pages/p/[name].vue +37 -0
  266. package/app/pages/r/[resource]/[id]/edit.vue +54 -0
  267. package/app/pages/r/[resource]/[id]/index.vue +143 -0
  268. package/app/pages/r/[resource]/create.vue +53 -0
  269. package/app/pages/r/[resource]/index.vue +29 -0
  270. package/app/pages/settings.vue +104 -0
  271. package/app/plugins/vector-csp.client.ts +14 -0
  272. package/app/plugins/vector-extensions.client.ts +84 -0
  273. package/app/plugins/vector-i18n.ts +76 -0
  274. package/app/types/vector.ts +438 -0
  275. package/app/utils/files.ts +14 -0
  276. package/app/utils/format.ts +100 -0
  277. package/app/utils/richtext.ts +305 -0
  278. package/app/utils/rules.ts +113 -0
  279. package/app/utils/translate.ts +45 -0
  280. package/app/utils/widgets.ts +11 -0
  281. package/nuxt.config.ts +83 -0
  282. package/package.json +82 -5
@@ -0,0 +1,351 @@
1
+ <script setup lang="ts">
2
+ import type { SubmitEventPromise } from 'vuetify'
3
+ import type { VectorField, VectorFieldState, VectorLayoutNode, VectorRecord, VectorResource } from '#vector/app/types/vector'
4
+
5
+ // A create or edit form generated from the descriptor: one input per
6
+ // editable field, client rules mirroring the server's, and the server's
7
+ // 422 mapped onto the field it names. It knows whether it holds unsaved
8
+ // changes (`dirty`), and on a page offers save variants: back to the
9
+ // record, keep editing, or (creating) start another — the last one chosen
10
+ // remembered per user, Ctrl/Cmd+S running it.
11
+ const props = defineProps<{
12
+ resource: VectorResource
13
+ operation: 'create' | 'update'
14
+ record?: VectorRecord
15
+ /** Values fixed by the context (a parent a child is created under): pre-filled, not shown. */
16
+ preset?: Record<string, unknown>
17
+ /** Offer the save variants and Ctrl/Cmd+S: the create and edit pages do, a dialog does not. */
18
+ variants?: boolean
19
+ /** Creating a copy: the record whose values the form starts from (Duplicate). */
20
+ copyOf?: VectorRecord
21
+ }>()
22
+
23
+ /** What happens after a save: back to the record, keep editing it, or start another. */
24
+ export type SaveVariant = 'close' | 'continue' | 'another'
25
+
26
+ const emit = defineEmits<{ saved: [record: VectorRecord, variant: SaveVariant], cancel: [] }>()
27
+
28
+ const { t } = useVectorT()
29
+ const { inputFor, editable } = useVectorInputs()
30
+ const { resource: find } = useVectorSchema()
31
+ const notifications = useVectorNotifications()
32
+ const client = useVectorResource(() => props.resource.slug)
33
+
34
+ const serverErrors = reactive<Record<string, string[]>>({})
35
+ const submitting = ref(false)
36
+ const formError = ref<string | null>(null)
37
+ /** Relation fields whose current rows could not be read: shown as they are, never written from a guess. */
38
+ const unloaded = reactive(new Set<string>())
39
+
40
+ const fields = computed<VectorField[]>(() => props.resource.fields.filter(f => editable(f, props.operation)))
41
+
42
+ // The form's values, keyed by field name: relation values reduce to ids;
43
+ // `initial` is what they were loaded as, which `dirty` and an update compare against.
44
+ const values = reactive<Record<string, unknown>>({})
45
+ const initial = reactive<Record<string, unknown>>({})
46
+ const clone = (value: unknown) => value === undefined ? undefined : JSON.parse(JSON.stringify(value)) as unknown
47
+
48
+ /**
49
+ * What a copy takes from the record it duplicates: every value the create
50
+ * form writes, but never a unique one (it would clash) nor a file or image
51
+ * (a stored key belongs to its record). Collections load below.
52
+ */
53
+ const copies = (field: VectorField) => props.operation === 'create' && props.copyOf !== undefined
54
+ && field.unique !== true && !['file', 'image', 'manyToMany', 'hasMany'].includes(field.type)
55
+
56
+ /** Fills the values from the record, the preset, the record being copied, or each field's default. */
57
+ function start() {
58
+ for (const field of fields.value) {
59
+ const current = props.preset?.[field.name] ?? props.record?.[field.name] ?? (copies(field) ? props.copyOf![field.name] : undefined)
60
+ // a new record starts from each field's default, which the server would apply as well
61
+ let value: unknown = current !== undefined
62
+ ? current
63
+ : props.operation === 'create' && field.hasDefault ? JSON.parse(JSON.stringify(field.default ?? null)) : defaultFor(field)
64
+
65
+ if (field.type === 'belongsTo' && current && typeof current === 'object' && 'id' in current) {
66
+ value = (current as { id: unknown }).id
67
+ }
68
+
69
+ if (field.type === 'manyToMany' || field.type === 'hasMany') {
70
+ value = [] // the ids, or a repeater's rows, load from their endpoint below
71
+ }
72
+
73
+ values[field.name] = value
74
+ initial[field.name] = clone(value)
75
+ }
76
+ }
77
+ start()
78
+
79
+ function defaultFor(field: VectorField): unknown {
80
+ if (field.type === 'boolean') return false
81
+ if (field.type === 'manyToMany' || field.type === 'hasMany') return []
82
+ return null
83
+ }
84
+
85
+ // A manyToMany field's current membership is not in the record (it lists
86
+ // as a count), so an edit form fetches every id it holds: the server
87
+ // syncs the whole list, so a partial one would detach what it left out.
88
+ // A copy takes the same collections: every membership, every repeater row (as a new row, without its id).
89
+ const loadFrom = props.operation === 'update' ? props.record : props.copyOf
90
+
91
+ if (loadFrom) {
92
+ for (const field of fields.value.filter(f => f.type === 'manyToMany')) {
93
+ allRelatedIds(loadFrom.id, field).then((ids) => {
94
+ values[field.name] = ids
95
+ initial[field.name] = [...ids]
96
+ }).catch((error) => {
97
+ unloaded.add(field.name)
98
+ formError.value = vectorErrorMessage(error)
99
+ })
100
+ }
101
+
102
+ // A repeater's rows: each child as a row of the fields it writes, its relations as ids.
103
+ for (const field of fields.value.filter(f => f.repeater)) {
104
+ client.rows(loadFrom.id, field.name).catch((error) => {
105
+ unloaded.add(field.name)
106
+ formError.value = vectorErrorMessage(error)
107
+
108
+ return null
109
+ }).then((page) => {
110
+ if (page === null) return
111
+
112
+ const rows = page.data.map(record => Object.fromEntries([
113
+ ...(props.operation === 'update' ? [['id', record.id]] : []),
114
+ ...field.repeater!.fields.map((name) => {
115
+ const value = record[name]
116
+ return [name, value && typeof value === 'object' && 'id' in value ? (value as { id: unknown }).id : value]
117
+ }),
118
+ ]))
119
+ values[field.name] = rows
120
+ initial[field.name] = JSON.parse(JSON.stringify(rows))
121
+ })
122
+ }
123
+ }
124
+
125
+ /** Every id a many-to-many field holds, page by page: the target's page cap bounds one request, not the membership. */
126
+ async function allRelatedIds(id: string | number, field: VectorField): Promise<unknown[]> {
127
+ const perPage = (field.relation ? find(field.relation.resource)?.table.maxPerPage : undefined) ?? 100
128
+ const ids: unknown[] = []
129
+
130
+ for (let page = 1; ; page++) {
131
+ const result = await client.related(id, field.name, { page, perPage })
132
+ ids.push(...result.data.map(r => r.id))
133
+
134
+ if (page >= result.lastPage) return ids
135
+ }
136
+ }
137
+
138
+ const wide = (field: VectorField) => ['markdown', 'richtext', 'text', 'json', 'keyValue'].includes(field.type) || !!field.repeater
139
+
140
+ /** Fields the browser's own rules refused on the last submit, by the input id each carries. */
141
+ const clientErrors = ref<string[]>([])
142
+ const inputId = (name: string) => `vx-field-${name}`
143
+
144
+ /** Whether a value differs from what the form loaded: what leaving would lose. */
145
+ const dirty = computed(() => fields.value.some(f => !unloaded.has(f.name) && JSON.stringify(values[f.name] ?? null) !== JSON.stringify(initial[f.name] ?? null)))
146
+
147
+ // The save variant: the user's last choice, where this form offers it.
148
+ const preferences = useVectorPreferences()
149
+ preferences.load().catch(() => undefined)
150
+ const offered = computed<SaveVariant[]>(() => props.operation === 'create' ? ['close', 'continue', 'another'] : ['close', 'continue'])
151
+ const chosen = computed<SaveVariant>(() => {
152
+ const stored = preferences.get<SaveVariant>('form.saveVariant')
153
+ return stored && offered.value.includes(stored) ? stored : 'close'
154
+ })
155
+ const variantLabel = (variant: SaveVariant) => {
156
+ const verb = props.operation === 'create' ? 'create' : 'save'
157
+ return variant === 'close' ? t(`common.${verb}`) : t(`record.variants.${verb}${variant === 'continue' ? 'Continue' : 'Another'}`)
158
+ }
159
+
160
+ const formRef = ref<{ validate: () => Promise<{ valid: boolean, errors: { id: string | number }[] }>, resetValidation: () => void } | null>(null)
161
+ let pendingVariant: SaveVariant | null = null
162
+
163
+ /** Saves as $variant, remembering it as this user's default. */
164
+ async function saveAs(variant: SaveVariant) {
165
+ if (variant !== chosen.value) preferences.put({ 'form.saveVariant': variant }).catch(() => undefined)
166
+ pendingVariant = variant
167
+ const result = await formRef.value?.validate()
168
+ if (result) await save(result)
169
+ }
170
+
171
+ // Ctrl/Cmd+S saves with the default variant, on a page form
172
+ if (import.meta.client) {
173
+ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
174
+ if (props.variants && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
175
+ event.preventDefault()
176
+ if (!submitting.value) saveAs(chosen.value)
177
+ }
178
+ })
179
+ }
180
+
181
+ async function submit(event: SubmitEventPromise) {
182
+ await save(await event)
183
+ }
184
+
185
+ async function save({ valid, errors }: { valid: boolean, errors: { id: string | number }[] }) {
186
+ const variant = pendingVariant ?? chosen.value
187
+ pendingVariant = null
188
+ clientErrors.value = errors.map(e => String(e.id)).filter(id => id.startsWith('vx-field-')).map(id => id.slice('vx-field-'.length))
189
+
190
+ if (!valid) {
191
+ return
192
+ }
193
+
194
+ submitting.value = true
195
+ formError.value = null
196
+
197
+ for (const key of Object.keys(serverErrors)) {
198
+ delete serverErrors[key]
199
+ }
200
+
201
+ try {
202
+ let response
203
+
204
+ if (props.operation === 'create') {
205
+ response = await client.create(payload(false))
206
+ } else {
207
+ const version = props.record?._version as number | undefined
208
+ response = await client.update(props.record!.id, payload(true), version)
209
+ }
210
+
211
+ notifications.success(t('record.saved', { label: props.resource.labelSingular }))
212
+ // what is saved is no longer a change: leaving now asks nothing
213
+ for (const field of fields.value) initial[field.name] = clone(values[field.name])
214
+ emit('saved', response.data, variant)
215
+ } catch (error) {
216
+ const failure = error as { status?: number, statusCode?: number, data?: { errors?: unknown } } | null
217
+ const violations = failure?.data?.errors
218
+
219
+ if ((failure?.status ?? failure?.statusCode) === 422 && Array.isArray(violations)) {
220
+ for (const violation of violations as { path: (string | number)[], message: string }[]) {
221
+ // a repeater row's own violation stays with its row: `lines.2.quantity`
222
+ const repeated = violation.path.length >= 3 && fields.value.some(f => f.name === violation.path[0] && f.repeater)
223
+ const key = repeated ? violation.path.slice(0, 3).join('.') : String(violation.path[0] ?? '')
224
+
225
+ if (key && (key in values || repeated)) {
226
+ (serverErrors[key] ??= []).push(`${violation.message.charAt(0).toUpperCase()}${violation.message.slice(1)}`)
227
+ } else {
228
+ formError.value = violation.message
229
+ }
230
+ }
231
+
232
+ if (!formError.value && Object.keys(serverErrors).length === 0) {
233
+ formError.value = t('common.serverRejected')
234
+ }
235
+ } else {
236
+ formError.value = vectorErrorMessage(error)
237
+ }
238
+ } finally {
239
+ submitting.value = false
240
+ }
241
+ }
242
+
243
+ /** Create sends every editable field; update sends only what changed. */
244
+ function payload(onlyChanged: boolean): Record<string, unknown> {
245
+ const body: Record<string, unknown> = {}
246
+
247
+ for (const field of fields.value) {
248
+ const value = values[field.name]
249
+
250
+ if (unloaded.has(field.name)) {
251
+ continue // its current rows never arrived; writing an empty list would detach them all
252
+ }
253
+
254
+ if (onlyChanged && JSON.stringify(value) === JSON.stringify(initial[field.name])) {
255
+ continue
256
+ }
257
+
258
+ if (!onlyChanged && (value === null || value === '') && field.nullable !== true && field.type !== 'manyToMany') {
259
+ continue // let the server report the required field rather than sending an empty string
260
+ }
261
+
262
+ body[field.name] = value === '' ? null : value
263
+ }
264
+
265
+ return body
266
+ }
267
+
268
+ function clearServerError(name: string) {
269
+ delete serverErrors[name]
270
+ }
271
+
272
+ /** Back to a fresh form: the defaults a new record starts from, no errors. */
273
+ function reset() {
274
+ for (const key of Object.keys(serverErrors)) delete serverErrors[key]
275
+ formError.value = null
276
+ clientErrors.value = []
277
+ start()
278
+ formRef.value?.resetValidation()
279
+ }
280
+
281
+ defineExpose({ dirty, reset })
282
+
283
+ // Conditions (visibleWhen, requiredWhen, disabledWhen) follow the values as they are edited.
284
+ // Like the server, they read the stored record overlaid with the values being edited.
285
+ const states = computed<Record<string, VectorFieldState>>(() => {
286
+ const current = { ...(props.record ?? {}), ...values }
287
+ return Object.fromEntries(fields.value.map(f => [f.name, fieldState(f, current)]))
288
+ })
289
+ const shownFields = computed(() => fields.value.filter(f => states.value[f.name]?.visible !== false && !(props.preset && f.name in props.preset)))
290
+ const fieldsByName = computed(() => new Map(fields.value.map(f => [f.name, f])))
291
+ // what an input sees of the record: the values being edited, and the stored record's id (a rich-text image loads through it)
292
+ const inputRecord = computed(() => ({ ...values, id: props.record?.id }))
293
+ const shownNames = computed(() => new Set(shownFields.value.map(f => f.name)))
294
+
295
+ // The declared layout, or every field in order; either way VxLayout draws it.
296
+ const layout = computed<VectorLayoutNode[]>(() => props.resource.layout?.form
297
+ ?? fields.value.map(f => ({ kind: 'field', label: null, description: null, collapsible: false, collapsed: false, columns: null, span: null, name: f.name, children: null })))
298
+ const defaultSpan = (name: string) => {
299
+ const field = fieldsByName.value.get(name)
300
+ return field && wide(field) ? 'full' as const : 1 as const
301
+ }
302
+
303
+ /** Every field holding an error: the server's (a repeater row's under its field) and the browser's. */
304
+ const errorNames = computed(() => [...new Set([
305
+ ...Object.entries(serverErrors).filter(([, messages]) => messages.length > 0).map(([key]) => key.split('.')[0]!),
306
+ ...clientErrors.value,
307
+ ])])
308
+ </script>
309
+
310
+ <template>
311
+ <v-form ref="formRef" validate-on="blur lazy" @submit.prevent="submit">
312
+ <v-card>
313
+ <v-card-text>
314
+ <v-alert v-if="formError" type="error" variant="tonal" class="mb-4" :text="formError" />
315
+ <VxLayout :nodes="layout" :shown="name => shownNames.has(name)" :errors="errorNames" :default-span="defaultSpan">
316
+ <template #field="{ field: name }">
317
+ <component
318
+ :is="inputFor(fieldsByName.get(name)!)"
319
+ :id="inputId(name)"
320
+ v-model="values[name]"
321
+ :field="fieldsByName.get(name)!"
322
+ :disabled="states[name]?.disabled === true"
323
+ :rules="rulesFor(fieldsByName.get(name)!, operation, states[name])"
324
+ :error-messages="serverErrors[name] ?? []"
325
+ :record="inputRecord"
326
+ :resource="resource.slug"
327
+ v-bind="fieldsByName.get(name)!.repeater ? { serverErrors } : {}"
328
+ @update:model-value="clearServerError(name)"
329
+ />
330
+ </template>
331
+ </VxLayout>
332
+ </v-card-text>
333
+ <v-card-actions>
334
+ <v-spacer />
335
+ <v-btn variant="text" @click="emit('cancel')">{{ t('common.cancel') }}</v-btn>
336
+ <v-btn v-if="!variants" type="submit" color="primary" :loading="submitting">{{ operation === 'create' ? t('common.create') : t('common.save') }}</v-btn>
337
+ <v-btn-group v-else color="primary" density="comfortable" divided>
338
+ <v-btn type="submit" :loading="submitting">{{ variantLabel(chosen) }}</v-btn>
339
+ <v-menu location="top end">
340
+ <template #activator="{ props: activator }">
341
+ <v-btn v-bind="activator" icon="mdi-menu-down" :aria-label="t('record.variants.menu')" :disabled="submitting" />
342
+ </template>
343
+ <v-list density="compact">
344
+ <v-list-item v-for="variant in offered" :key="variant" :title="variantLabel(variant)" :active="variant === chosen" @click="saveAs(variant)" />
345
+ </v-list>
346
+ </v-menu>
347
+ </v-btn-group>
348
+ </v-card-actions>
349
+ </v-card>
350
+ </v-form>
351
+ </template>
@@ -0,0 +1,217 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField, VectorRecord, VectorRelationValue, VectorResource } from '#vector/app/types/vector'
3
+
4
+ // One collection of a record (hasMany or manyToMany) as a panel under
5
+ // its show page: the related records as a server-paged table in the
6
+ // target's own columns, and the changes the relation allows here —
7
+ // creating a child with the parent pre-filled (hasMany), attaching and
8
+ // detaching existing records (manyToMany).
9
+ const props = defineProps<{
10
+ owner: VectorResource
11
+ record: VectorRecord
12
+ field: VectorField
13
+ canUpdate: boolean
14
+ }>()
15
+
16
+ const emit = defineEmits<{ changed: [] }>()
17
+
18
+ const { t, noun } = useVectorT()
19
+ const appConfig = useAppConfig()
20
+ const { resource: find } = useVectorSchema()
21
+ const notifications = useVectorNotifications()
22
+ const { confirm } = useConfirm()
23
+ const client = useVectorResource(() => props.owner.slug)
24
+
25
+ const target = computed(() => props.field.relation ? find(props.field.relation.resource) : undefined)
26
+ const inverse = computed(() => props.field.relation?.inverse ?? null)
27
+ const columns = computed<VectorField[]>(() => (target.value?.table.columns ?? [])
28
+ .filter(name => name !== inverse.value)
29
+ .map(name => target.value!.fields.find(f => f.name === name))
30
+ .filter((f): f is VectorField => !!f)
31
+ .slice(0, 6))
32
+ const manyToMany = computed(() => props.field.type === 'manyToMany')
33
+ const canAttach = computed(() => manyToMany.value && props.canUpdate && !props.field.readonly && props.field.views.includes('update'))
34
+ const canCreate = computed(() => props.field.type === 'hasMany' && !!inverse.value && target.value?.permissions.create === true
35
+ && target.value.fields.some(f => f.name === inverse.value))
36
+
37
+ const headers = computed(() => [
38
+ ...columns.value.map(f => ({ title: f.label, key: f.name, sortable: f.sortable })),
39
+ ...(canAttach.value ? [{ title: '', key: '_actions', sortable: false, align: 'end' as const }] : []),
40
+ ])
41
+
42
+ // --- the related page ---------------------------------------------------
43
+ const items = ref<VectorRecord[]>([])
44
+ const total = ref(0)
45
+ const page = ref(1)
46
+ const perPage = ref(10)
47
+ const sortBy = ref<{ key: string, order: 'asc' | 'desc' }[]>([])
48
+ const loading = ref(false)
49
+
50
+ async function load() {
51
+ loading.value = true
52
+
53
+ try {
54
+ const result = await client.related(props.record.id, props.field.name, {
55
+ page: page.value, perPage: perPage.value,
56
+ sort: sortBy.value.map(s => ({ field: s.key, order: s.order })),
57
+ })
58
+ items.value = result.data
59
+ total.value = result.total
60
+ } catch (error) {
61
+ notifications.error(vectorErrorMessage(error))
62
+ } finally {
63
+ loading.value = false
64
+ }
65
+ }
66
+
67
+ watch(() => props.record.id, () => { page.value = 1; load() })
68
+
69
+ function open(item: VectorRecord) {
70
+ if (target.value) navigateTo(`${appConfig.vector.basePath}/r/${target.value.slug}/${encodeURIComponent(String(item.id))}`)
71
+ }
72
+
73
+ // --- manyToMany: attach and detach ---------------------------------------
74
+ const attaching = ref(false)
75
+ const choice = ref<(number | string)[]>([])
76
+ const options = ref<VectorRelationValue[]>([])
77
+ const searching = ref(false)
78
+ const saving = ref(false)
79
+
80
+ const search = useDebounceFn(async (term: string) => {
81
+ searching.value = true
82
+ try {
83
+ options.value = (await client.options(props.field.name, term ?? '')).data
84
+ } catch (error) {
85
+ notifications.error(vectorErrorMessage(error))
86
+ } finally {
87
+ searching.value = false
88
+ }
89
+ }, 250)
90
+
91
+ function startAttach() {
92
+ choice.value = []
93
+ attaching.value = true
94
+ search('')
95
+ }
96
+
97
+ async function attach() {
98
+ if (!choice.value.length) return
99
+ saving.value = true
100
+
101
+ try {
102
+ await client.attach(props.record.id, props.field.name, choice.value)
103
+ notifications.success(t('record.panel.attached', { count: choice.value.length, label: label(choice.value.length) }))
104
+ attaching.value = false
105
+ await load()
106
+ emit('changed')
107
+ } catch (error) {
108
+ notifications.error(vectorErrorMessage(error))
109
+ } finally {
110
+ saving.value = false
111
+ }
112
+ }
113
+
114
+ async function detach(item: VectorRecord) {
115
+ const title = String(item[target.value?.titleField ?? 'id'] ?? `#${item.id}`)
116
+ if (!await confirm({
117
+ title: t('record.panel.detachTitle', { title }),
118
+ text: t('record.panel.detachText', { target: target.value ? noun(target.value.labelSingular) : t('record.panel.record'), owner: noun(props.owner.labelSingular) }),
119
+ confirmText: t('record.panel.detach'),
120
+ destructive: true,
121
+ })) return
122
+
123
+ try {
124
+ await client.detach(props.record.id, props.field.name, [item.id])
125
+ notifications.success(t('record.panel.detached', { title }))
126
+ await load()
127
+ emit('changed')
128
+ } catch (error) {
129
+ notifications.error(vectorErrorMessage(error))
130
+ }
131
+ }
132
+
133
+ // --- hasMany: create a child under this record ---------------------------
134
+ const creating = ref(false)
135
+ const preset = computed(() => inverse.value ? { [inverse.value]: { id: props.record.id } } : {})
136
+
137
+ async function created() {
138
+ creating.value = false
139
+ await load()
140
+ emit('changed')
141
+ }
142
+
143
+ function label(n: number): string {
144
+ return target.value ? noun(n === 1 ? target.value.labelSingular : target.value.label) : t('record.panel.records')
145
+ }
146
+ </script>
147
+
148
+ <template>
149
+ <v-card v-if="target" class="mt-4">
150
+ <v-card-title class="d-flex align-center ga-2 text-subtitle-1">
151
+ <v-icon v-if="target.icon" :icon="target.icon" size="small" />
152
+ {{ field.label }}
153
+ <v-chip size="x-small" variant="tonal">{{ total }}</v-chip>
154
+ <v-spacer />
155
+ <v-btn v-if="canAttach" size="small" variant="tonal" prepend-icon="mdi-link-plus" @click="startAttach">{{ t('record.panel.attach') }}</v-btn>
156
+ <v-btn v-if="canCreate" size="small" variant="tonal" color="primary" prepend-icon="mdi-plus" @click="creating = true">{{ t('record.newTitle', { label: noun(target.labelSingular) }) }}</v-btn>
157
+ </v-card-title>
158
+
159
+ <v-data-table-server
160
+ v-model:page="page"
161
+ v-model:items-per-page="perPage"
162
+ v-model:sort-by="sortBy"
163
+ :headers="headers"
164
+ :items="items"
165
+ :items-length="total"
166
+ :loading="loading"
167
+ :items-per-page-options="[5, 10, 25, 50]"
168
+ item-value="id"
169
+ density="compact"
170
+ hover
171
+ @update:options="load"
172
+ @click:row="(_: Event, { item }: { item: VectorRecord }) => open(item)"
173
+ >
174
+ <template v-for="column in columns" :key="column.name" #[`item.${column.name}`]="{ item }">
175
+ <VxCell :field="column" :value="item[column.name]" :record="item" :resource="target" context="index" />
176
+ </template>
177
+ <template #[`item._actions`]="{ item }">
178
+ <v-btn icon="mdi-link-off" size="small" variant="text" :aria-label="t('record.panel.detachLabel', { title: String(item[target.titleField] ?? item.id) })" @click.stop="detach(item)" />
179
+ </template>
180
+ <template #no-data>
181
+ <div class="text-medium-emphasis py-4">{{ t('record.panel.empty', { label: noun(target.label) }) }}</div>
182
+ </template>
183
+ </v-data-table-server>
184
+
185
+ <v-dialog v-model="attaching" max-width="520">
186
+ <v-card :title="t('record.panel.attachTitle', { label: noun(target.label) })">
187
+ <v-card-text>
188
+ <v-autocomplete
189
+ v-model="choice"
190
+ :items="options"
191
+ item-title="label"
192
+ item-value="id"
193
+ :label="target.label"
194
+ :loading="searching"
195
+ multiple
196
+ chips
197
+ closable-chips
198
+ no-filter
199
+ autofocus
200
+ @update:search="search"
201
+ />
202
+ </v-card-text>
203
+ <v-card-actions>
204
+ <v-spacer />
205
+ <v-btn variant="text" :disabled="saving" @click="attaching = false">{{ t('common.cancel') }}</v-btn>
206
+ <v-btn color="primary" :loading="saving" :disabled="!choice.length" @click="attach">{{ t('record.panel.attachCount', { count: choice.length || '' }) }}</v-btn>
207
+ </v-card-actions>
208
+ </v-card>
209
+ </v-dialog>
210
+
211
+ <v-dialog v-model="creating" max-width="900">
212
+ <v-card :title="t('record.newTitle', { label: noun(target.labelSingular) })">
213
+ <VxResourceForm v-if="creating" :resource="target" operation="create" :preset="preset" @saved="created" @cancel="creating = false" />
214
+ </v-card>
215
+ </v-dialog>
216
+ </v-card>
217
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField, VectorLayoutNode, VectorRecord, VectorResource } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ resource: VectorResource, record: VectorRecord }>()
5
+
6
+ const fields = computed<VectorField[]>(() => props.resource.show.fields
7
+ .map(name => props.resource.fields.find(f => f.name === name))
8
+ .filter((f): f is VectorField => f !== undefined))
9
+ const fieldsByName = computed(() => new Map(fields.value.map(f => [f.name, f])))
10
+
11
+ const wide = (field: VectorField) => ['markdown', 'richtext', 'text', 'json'].includes(field.type)
12
+
13
+ // The declared layout, or every field in order; either way VxLayout draws it.
14
+ const layout = computed<VectorLayoutNode[]>(() => props.resource.layout?.show
15
+ ?? fields.value.map(f => ({ kind: 'field', label: null, description: null, collapsible: false, collapsed: false, columns: null, span: null, name: f.name, children: null })))
16
+ const defaultSpan = (name: string) => {
17
+ const field = fieldsByName.value.get(name)
18
+ return field && wide(field) ? 'full' as const : 1 as const
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <v-card>
24
+ <v-card-text>
25
+ <VxLayout :nodes="layout" :shown="name => fieldsByName.has(name)" :default-span="defaultSpan">
26
+ <template #field="{ field: name }">
27
+ <div class="text-caption text-medium-emphasis">{{ fieldsByName.get(name)!.label }}</div>
28
+ <div class="text-body-1">
29
+ <VxCell :field="fieldsByName.get(name)!" :value="record[name]" :record="record" :resource="resource" context="show" />
30
+ </div>
31
+ </template>
32
+ </VxLayout>
33
+ </v-card-text>
34
+ </v-card>
35
+ </template>