@kinetis/vector-ui 0.0.0-stage → 0.9.1

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 +34 -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 +83 -5
@@ -0,0 +1,224 @@
1
+ <script setup lang="ts">
2
+ import { fieldProps, type VectorField, type VectorFilterGroup, type VectorFilterQuery, type VectorFilters, type VectorOperator, type VectorResource } from '#vector/app/types/vector'
3
+
4
+ // The active filters as chips, plus a menu to add one: a field, an
5
+ // operator it admits, a value shaped by the field. The conditions (the
6
+ // model, `{field: {op: value}}`) must all hold; each "any of" group holds
7
+ // alternatives, one of which must. A saved view's deeper nesting shows as
8
+ // one chip that can only be removed.
9
+ const props = defineProps<{ resource: VectorResource }>()
10
+ const model = defineModel<VectorFilters>({ required: true })
11
+ const groups = defineModel<VectorFilterGroup[]>('groups', { default: () => [] })
12
+ const advanced = defineModel<VectorFilterQuery[]>('advanced', { default: () => [] })
13
+
14
+ /** Where the menu's condition goes: the conditions, an alternative, a new alternative (-1), or a new group (-1). */
15
+ type Target = { group: number, alternative: number } | null
16
+ const target = ref<Target>(null)
17
+ const menuActivator = ref<HTMLElement | undefined>(undefined)
18
+
19
+ function openAdd(where: Target, event: Event) {
20
+ target.value = where
21
+ menuActivator.value = event.currentTarget as HTMLElement
22
+ fieldName.value = null
23
+ adding.value = true
24
+ }
25
+
26
+ const { t, locale } = useVectorT()
27
+
28
+ const OPERATORS: readonly VectorOperator[] = ['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin', 'like', 'nlike', 'between', 'null', 'nnull']
29
+
30
+ const operatorLabel = (op: string): string => (OPERATORS as readonly string[]).includes(op) ? t(`list.filter.operators.${op}`) : op
31
+
32
+ const filterable = computed(() => props.resource.fields.filter(f => f.operators.length > 0))
33
+
34
+ const adding = ref(false)
35
+ const fieldName = ref<string | null>(null)
36
+ const operator = ref<VectorOperator | null>(null)
37
+ const value = ref<string>('')
38
+ const valueTo = ref<string>('')
39
+ const client = useVectorResource(() => props.resource.slug)
40
+ const relationOptions = ref<{ id: number | string, label: string }[]>([])
41
+ const relationSearch = ref('')
42
+
43
+ const field = computed(() => filterable.value.find(f => f.name === fieldName.value) ?? null)
44
+
45
+ watch(field, (f) => {
46
+ operator.value = f?.operators[0] ?? null
47
+ value.value = ''
48
+ valueTo.value = ''
49
+ relationOptions.value = []
50
+
51
+ if (f?.type === 'belongsTo') searchRelation('')
52
+ })
53
+
54
+ const needsValue = computed(() => operator.value !== null && !['null', 'nnull'].includes(operator.value))
55
+ const list = computed(() => operator.value === 'in' || operator.value === 'nin')
56
+
57
+ const searchRelation = useDebounceFn(async (q: string) => {
58
+ if (!field.value) return
59
+ relationOptions.value = (await client.options(field.value.name, q)).data
60
+ }, 250)
61
+
62
+ watch(relationSearch, q => searchRelation(q ?? ''))
63
+
64
+ function inputType(f: VectorField): string {
65
+ if (f.type === 'date') return 'date'
66
+ if (f.type === 'datetime') return 'datetime-local'
67
+ if (['number', 'money', 'id'].includes(f.type)) return 'number'
68
+ return 'text'
69
+ }
70
+
71
+ /**
72
+ * A money filter compares minor units. A fixed currency says how many
73
+ * decimals the typed amount has; one priced per record has no single
74
+ * answer, so it is read in hundredths.
75
+ */
76
+ function moneyDigits(f: VectorField): number {
77
+ const currency = fieldProps(f).currency
78
+ return minorDigits(typeof currency === 'string' ? currency : null)
79
+ }
80
+
81
+ function add() {
82
+ if (!field.value || !operator.value) return
83
+
84
+ let v = value.value.trim()
85
+
86
+ if (field.value.type === 'money' && v !== '' && !list.value) {
87
+ v = String(Math.round(Number.parseFloat(v.replace(',', '.')) * 10 ** moneyDigits(field.value)))
88
+ }
89
+
90
+ // a datetime typed in the browser's zone goes over as UTC, each bound of a `between` alike
91
+ const utc = (typed: string) => field.value?.type === 'datetime' && typed.includes('T') ? new Date(typed).toISOString() : typed
92
+ if (operator.value === 'between') v = `${utc(value.value.trim())},${utc(valueTo.value.trim())}`
93
+ else if (field.value.type === 'datetime' && v !== '' && !list.value) v = utc(v)
94
+ if (needsValue.value && v === '') return
95
+
96
+ const name = field.value.name
97
+ const condition = { [operator.value]: needsValue.value ? v : '' }
98
+ const where = target.value
99
+
100
+ if (where === null) {
101
+ model.value = { ...model.value, [name]: { ...(model.value[name] ?? {}), ...condition } }
102
+ } else if (where.group === -1) {
103
+ groups.value = [...groups.value, [{ [name]: condition }]]
104
+ } else {
105
+ groups.value = groups.value.map((group, g) => {
106
+ if (g !== where.group) return group
107
+ if (where.alternative === -1) return [...group, { [name]: condition }]
108
+ return group.map((alternative, a) => (a === where.alternative ? { ...alternative, [name]: { ...(alternative[name] ?? {}), ...condition } } : alternative))
109
+ })
110
+ }
111
+
112
+ adding.value = false
113
+ fieldName.value = null
114
+ }
115
+
116
+ function removeFromGroup(g: number, a: number, name: string, op: string) {
117
+ groups.value = groups.value
118
+ .map((group, gi) => gi !== g
119
+ ? group
120
+ : group.map((alternative, ai) => {
121
+ if (ai !== a) return alternative
122
+ const next = { ...alternative }
123
+ const ops = { ...(next[name] ?? {}) }
124
+ delete ops[op as VectorOperator]
125
+ if (Object.keys(ops).length === 0) delete next[name]
126
+ else next[name] = ops
127
+ return next
128
+ }).filter(alternative => Object.keys(alternative).length > 0))
129
+ .filter(group => group.length > 0)
130
+ }
131
+
132
+ const chipsOf = (conditions: VectorFilters) => Object.entries(conditions).flatMap(([name, ops]) => Object.entries(ops).map(([op, v]) => ({ name, op, value: v ?? '' })))
133
+
134
+ function clearAll() {
135
+ model.value = {}
136
+ groups.value = []
137
+ advanced.value = []
138
+ }
139
+
140
+ function remove(name: string, op: string) {
141
+ const next = { ...model.value }
142
+ const ops = { ...(next[name] ?? {}) }
143
+ delete ops[op as VectorOperator]
144
+
145
+ if (Object.keys(ops).length === 0) delete next[name]
146
+ else next[name] = ops
147
+
148
+ model.value = next
149
+ }
150
+
151
+ function chipText(name: string, op: string, v: string): string {
152
+ const f = props.resource.fields.find(x => x.name === name)
153
+ const label = f?.label ?? name
154
+ const opLabel = operatorLabel(op)
155
+
156
+ if (!f || op === 'null' || op === 'nnull') return t('list.filter.chipNoValue', { field: label, operator: opLabel })
157
+
158
+ let shown = v
159
+
160
+ if (f.type === 'enum') shown = v.split(',').map(x => f.options?.find(o => String(o.value) === x)?.label ?? x).join(', ')
161
+ if (f.type === 'currency') shown = v.split(',').map(x => currencyLabel(x, locale.value)).join(', ')
162
+ if (f.type === 'boolean') shown = v === 'true' || v === '1' ? t('list.filter.yes') : t('list.filter.no')
163
+ if (f.type === 'money' && !['in', 'nin', 'between'].includes(op)) shown = (Number(v) / 10 ** moneyDigits(f)).toFixed(moneyDigits(f))
164
+ if (op === 'between') shown = v.replace(',', ` ${t('list.filter.and')} `)
165
+
166
+ return t('list.filter.chip', { field: label, operator: opLabel, value: shown })
167
+ }
168
+
169
+ const active = computed(() => chipsOf(model.value))
170
+ const anything = computed(() => active.value.length > 0 || groups.value.length > 0 || advanced.value.length > 0)
171
+ </script>
172
+
173
+ <template>
174
+ <div class="d-flex align-center flex-wrap ga-2">
175
+ <v-chip v-for="chip in active" :key="`${chip.name}.${chip.op}`" closable size="small" variant="tonal" color="primary" @click:close="remove(chip.name, chip.op)">
176
+ {{ chipText(chip.name, chip.op, chip.value) }}
177
+ </v-chip>
178
+ <div
179
+ v-for="(group, g) in groups"
180
+ :key="`group-${g}`"
181
+ class="d-inline-flex align-center flex-wrap ga-1 px-2 py-1 rounded border"
182
+ role="group"
183
+ :aria-label="t('list.filter.anyOfGroup', { n: g + 1 })"
184
+ >
185
+ <span class="text-caption text-medium-emphasis">{{ t('list.filter.anyOf') }}</span>
186
+ <template v-for="(alternative, a) in group" :key="`alternative-${g}-${a}`">
187
+ <span v-if="a > 0" class="text-caption font-weight-medium">{{ t('list.filter.or') }}</span>
188
+ <span class="d-inline-flex align-center ga-1" role="group" :aria-label="t('list.filter.alternative', { n: a + 1 })">
189
+ <v-chip v-for="chip in chipsOf(alternative)" :key="`${chip.name}.${chip.op}`" closable size="small" variant="tonal" color="primary" @click:close="removeFromGroup(g, a, chip.name, chip.op)">
190
+ {{ chipText(chip.name, chip.op, chip.value) }}
191
+ </v-chip>
192
+ <v-btn icon="mdi-plus" size="x-small" variant="text" :aria-label="t('list.filter.addToAlternative', { n: a + 1 })" @click="openAdd({ group: g, alternative: a }, $event)" />
193
+ </span>
194
+ </template>
195
+ <v-btn size="x-small" variant="text" prepend-icon="mdi-plus" @click="openAdd({ group: g, alternative: -1 }, $event)">{{ t('list.filter.orCondition') }}</v-btn>
196
+ <v-btn icon="mdi-close" size="x-small" variant="text" :aria-label="t('list.filter.removeGroup', { n: g + 1 })" @click="groups = groups.filter((_, i) => i !== g)" />
197
+ </div>
198
+ <v-chip v-for="(node, i) in advanced" :key="`advanced-${i}`" closable size="small" variant="outlined" prepend-icon="mdi-code-braces" :title="JSON.stringify(node)" @click:close="advanced = advanced.filter((_, j) => j !== i)">
199
+ {{ t('list.filter.advanced') }}
200
+ </v-chip>
201
+ <v-btn size="small" variant="text" prepend-icon="mdi-filter-variant-plus" :disabled="filterable.length === 0" @click="openAdd(null, $event)">{{ t('list.filter.add') }}</v-btn>
202
+ <v-btn size="small" variant="text" prepend-icon="mdi-set-merge" :disabled="filterable.length === 0" @click="openAdd({ group: -1, alternative: -1 }, $event)">{{ t('list.filter.addAnyOf') }}</v-btn>
203
+ <v-menu v-model="adding" :activator="menuActivator" :close-on-content-click="false" location="bottom start">
204
+ <v-card min-width="360" class="pa-4">
205
+ <v-select v-model="fieldName" :label="t('list.field')" :items="filterable" item-title="label" item-value="name" density="compact" hide-details class="mb-3" />
206
+ <v-select v-if="field" v-model="operator" :label="t('list.filter.condition')" :items="field.operators.map(op => ({ title: operatorLabel(op), value: op }))" density="compact" hide-details class="mb-3" />
207
+ <template v-if="field && needsValue">
208
+ <v-select v-if="field.type === 'enum' || field.type === 'currency'" v-model="value" :label="t('list.filter.value')" :items="field.type === 'currency' ? (field.options ?? []).map(o => ({ value: o.value, label: currencyLabel(String(o.value), locale) })) : (field.options ?? [])" item-title="label" item-value="value" density="compact" hide-details class="mb-3" :multiple="list" @update:model-value="(v: unknown) => value = Array.isArray(v) ? v.join(',') : String(v ?? '')" />
209
+ <v-select v-else-if="field.type === 'boolean'" v-model="value" :label="t('list.filter.value')" :items="[{ title: t('common.yes'), value: 'true' }, { title: t('common.no'), value: 'false' }]" density="compact" hide-details class="mb-3" />
210
+ <v-autocomplete v-else-if="field.type === 'belongsTo'" v-model:search="relationSearch" :label="t('list.filter.value')" :items="relationOptions" item-title="label" item-value="id" density="compact" hide-details class="mb-3" :multiple="list" no-filter @update:model-value="(v: unknown) => value = Array.isArray(v) ? v.join(',') : String(v ?? '')" />
211
+ <template v-else>
212
+ <v-text-field v-model="value" :label="operator === 'between' ? t('list.filter.from') : t('list.filter.value')" :type="list ? 'text' : inputType(field)" :hint="list ? t('list.filter.commaSeparated') : undefined" density="compact" hide-details class="mb-3" @keyup.enter="add" />
213
+ <v-text-field v-if="operator === 'between'" v-model="valueTo" :label="t('list.filter.to')" :type="inputType(field)" density="compact" hide-details class="mb-3" @keyup.enter="add" />
214
+ </template>
215
+ </template>
216
+ <div class="d-flex justify-end ga-2">
217
+ <v-btn variant="text" size="small" @click="adding = false">{{ t('common.cancel') }}</v-btn>
218
+ <v-btn color="primary" size="small" :disabled="!field || !operator" @click="add">{{ t('common.apply') }}</v-btn>
219
+ </div>
220
+ </v-card>
221
+ </v-menu>
222
+ <v-btn v-if="anything" size="small" variant="text" @click="clearAll">{{ t('list.filter.clear') }}</v-btn>
223
+ </div>
224
+ </template>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ import type { VectorListState, VectorResource, VectorSavedView } from '#vector/app/types/vector'
3
+
4
+ // Saved views: pick one, save the current list as a new one, update or
5
+ // delete your own, share, set your default — or, where the policy allows,
6
+ // the default of everyone who has none of their own.
7
+ const props = defineProps<{ resource: VectorResource, current: VectorListState, activeId: number | null }>()
8
+ const emit = defineEmits<{ apply: [view: VectorSavedView | null], 'update:activeId': [id: number | null] }>()
9
+
10
+ const api = useVectorViews(() => props.resource.slug)
11
+ const { t } = useVectorT()
12
+ const notifications = useVectorNotifications()
13
+ const { confirm } = useConfirm()
14
+ const views = ref<VectorSavedView[]>([])
15
+ const loading = ref(false)
16
+
17
+ async function load() {
18
+ loading.value = true
19
+ try {
20
+ views.value = await api.list()
21
+ } finally {
22
+ loading.value = false
23
+ }
24
+ }
25
+
26
+ defineExpose({ views, load })
27
+ await load()
28
+
29
+ const saving = ref(false)
30
+ const name = ref('')
31
+ const shared = ref(false)
32
+ const isDefault = ref(false)
33
+ const resourceDefault = ref(false)
34
+ const mayDefaultForEveryone = computed(() => props.resource.permissions.setDefaultView === true)
35
+ const editing = ref<VectorSavedView | null>(null)
36
+ watch(shared, (on) => { if (!on) resourceDefault.value = false })
37
+
38
+ function openSave(view: VectorSavedView | null) {
39
+ editing.value = view
40
+ name.value = view?.name ?? ''
41
+ shared.value = view?.shared ?? false
42
+ isDefault.value = view?.isDefault ?? false
43
+ resourceDefault.value = view?.resourceDefault ?? false
44
+ saving.value = true
45
+ }
46
+
47
+ async function save() {
48
+ if (name.value.trim() === '') return
49
+
50
+ try {
51
+ const view = editing.value
52
+ ? await api.update(editing.value.id, name.value.trim(), props.current, shared.value, isDefault.value, resourceDefault.value)
53
+ : await api.create(name.value.trim(), props.current, shared.value, isDefault.value, resourceDefault.value)
54
+ notifications.success(t('list.views.saved', { name: view.name }))
55
+ saving.value = false
56
+ await load()
57
+ emit('update:activeId', view.id)
58
+ } catch (error) {
59
+ notifications.error(vectorErrorMessage(error))
60
+ }
61
+ }
62
+
63
+ async function remove(view: VectorSavedView) {
64
+ if (!await confirm({ title: t('list.views.deleteTitle', { name: view.name }), confirmText: t('common.delete'), destructive: true })) return
65
+
66
+ await api.remove(view.id)
67
+ await load()
68
+
69
+ if (props.activeId === view.id) emit('apply', null)
70
+ }
71
+
72
+ const active = computed(() => views.value.find(v => v.id === props.activeId) ?? null)
73
+ </script>
74
+
75
+ <template>
76
+ <v-menu>
77
+ <template #activator="{ props: activator }">
78
+ <v-btn v-bind="activator" size="small" variant="tonal" prepend-icon="mdi-view-list-outline" append-icon="mdi-menu-down">
79
+ {{ active?.name ?? t('list.views.menu') }}
80
+ </v-btn>
81
+ </template>
82
+ <v-list density="compact" min-width="260">
83
+ <v-list-item :title="t('list.views.allRecords')" :active="activeId === null" prepend-icon="mdi-table" @click="emit('apply', null)" />
84
+ <v-divider v-if="views.length" />
85
+ <v-list-item v-for="view in views" :key="view.id" :title="view.name" :active="view.id === activeId" :prepend-icon="view.shared ? 'mdi-account-group-outline' : 'mdi-account-outline'" @click="emit('apply', view)">
86
+ <template #subtitle>
87
+ <span v-if="view.isDefault">{{ t('list.views.default') }}</span>
88
+ <span v-else-if="view.resourceDefault">{{ t('list.views.everyoneDefault') }}</span>
89
+ </template>
90
+ <template v-if="view.mine" #append>
91
+ <v-btn icon="mdi-pencil-outline" size="x-small" variant="text" :aria-label="t('list.views.updateWithCurrent')" @click.stop="openSave(view)" />
92
+ <v-btn icon="mdi-delete-outline" size="x-small" variant="text" :aria-label="t('list.views.delete')" @click.stop="remove(view)" />
93
+ </template>
94
+ </v-list-item>
95
+ <v-divider />
96
+ <v-list-item :title="t('list.views.saveCurrent')" prepend-icon="mdi-content-save-plus-outline" @click="openSave(null)" />
97
+ </v-list>
98
+ </v-menu>
99
+
100
+ <v-dialog v-model="saving" max-width="420">
101
+ <v-card :title="editing ? t('list.views.updateTitle', { name: editing.name }) : t('list.views.saveTitle')">
102
+ <v-card-text>
103
+ <v-text-field v-model="name" :label="t('list.views.name')" autofocus :rules="[(v: string) => v.trim() !== '' || t('common.required')]" @keyup.enter="save" />
104
+ <v-switch v-model="shared" :label="t('list.views.share')" color="primary" hide-details />
105
+ <v-switch v-model="isDefault" :label="t('list.views.openWith')" color="primary" hide-details />
106
+ <v-switch v-if="mayDefaultForEveryone" v-model="resourceDefault" :disabled="!shared" :label="t('list.views.openWithForEveryone')" :hint="shared ? undefined : t('list.views.shareFirst')" persistent-hint color="primary" />
107
+ </v-card-text>
108
+ <v-card-actions>
109
+ <v-spacer />
110
+ <v-btn variant="text" @click="saving = false">{{ t('common.cancel') }}</v-btn>
111
+ <v-btn color="primary" @click="save">{{ t('common.save') }}</v-btn>
112
+ </v-card-actions>
113
+ </v-card>
114
+ </v-dialog>
115
+ </template>
@@ -0,0 +1,9 @@
1
+ <script setup lang="ts">
2
+ // Transient toasts. Anything in the layer pushes through
3
+ // useVectorNotifications(); Vuetify's queue shows them one at a time.
4
+ const { queue } = useVectorNotifications()
5
+ </script>
6
+
7
+ <template>
8
+ <v-snackbar-queue v-model="queue" :timeout="4000" closable location="bottom right" />
9
+ </template>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import type { VectorAuditEntry, VectorResource } from '#vector/app/types/vector'
3
+
4
+ // The audit trail of one record: who changed what, when, as whom. An
5
+ // update (or a revert) can be reverted by whoever may edit the record.
6
+ const props = defineProps<{ resource: VectorResource, id: string | number, canUpdate?: boolean }>()
7
+ const emit = defineEmits<{ reverted: [] }>()
8
+ const { t } = useVectorT()
9
+ const { confirm } = useConfirm()
10
+ const notifications = useVectorNotifications()
11
+ const client = useVectorResource(() => props.resource.slug)
12
+ const { data, status, error, refresh } = await useAsyncData(() => `vector.audit.${props.resource.slug}.${props.id}`, () => client.audit(props.id))
13
+ const entries = computed<VectorAuditEntry[]>(() => data.value?.data ?? [])
14
+
15
+ const label = (name: string) => props.resource.fields.find(f => f.name === name)?.label ?? name
16
+ const show = (value: unknown) => value === null || value === undefined ? t('common.none') : typeof value === 'object' ? JSON.stringify(value) : String(value)
17
+ const icon: Record<string, string> = { create: 'mdi-plus-circle-outline', update: 'mdi-pencil-outline', revert: 'mdi-undo-variant', delete: 'mdi-delete-outline', restore: 'mdi-restore', forceDelete: 'mdi-delete-forever-outline' }
18
+
19
+ /** The fields of an entry a revert would put back: shown, and editable on update. */
20
+ function revertableFields(entry: VectorAuditEntry): string[] {
21
+ if (!props.canUpdate || !['update', 'revert'].includes(entry.event)) return []
22
+ return Object.keys(entry.changes).filter((name) => {
23
+ const field = props.resource.fields.find(f => f.name === name)
24
+ return field !== undefined && !field.readonly && field.views.includes('update') && !['id', 'hasOne', 'hasMany', 'manyToMany', 'password'].includes(field.type)
25
+ })
26
+ }
27
+
28
+ const reverting = ref<number | null>(null)
29
+
30
+ async function revert(entry: VectorAuditEntry) {
31
+ const fields = revertableFields(entry).map(label).join(', ')
32
+ if (!await confirm({ title: t('record.audit.revertTitle'), text: t('record.audit.revertText', { fields }), confirmText: t('record.audit.revert') })) return
33
+
34
+ reverting.value = entry.id
35
+ try {
36
+ await client.revert(props.id, entry.id)
37
+ notifications.success(t('record.audit.reverted'))
38
+ await refresh()
39
+ emit('reverted')
40
+ } catch (failure) {
41
+ notifications.error(vectorErrorMessage(failure))
42
+ } finally {
43
+ reverting.value = null
44
+ }
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <v-card class="mt-4">
50
+ <v-card-title class="text-subtitle-1">{{ t('record.audit.title') }}</v-card-title>
51
+ <v-card-text>
52
+ <v-skeleton-loader v-if="status === 'pending'" type="list-item-two-line" />
53
+ <v-alert v-else-if="error" type="error" variant="tonal" density="compact" :text="vectorErrorMessage(error)" />
54
+ <v-timeline v-else-if="entries.length" side="end" density="compact" align="start">
55
+ <v-timeline-item v-for="entry in entries" :key="entry.id" :icon="icon[entry.event] ?? 'mdi-play-circle-outline'" dot-color="primary" size="small">
56
+ <div class="text-body-2">
57
+ <strong>{{ entry.event }}</strong> {{ t('record.audit.byUser', { actor: entry.actorId }) }}<span v-if="entry.impersonatorId"> {{ t('record.audit.impersonatedBy', { impersonator: entry.impersonatorId }) }}</span>
58
+ <span class="text-medium-emphasis"> · {{ formatDateTime(entry.createdAt) }}</span>
59
+ <v-btn
60
+ v-if="revertableFields(entry).length"
61
+ size="x-small"
62
+ variant="text"
63
+ prepend-icon="mdi-undo-variant"
64
+ class="ms-2"
65
+ :loading="reverting === entry.id"
66
+ :aria-label="t('record.audit.revertLabel')"
67
+ @click="revert(entry)"
68
+ >{{ t('record.audit.revert') }}</v-btn>
69
+ </div>
70
+ <ul v-if="Object.keys(entry.changes).length" class="text-caption mt-1 ps-4">
71
+ <li v-for="(change, field) in entry.changes" :key="field">
72
+ <span class="text-medium-emphasis">{{ label(String(field)) }}:</span>
73
+ <template v-if="entry.event === 'create'"> {{ show(change) }}</template>
74
+ <template v-else> {{ show(change.from) }} → {{ show(change.to) }}</template>
75
+ </li>
76
+ </ul>
77
+ </v-timeline-item>
78
+ </v-timeline>
79
+ <p v-else class="text-medium-emphasis text-body-2">{{ t('record.audit.empty') }}</p>
80
+ </v-card-text>
81
+ </v-card>
82
+ </template>