@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,21 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
5
+ const model = defineModel<string | null>()
6
+ const type = computed(() => ({ email: 'email', url: 'url', password: 'password' } as Record<string, string>)[props.field.type] ?? 'text')
7
+ </script>
8
+
9
+ <template>
10
+ <v-text-field
11
+ v-model="model"
12
+ :label="field.label"
13
+ :type="type"
14
+ :rules="rules"
15
+ :error-messages="errorMessages"
16
+ :hint="field.help ?? undefined"
17
+ :persistent-hint="!!field.help"
18
+ :clearable="field.nullable === true"
19
+ :prepend-inner-icon="field.type === 'color' ? 'mdi-palette' : undefined"
20
+ />
21
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
5
+ const model = defineModel<string | null>()
6
+ </script>
7
+
8
+ <template>
9
+ <v-textarea
10
+ v-model="model"
11
+ :label="field.label"
12
+ :rules="rules"
13
+ :error-messages="errorMessages"
14
+ :hint="field.help ?? undefined"
15
+ :persistent-hint="!!field.help"
16
+ :class="{ 'font-monospace': props.field.type === 'json' }"
17
+ auto-grow
18
+ rows="3"
19
+ />
20
+ </template>
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField, VectorUploaded } from '#vector/app/types/vector'
3
+
4
+ // A file or image field: pick a file, it uploads at once, and the field
5
+ // takes the key the server issued. The server detects the format from the
6
+ // bytes and enforces the size; accept and the size here only guide the
7
+ // picker. Without upload support the key is edited as text.
8
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
9
+ const model = defineModel<string | null>()
10
+
11
+ const { t } = useVectorT()
12
+ const { schema, resource: find } = useVectorSchema()
13
+ const client = useVectorResource(() => props.resource ?? '')
14
+ const owner = computed(() => find(props.resource ?? ''))
15
+ // The rules read the field's value (the key), not the picked file.
16
+ const keyRules = computed(() => props.rules.map(rule => () => rule(model.value)))
17
+ const uploads = computed(() => schema.value?.capabilities.uploads === true)
18
+ const fieldProps = computed(() => (Array.isArray(props.field.props) ? {} : props.field.props) as { accept?: Record<string, string>, maxBytes?: number })
19
+ const accept = computed(() => Object.keys(fieldProps.value.accept ?? {}).join(','))
20
+ const formats = computed(() => [...new Set(Object.values(fieldProps.value.accept ?? {}))].join(', '))
21
+ const maxMb = computed(() => fieldProps.value.maxBytes ? Math.round(fieldProps.value.maxBytes / 100_000) / 10 : null)
22
+ const image = computed(() => props.field.type === 'image')
23
+
24
+ const uploading = ref(false)
25
+ const uploadError = ref<string | null>(null)
26
+ const picked = ref<VectorUploaded | null>(null)
27
+ const preview = ref<string | null>(null)
28
+ const stored = computed(() => typeof props.record?.[props.field.name] === 'string' ? props.record![props.field.name] as string : null)
29
+
30
+ async function choose(files: File | File[] | null) {
31
+ const file = Array.isArray(files) ? files[0] : files
32
+ if (!file) return
33
+ uploadError.value = null
34
+
35
+ if (fieldProps.value.maxBytes && file.size > fieldProps.value.maxBytes) {
36
+ uploadError.value = t('fields.upload.tooLarge', { size: maxMb.value })
37
+ return
38
+ }
39
+
40
+ uploading.value = true
41
+ try {
42
+ picked.value = await client.upload(props.field.name, file)
43
+ model.value = picked.value.key
44
+ preview.value = image.value ? await dataUrl(file) : null
45
+ } catch (error) {
46
+ const failure = error as { data?: { errors?: { message: string }[] } } | null
47
+ uploadError.value = failure?.data?.errors?.[0]?.message ? t('fields.upload.rejected', { message: failure.data.errors[0].message }) : vectorErrorMessage(error)
48
+ } finally {
49
+ uploading.value = false
50
+ }
51
+ }
52
+
53
+ function clear() {
54
+ model.value = null
55
+ picked.value = null
56
+ preview.value = null
57
+ }
58
+
59
+ </script>
60
+
61
+ <template>
62
+ <v-text-field
63
+ v-if="!uploads"
64
+ v-model="model"
65
+ :label="field.label"
66
+ :rules="rules"
67
+ :error-messages="errorMessages"
68
+ :hint="field.help ?? t('fields.upload.storageKey')"
69
+ persistent-hint
70
+ :clearable="field.nullable === true"
71
+ />
72
+ <div v-else class="d-flex align-center ga-3">
73
+ <v-img v-if="preview" :src="preview" width="56" height="56" cover class="rounded flex-grow-0" :alt="t('fields.upload.preview', { label: field.label })" />
74
+ <VxCell v-else-if="model && model === stored && record && owner" :field="field" :value="model" :record="record" :resource="owner" context="index" />
75
+ <v-file-input
76
+ :label="field.label"
77
+ :accept="accept || undefined"
78
+ :loading="uploading"
79
+ :rules="keyRules"
80
+ :error-messages="uploadError ? [uploadError, ...errorMessages] : errorMessages"
81
+ :hint="picked ? t('fields.upload.uploaded', { name: picked.name ?? t('fields.upload.theFile'), size: Math.max(1, Math.round(picked.size / 1000)) }) : (field.help ?? [formats, maxMb ? t('fields.upload.upTo', { size: maxMb }) : ''].filter(Boolean).join(' · '))"
82
+ persistent-hint
83
+ :prepend-icon="image ? 'mdi-image-outline' : 'mdi-paperclip'"
84
+ :clearable="false"
85
+ @update:model-value="choose"
86
+ />
87
+ <v-btn v-if="model && field.nullable === true" icon="mdi-close" variant="text" size="small" :aria-label="t('fields.upload.remove', { label: field.label })" @click="clear" />
88
+ </div>
89
+ </template>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import type { VectorLayoutNode } from '#vector/app/types/vector'
3
+
4
+ // A resource's layout (sections, tabs, column spans) around the fields
5
+ // the owner renders through the `field` slot: the record form's inputs,
6
+ // the show page's values. Layout only groups — it never decides what is
7
+ // edited or shown; `shown` does, per field. A section or tab none of
8
+ // whose fields is shown is not drawn. A field with an error opens the
9
+ // section holding it and activates its tab, which carries a badge.
10
+ const props = withDefaults(defineProps<{
11
+ nodes: VectorLayoutNode[]
12
+ /** The grid columns at this level. */
13
+ columns?: number
14
+ shown: (name: string) => boolean
15
+ /** Names of the fields holding an error. */
16
+ errors?: string[]
17
+ /** A field's width when its node does not say. */
18
+ defaultSpan: (name: string) => 1 | 2 | 'full'
19
+ }>(), { columns: 2, errors: () => [] })
20
+
21
+ // the slot prop is `field`, not `name`: on <slot>, `name` picks which slot renders
22
+ defineSlots<{ field(props: { field: string }): unknown }>()
23
+
24
+ function names(node: VectorLayoutNode): string[] {
25
+ return node.kind === 'field' ? [node.name!] : (node.children ?? []).flatMap(names)
26
+ }
27
+
28
+ const visible = (node: VectorLayoutNode) => names(node).some(name => props.shown(name))
29
+ const hasError = (node: VectorLayoutNode) => names(node).some(name => props.errors.includes(name))
30
+ const visibleNodes = computed(() => props.nodes.map((node, index) => ({ node, key: String(index) })).filter(({ node }) => visible(node)))
31
+
32
+ /** The md width of a field node on this level's grid. */
33
+ function width(node: VectorLayoutNode): number {
34
+ const span = node.span ?? props.defaultSpan(node.name!)
35
+ if (span === 'full') return 12
36
+ return Math.round((12 * Math.min(span, props.columns)) / props.columns)
37
+ }
38
+
39
+ // Sections open unless declared collapsed; tabs start on their first visible tab.
40
+ const open = reactive<Record<string, boolean>>(Object.fromEntries(props.nodes.map((node, i) => [String(i), !node.collapsed])))
41
+ const active = reactive<Record<string, string | undefined>>({})
42
+ const tabsOf = (node: VectorLayoutNode) => (node.children ?? []).filter(visible)
43
+
44
+ // An error inside a closed section opens it; one in another tab switches to it.
45
+ watch(() => props.errors, () => {
46
+ for (const { node, key } of visibleNodes.value) {
47
+ if (node.kind === 'section' && hasError(node)) open[key] = true
48
+
49
+ if (node.kind === 'tabs') {
50
+ const tabs = tabsOf(node)
51
+ const current = tabs.find(tab => tab.label === (active[key] ?? tabs[0]?.label))
52
+ const failing = tabs.find(hasError)
53
+ if (failing && (!current || !hasError(current))) active[key] = failing.label ?? undefined
54
+ }
55
+ }
56
+ }, { deep: true, immediate: true })
57
+ </script>
58
+
59
+ <template>
60
+ <v-row density="compact">
61
+ <template v-for="{ node, key } in visibleNodes" :key="key">
62
+ <v-col v-if="node.kind === 'field'" cols="12" :md="width(node)" :data-layout-field="node.name">
63
+ <slot name="field" :field="node.name!" />
64
+ </v-col>
65
+
66
+ <v-col v-else-if="node.kind === 'section'" cols="12" class="vx-layout-section" :data-layout-section="node.label">
67
+ <v-expansion-panels v-if="node.collapsible" :model-value="open[key] ? [0] : []" variant="accordion" @update:model-value="(v: unknown) => open[key] = Array.isArray(v) && v.length > 0">
68
+ <v-expansion-panel :value="0">
69
+ <v-expansion-panel-title>
70
+ <span class="font-weight-medium">{{ node.label }}</span>
71
+ <v-icon v-if="hasError(node)" icon="mdi-alert-circle" color="error" size="small" class="ms-2" />
72
+ </v-expansion-panel-title>
73
+ <v-expansion-panel-text eager>
74
+ <p v-if="node.description" class="text-body-2 text-medium-emphasis mb-4">{{ node.description }}</p>
75
+ <VxLayout :nodes="node.children ?? []" :columns="node.columns ?? 2" :shown="shown" :errors="errors" :default-span="defaultSpan">
76
+ <template #field="{ field }"><slot name="field" :field="field" /></template>
77
+ </VxLayout>
78
+ </v-expansion-panel-text>
79
+ </v-expansion-panel>
80
+ </v-expansion-panels>
81
+ <template v-else>
82
+ <div class="text-subtitle-1 font-weight-medium mt-2" :class="node.description ? 'mb-1' : 'mb-4'">{{ node.label }}</div>
83
+ <p v-if="node.description" class="text-body-2 text-medium-emphasis mb-4">{{ node.description }}</p>
84
+ <VxLayout :nodes="node.children ?? []" :columns="node.columns ?? 2" :shown="shown" :errors="errors" :default-span="defaultSpan">
85
+ <template #field="{ field }"><slot name="field" :field="field" /></template>
86
+ </VxLayout>
87
+ </template>
88
+ </v-col>
89
+
90
+ <v-col v-else-if="node.kind === 'tabs'" cols="12" class="vx-layout-tabs">
91
+ <div v-if="node.label" class="text-subtitle-1 font-weight-medium mt-2">{{ node.label }}</div>
92
+ <v-tabs :model-value="active[key] ?? tabsOf(node)[0]?.label ?? undefined" density="compact" color="primary" @update:model-value="(v: unknown) => active[key] = String(v)">
93
+ <v-tab v-for="tab in tabsOf(node)" :key="tab.label ?? ''" :value="tab.label" :data-layout-tab="tab.label">
94
+ {{ tab.label }}
95
+ <v-badge v-if="hasError(tab)" dot inline color="error" class="ms-1" />
96
+ </v-tab>
97
+ </v-tabs>
98
+ <v-divider class="mb-2" />
99
+ <!-- eager: an inactive tab's inputs stay mounted, so the form validates and keeps them -->
100
+ <v-tabs-window :model-value="active[key] ?? tabsOf(node)[0]?.label ?? undefined">
101
+ <!-- padded: the window clips its content, and an outlined field's label sits above its border -->
102
+ <v-tabs-window-item v-for="tab in tabsOf(node)" :key="tab.label ?? ''" :value="tab.label" eager class="pt-3">
103
+ <VxLayout :nodes="tab.children ?? []" :columns="tab.columns ?? 2" :shown="shown" :errors="errors" :default-span="defaultSpan">
104
+ <template #field="{ field }"><slot name="field" :field="field" /></template>
105
+ </VxLayout>
106
+ </v-tabs-window-item>
107
+ </v-tabs-window>
108
+ </v-col>
109
+ </template>
110
+ </v-row>
111
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import type { VectorAction, VectorResource } from '#vector/app/types/vector'
3
+
4
+ // What a selection can do: the built-in edit, delete, restore and
5
+ // force-delete of the resource and every index action. "All matching" widens the
6
+ // selection from the checked rows to every record the list matches.
7
+ defineProps<{
8
+ resource: VectorResource
9
+ selected: number
10
+ total: number
11
+ allMatching: boolean
12
+ trashedOnly: boolean
13
+ actions: VectorAction[]
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ 'update:allMatching': [value: boolean]
18
+ clear: []
19
+ operation: [op: 'delete' | 'restore' | 'forceDelete']
20
+ action: [action: VectorAction]
21
+ edit: []
22
+ }>()
23
+
24
+ const { t } = useVectorT()
25
+ </script>
26
+
27
+ <template>
28
+ <v-sheet color="primary" rounded class="d-flex align-center flex-wrap ga-2 px-4 py-2 mb-2">
29
+ <span class="font-weight-medium">{{ allMatching ? t('list.bulk.allMatching', { total }) : t('list.bulk.selected', { count: selected }) }}</span>
30
+ <v-btn v-if="!allMatching && total > selected" size="small" variant="outlined" @click="emit('update:allMatching', true)">{{ t('list.bulk.selectAllMatching', { total }) }}</v-btn>
31
+ <v-btn v-else-if="allMatching" size="small" variant="outlined" @click="emit('update:allMatching', false)">{{ t('list.bulk.onlyChecked') }}</v-btn>
32
+ <v-spacer />
33
+ <template v-if="resource.softDelete && trashedOnly">
34
+ <v-btn size="small" variant="flat" color="white" prepend-icon="mdi-restore" @click="emit('operation', 'restore')">{{ t('common.restore') }}</v-btn>
35
+ <v-btn size="small" variant="flat" color="error" prepend-icon="mdi-delete-forever-outline" @click="emit('operation', 'forceDelete')">{{ t('common.deleteForever') }}</v-btn>
36
+ </template>
37
+ <template v-else>
38
+ <v-btn v-if="resource.permissions.update" size="small" variant="flat" color="white" prepend-icon="mdi-pencil-box-multiple-outline" @click="emit('edit')">{{ t('common.edit') }}</v-btn>
39
+ <v-btn v-if="resource.permissions.delete" size="small" variant="flat" color="white" prepend-icon="mdi-delete-outline" @click="emit('operation', 'delete')">{{ t('common.delete') }}</v-btn>
40
+ </template>
41
+ <v-btn v-for="action in actions" :key="action.name" size="small" variant="flat" color="white" :prepend-icon="action.icon ?? undefined" @click="emit('action', action)">{{ action.label }}</v-btn>
42
+ <v-btn size="small" variant="text" icon="mdi-close" :aria-label="t('list.bulk.clearSelection')" @click="emit('clear')" />
43
+ </v-sheet>
44
+ </template>
@@ -0,0 +1,91 @@
1
+ <script setup lang="ts">
2
+ import type { SubmitEventPromise } from 'vuetify'
3
+ import type { VectorResource, VectorSelection } from '#vector/app/types/vector'
4
+
5
+ // Writes the same values to every selected record: the user picks which
6
+ // fields to change, fills them in, and the server validates once and
7
+ // writes all records in one transaction, or none.
8
+ const props = defineProps<{
9
+ resource: VectorResource
10
+ selection: VectorSelection
11
+ count: number
12
+ }>()
13
+
14
+ const emit = defineEmits<{ done: [affected: number], cancel: [] }>()
15
+
16
+ const { t, noun } = useVectorT()
17
+ const notifications = useVectorNotifications()
18
+ const client = useVectorResource(() => props.resource.slug)
19
+ const { editable } = useVectorInputs()
20
+
21
+ const editableFields = computed(() => props.resource.fields.filter(f => editable(f, 'update') && f.type !== 'password' && f.type !== 'hasMany'))
22
+ const chosen = ref<string[]>([])
23
+ const fields = computed(() => editableFields.value.filter(f => chosen.value.includes(f.name)))
24
+ const values = reactive<Record<string, unknown>>({})
25
+ const serverErrors = reactive<Record<string, string[]>>({})
26
+ const formError = ref<string | null>(null)
27
+ const running = ref(false)
28
+
29
+ watch(chosen, (names) => {
30
+ for (const field of editableFields.value) {
31
+ if (names.includes(field.name) && !(field.name in values)) values[field.name] = initialValue(field)
32
+ if (!names.includes(field.name)) delete values[field.name]
33
+ }
34
+ })
35
+
36
+ const subject = computed(() => props.selection.all
37
+ ? t('list.bulkEdit.everyMatching', { noun: noun(props.resource.labelSingular), count: props.count })
38
+ : t('list.bulkEdit.some', { count: props.count, noun: noun(props.count === 1 ? props.resource.labelSingular : props.resource.label) }))
39
+
40
+ async function save(event: SubmitEventPromise) {
41
+ const { valid } = await event
42
+ if (!valid || !chosen.value.length) return
43
+
44
+ running.value = true
45
+ formError.value = null
46
+ for (const key of Object.keys(serverErrors)) delete serverErrors[key]
47
+
48
+ try {
49
+ const { affected } = await client.batch('update', props.selection, { ...values })
50
+ notifications.success(t('list.bulkEdit.updated', { count: affected, noun: noun(affected === 1 ? props.resource.labelSingular : props.resource.label) }))
51
+ emit('done', affected)
52
+ } catch (error) {
53
+ formError.value = applyViolations(error, values, serverErrors)
54
+ } finally {
55
+ running.value = false
56
+ }
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <v-dialog :model-value="true" max-width="640" persistent @update:model-value="emit('cancel')">
62
+ <v-form validate-on="blur lazy" @submit.prevent="save">
63
+ <v-card>
64
+ <v-card-title class="d-flex align-center ga-2">
65
+ <v-icon icon="mdi-pencil-box-multiple-outline" />
66
+ {{ t('list.bulkEdit.title', { subject }) }}
67
+ </v-card-title>
68
+ <v-card-text>
69
+ <v-alert v-if="formError" type="error" variant="tonal" class="mb-4" :text="formError" />
70
+ <v-select
71
+ v-model="chosen"
72
+ :items="editableFields.map(f => ({ title: f.label, value: f.name }))"
73
+ :label="t('list.bulkEdit.fieldsToChange')"
74
+ multiple
75
+ chips
76
+ closable-chips
77
+ :hint="t('list.bulkEdit.fieldsHint')"
78
+ persistent-hint
79
+ class="mb-4"
80
+ />
81
+ <VxFieldsForm v-if="fields.length" :fields="fields" :values="values" :server-errors="serverErrors" :resource="resource.slug" @clear="(name: string) => delete serverErrors[name]" />
82
+ </v-card-text>
83
+ <v-card-actions>
84
+ <v-spacer />
85
+ <v-btn variant="text" :disabled="running" @click="emit('cancel')">{{ t('common.cancel') }}</v-btn>
86
+ <v-btn type="submit" color="primary" :loading="running" :disabled="!chosen.length">{{ t('list.bulkEdit.applyTo', { count }) }}</v-btn>
87
+ </v-card-actions>
88
+ </v-card>
89
+ </v-form>
90
+ </v-dialog>
91
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import type { VectorResource } from '#vector/app/types/vector'
3
+
4
+ // Which index columns show, and in what order. Persisted per user and
5
+ // resource through the preferences store.
6
+ const props = defineProps<{ resource: VectorResource }>()
7
+ const model = defineModel<string[]>({ required: true })
8
+
9
+ const { t } = useVectorT()
10
+ const open = ref(false)
11
+ const draft = ref<string[]>([])
12
+ const available = computed(() => props.resource.table.columns)
13
+
14
+ function start() {
15
+ draft.value = [...model.value.filter(c => available.value.includes(c)), ...available.value.filter(c => !model.value.includes(c))]
16
+ open.value = true
17
+ }
18
+
19
+ const visible = (name: string) => model.value.includes(name)
20
+ const shown = ref<Record<string, boolean>>({})
21
+
22
+ watch(open, (o) => {
23
+ if (o) shown.value = Object.fromEntries(available.value.map(c => [c, visible(c)]))
24
+ })
25
+
26
+ function move(index: number, delta: number) {
27
+ const target = index + delta
28
+ if (target < 0 || target >= draft.value.length) return
29
+ const next = [...draft.value]
30
+ const [item] = next.splice(index, 1)
31
+ next.splice(target, 0, item!)
32
+ draft.value = next
33
+ }
34
+
35
+ function apply() {
36
+ const chosen = draft.value.filter(c => shown.value[c])
37
+ model.value = chosen.length ? chosen : [available.value[0]!]
38
+ open.value = false
39
+ }
40
+
41
+ function reset() {
42
+ model.value = [...available.value]
43
+ open.value = false
44
+ }
45
+
46
+ const label = (name: string) => props.resource.fields.find(f => f.name === name)?.label ?? name
47
+ </script>
48
+
49
+ <template>
50
+ <v-btn size="small" variant="text" icon="mdi-table-column-plus-after" :aria-label="t('list.columns.title')" @click="start" />
51
+ <v-dialog v-model="open" max-width="420">
52
+ <v-card :title="t('list.columns.title')">
53
+ <v-card-text>
54
+ <v-list density="compact">
55
+ <v-list-item v-for="(name, index) in draft" :key="name" :title="label(name)">
56
+ <template #prepend>
57
+ <v-checkbox-btn v-model="shown[name]" />
58
+ </template>
59
+ <template #append>
60
+ <v-btn icon="mdi-chevron-up" size="x-small" variant="text" :disabled="index === 0" :aria-label="t('list.columns.moveUp')" @click="move(index, -1)" />
61
+ <v-btn icon="mdi-chevron-down" size="x-small" variant="text" :disabled="index === draft.length - 1" :aria-label="t('list.columns.moveDown')" @click="move(index, 1)" />
62
+ </template>
63
+ </v-list-item>
64
+ </v-list>
65
+ </v-card-text>
66
+ <v-card-actions>
67
+ <v-btn variant="text" @click="reset">{{ t('list.columns.reset') }}</v-btn>
68
+ <v-spacer />
69
+ <v-btn variant="text" @click="open = false">{{ t('common.cancel') }}</v-btn>
70
+ <v-btn color="primary" @click="apply">{{ t('common.apply') }}</v-btn>
71
+ </v-card-actions>
72
+ </v-card>
73
+ </v-dialog>
74
+ </template>