@bemaestro/views 0.0.2

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.
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { computed, toRef } from 'vue'
3
+ import type { ListParams, MaestroClient, RuntimeView } from '@bemaestro/sdk/runtime'
4
+ import type { JSONContent } from '@tiptap/core'
5
+ import { useViewData, type DataRecord } from '../composables/use-view-data.js'
6
+ import { formatValue, humanizeKey } from '../lib/format.js'
7
+ import Card from '../primitives/Card.vue'
8
+ import StateMessage from '../primitives/StateMessage.vue'
9
+ import RichTextField from '../primitives/RichTextField.vue'
10
+
11
+ const props = defineProps<{
12
+ view: RuntimeView
13
+ client: MaestroClient
14
+ /** Optional target record; when absent the first record is shown (ABAC-scoped
15
+ * client surfaces resolve to the caller's own single record). */
16
+ recordId?: string
17
+ }>()
18
+
19
+ interface FieldGroup {
20
+ label?: string
21
+ fields: string[]
22
+ }
23
+
24
+ const collectionKey = toRef(() => props.view.collectionKey)
25
+ const params = computed<ListParams>(() =>
26
+ props.recordId ? { filter: { id: props.recordId }, limit: 1 } : { limit: 1 },
27
+ )
28
+ const { rows, loading, error } = useViewData(props.client, { collectionKey, params })
29
+ const record = computed<DataRecord | undefined>(() => rows.value[0])
30
+
31
+ const groups = computed<FieldGroup[]>(() => {
32
+ const configured = (props.view.config as { fieldGroups?: FieldGroup[] })?.fieldGroups
33
+ if (configured?.length) return configured
34
+ const rec = record.value
35
+ if (!rec) return []
36
+ return [{ fields: Object.keys(rec).filter((k) => k !== 'id') }]
37
+ })
38
+
39
+ /** Structural check for a Tiptap doc (spec 19 US-19.2) — DetailView has no
40
+ * schema fetch of its own, so it recognizes the shape rather than the
41
+ * `ui.widget` flag FormView reads from `schema.getCollection`. */
42
+ function isRichTextDoc(value: unknown): value is JSONContent {
43
+ return (
44
+ typeof value === 'object' &&
45
+ value !== null &&
46
+ (value as JSONContent).type === 'doc' &&
47
+ Array.isArray((value as JSONContent).content)
48
+ )
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <StateMessage v-if="error" variant="error" title="Failed to load" :description="error.message" />
54
+ <StateMessage v-else-if="loading && !record" variant="loading" title="Loading…" />
55
+ <StateMessage v-else-if="!record" variant="empty" title="No record" />
56
+
57
+ <div v-else class="flex flex-col gap-4">
58
+ <Card v-for="(group, gi) in groups" :key="gi" class="p-5">
59
+ <h3 v-if="group.label" class="mb-3 text-sm font-semibold">{{ group.label }}</h3>
60
+ <dl class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2">
61
+ <div
62
+ v-for="field in group.fields"
63
+ :key="field"
64
+ class="flex flex-col gap-0.5"
65
+ :class="isRichTextDoc(record[field]) && 'sm:col-span-2'"
66
+ >
67
+ <dt class="text-xs text-muted-foreground">{{ humanizeKey(field) }}</dt>
68
+ <RichTextField
69
+ v-if="isRichTextDoc(record[field])"
70
+ :model-value="record[field] as JSONContent"
71
+ :client="client"
72
+ :editable="false"
73
+ />
74
+ <dd v-else class="text-sm">{{ formatValue(record[field]) }}</dd>
75
+ </div>
76
+ </dl>
77
+ </Card>
78
+ </div>
79
+ </template>
@@ -0,0 +1,160 @@
1
+ <script setup lang="ts">
2
+ import { computed, reactive, ref, watchEffect } from 'vue'
3
+ import type { MaestroClient, RuntimeView } from '@bemaestro/sdk/runtime'
4
+ import type { JSONContent } from '@tiptap/core'
5
+ import { humanizeKey } from '../lib/format.js'
6
+ import Card from '../primitives/Card.vue'
7
+ import StateMessage from '../primitives/StateMessage.vue'
8
+ import RichTextField from '../primitives/RichTextField.vue'
9
+
10
+ const props = defineProps<{ view: RuntimeView; client: MaestroClient }>()
11
+ const emit = defineEmits<{ submitted: [record: Record<string, unknown>] }>()
12
+
13
+ interface FormField {
14
+ key: string
15
+ type: string
16
+ required: boolean
17
+ options?: string[]
18
+ /** UI hints passed through the meta-model unchanged (spec 19 US-19.1/19.2):
19
+ * `widget: 'richtext'` renders a Tiptap doc+blocks editor instead of the
20
+ * generic input. */
21
+ ui?: Record<string, unknown>
22
+ }
23
+
24
+ function isRichText(field: FormField): boolean {
25
+ return field.ui?.widget === 'richtext'
26
+ }
27
+
28
+ function richTextValue(key: string): JSONContent | null {
29
+ const value = model[key]
30
+ return value && typeof value === 'object' ? (value as JSONContent) : null
31
+ }
32
+
33
+ function setRichTextValue(key: string, doc: JSONContent): void {
34
+ model[key] = doc
35
+ }
36
+
37
+ const fields = ref<FormField[]>([])
38
+ const loadError = ref<string | null>(null)
39
+ const submitting = ref(false)
40
+ const submitError = ref<string | null>(null)
41
+ const done = ref(false)
42
+ const model = reactive<Record<string, unknown>>({})
43
+
44
+ const submitAction = computed(
45
+ () => (props.view.config as { submitAction?: string })?.submitAction ?? 'create',
46
+ )
47
+
48
+ // Derive fields from config.fieldGroups when present, else from the collection
49
+ // schema (spec 17: form binds collection fields).
50
+ watchEffect(async () => {
51
+ const key = props.view.collectionKey
52
+ if (!key) {
53
+ loadError.value = 'form view is not bound to a collection'
54
+ return
55
+ }
56
+ const groups = (props.view.config as { fieldGroups?: { fields: string[] }[] })?.fieldGroups
57
+ try {
58
+ const detail = await props.client.schema.getCollection(key)
59
+ const byKey = new Map(detail.fields.map((f) => [f.key, f]))
60
+ const chosen = groups?.length ? groups.flatMap((g) => g.fields) : detail.fields.map((f) => f.key)
61
+ fields.value = chosen
62
+ .map((k) => byKey.get(k))
63
+ .filter((f): f is NonNullable<typeof f> => Boolean(f))
64
+ .filter((f) => !['id', 'created_at', 'updated_at', 'deleted_at'].includes(f.key))
65
+ .map((f) => ({
66
+ key: f.key,
67
+ type: f.type,
68
+ required: Boolean(f.required),
69
+ options: f.options,
70
+ ui: f.ui,
71
+ }))
72
+ loadError.value = null
73
+ } catch (err) {
74
+ loadError.value = err instanceof Error ? err.message : String(err)
75
+ }
76
+ })
77
+
78
+ function inputType(type: string): string {
79
+ if (type === 'integer' || type === 'decimal') return 'number'
80
+ if (type === 'date') return 'date'
81
+ if (type === 'datetime') return 'datetime-local'
82
+ if (type === 'boolean') return 'checkbox'
83
+ return 'text'
84
+ }
85
+
86
+ async function submit() {
87
+ const key = props.view.collectionKey
88
+ if (!key) return
89
+ submitting.value = true
90
+ submitError.value = null
91
+ try {
92
+ const created = await props.client.collection(key).create(model)
93
+ done.value = true
94
+ emit('submitted', created as Record<string, unknown>)
95
+ } catch (err) {
96
+ submitError.value = err instanceof Error ? err.message : String(err)
97
+ } finally {
98
+ submitting.value = false
99
+ }
100
+ }
101
+ </script>
102
+
103
+ <template>
104
+ <StateMessage v-if="loadError" variant="error" title="Misconfigured form" :description="loadError" />
105
+ <Card v-else class="p-5">
106
+ <form class="flex flex-col gap-4" @submit.prevent="submit">
107
+ <div v-for="field in fields" :key="field.key" class="flex flex-col gap-1">
108
+ <label :for="field.key" class="text-xs font-medium text-muted-foreground">
109
+ {{ humanizeKey(field.key) }}<span v-if="field.required" class="text-destructive"> *</span>
110
+ </label>
111
+
112
+ <select
113
+ v-if="field.options?.length"
114
+ :id="field.key"
115
+ v-model="model[field.key]"
116
+ class="h-9 rounded-md border bg-background px-3 text-sm"
117
+ :required="field.required"
118
+ >
119
+ <option value="" disabled>Select…</option>
120
+ <option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
121
+ </select>
122
+
123
+ <input
124
+ v-else-if="field.type === 'boolean'"
125
+ :id="field.key"
126
+ v-model="model[field.key]"
127
+ type="checkbox"
128
+ class="size-4"
129
+ />
130
+
131
+ <RichTextField
132
+ v-else-if="isRichText(field)"
133
+ :model-value="richTextValue(field.key)"
134
+ :client="client"
135
+ @update:model-value="setRichTextValue(field.key, $event)"
136
+ />
137
+
138
+ <input
139
+ v-else
140
+ :id="field.key"
141
+ v-model="model[field.key]"
142
+ :type="inputType(field.type)"
143
+ :required="field.required"
144
+ class="h-9 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
145
+ />
146
+ </div>
147
+
148
+ <p v-if="submitError" class="text-xs text-destructive">{{ submitError }}</p>
149
+ <p v-if="done" class="text-xs text-green-600">Saved.</p>
150
+
151
+ <button
152
+ type="submit"
153
+ class="inline-flex h-9 items-center justify-center self-start rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground disabled:opacity-50"
154
+ :disabled="submitting || fields.length === 0"
155
+ >
156
+ {{ submitAction === 'update' ? 'Save' : 'Create' }}
157
+ </button>
158
+ </form>
159
+ </Card>
160
+ </template>
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, toRef } from 'vue'
3
+ import type { ListParams, MaestroClient, RuntimeView } from '@bemaestro/sdk/runtime'
4
+ import { useViewData, type DataRecord } from '../composables/use-view-data.js'
5
+ import { checkViewBinding, configString } from '../lib/view-binding.js'
6
+ import { formatValue } from '../lib/format.js'
7
+ import StateMessage from '../primitives/StateMessage.vue'
8
+
9
+ const props = defineProps<{ view: RuntimeView; client: MaestroClient }>()
10
+
11
+ const binding = computed(() => checkViewBinding(props.view))
12
+ const imageField = computed(() => configString(props.view, 'imageField'))
13
+ const titleField = computed(() => configString(props.view, 'titleField'))
14
+ const subtitleField = computed(() => configString(props.view, 'subtitleField'))
15
+
16
+ const offset = ref(0)
17
+ const collectionKey = toRef(() => props.view.collectionKey)
18
+ const params = computed<ListParams>(() => ({ limit: 24, offset: offset.value, withTotal: 1 }))
19
+ const { rows, loading, error } = useViewData(props.client, { collectionKey, params })
20
+
21
+ function imageUrl(row: DataRecord): string | null {
22
+ const raw = imageField.value ? row[imageField.value] : null
23
+ if (!raw) return null
24
+ if (typeof raw === 'string') return raw
25
+ // File fields may serialize as { url } or { id, url }.
26
+ if (typeof raw === 'object' && 'url' in raw && typeof (raw as { url: unknown }).url === 'string') {
27
+ return (raw as { url: string }).url
28
+ }
29
+ return null
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <StateMessage
35
+ v-if="!binding.ok"
36
+ variant="error"
37
+ title="Misconfigured gallery"
38
+ :description="binding.reason"
39
+ />
40
+ <StateMessage v-else-if="error" variant="error" title="Failed to load" :description="error.message" />
41
+ <StateMessage v-else-if="loading && rows.length === 0" variant="loading" title="Loading…" />
42
+ <StateMessage v-else-if="rows.length === 0" variant="empty" title="No items" />
43
+
44
+ <div v-else class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
45
+ <figure
46
+ v-for="row in rows"
47
+ :key="String(row.id)"
48
+ class="overflow-hidden rounded-lg border bg-card"
49
+ >
50
+ <div class="aspect-square w-full bg-muted">
51
+ <img
52
+ v-if="imageUrl(row)"
53
+ :src="imageUrl(row)!"
54
+ :alt="titleField ? formatValue(row[titleField]) : ''"
55
+ class="size-full object-cover"
56
+ loading="lazy"
57
+ />
58
+ <div v-else class="flex size-full items-center justify-center text-xs text-muted-foreground">
59
+ No image
60
+ </div>
61
+ </div>
62
+ <figcaption class="p-3">
63
+ <p class="truncate text-sm font-medium">
64
+ {{ titleField ? formatValue(row[titleField]) : '—' }}
65
+ </p>
66
+ <p v-if="subtitleField" class="truncate text-xs text-muted-foreground">
67
+ {{ formatValue(row[subtitleField]) }}
68
+ </p>
69
+ </figcaption>
70
+ </figure>
71
+ </div>
72
+ </template>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, toRef, watchEffect } from 'vue'
3
+ import type { ListParams, MaestroClient, RuntimeView } from '@bemaestro/sdk/runtime'
4
+ import { useViewData, type DataRecord } from '../composables/use-view-data.js'
5
+ import { checkViewBinding, configString } from '../lib/view-binding.js'
6
+ import { formatValue, humanizeKey, recordLabel } from '../lib/format.js'
7
+ import Badge from '../primitives/Badge.vue'
8
+ import StateMessage from '../primitives/StateMessage.vue'
9
+
10
+ const props = defineProps<{ view: RuntimeView; client: MaestroClient }>()
11
+
12
+ const binding = computed(() => checkViewBinding(props.view))
13
+ const groupField = computed(() => configString(props.view, 'groupField'))
14
+ const titleField = computed(() => configString(props.view, 'titleField'))
15
+ const cardFields = computed<string[]>(
16
+ () => (props.view.config as { cardFields?: string[] })?.cardFields ?? [],
17
+ )
18
+
19
+ const collectionKey = toRef(() => props.view.collectionKey)
20
+ // A board page: bounded window, not the whole collection (spec 17 NFR).
21
+ const params = computed<ListParams>(() => ({ limit: 200, withTotal: 1 }))
22
+ const { rows, loading, error } = useViewData(props.client, { collectionKey, params })
23
+
24
+ // Fixed columns from the enum's options (so empty stages still show); fall back
25
+ // to the distinct values found in the data.
26
+ const enumOptions = ref<string[] | null>(null)
27
+ watchEffect(async () => {
28
+ const key = props.view.collectionKey
29
+ const field = groupField.value
30
+ if (!key || !field) return
31
+ try {
32
+ const detail = await props.client.schema.getCollection(key)
33
+ enumOptions.value = detail.fields.find((f) => f.key === field)?.options ?? null
34
+ } catch {
35
+ enumOptions.value = null
36
+ }
37
+ })
38
+
39
+ const columns = computed<string[]>(() => {
40
+ if (enumOptions.value?.length) return enumOptions.value
41
+ const field = groupField.value
42
+ if (!field) return []
43
+ return [...new Set(rows.value.map((r) => String(r[field] ?? '—')))]
44
+ })
45
+
46
+ function cardsFor(column: string): DataRecord[] {
47
+ const field = groupField.value
48
+ if (!field) return []
49
+ return rows.value.filter((r) => String(r[field] ?? '—') === column)
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <StateMessage
55
+ v-if="!binding.ok"
56
+ variant="error"
57
+ title="Misconfigured board"
58
+ :description="binding.reason"
59
+ />
60
+ <StateMessage v-else-if="error" variant="error" title="Failed to load" :description="error.message" />
61
+ <StateMessage v-else-if="loading && rows.length === 0" variant="loading" title="Loading…" />
62
+
63
+ <div v-else class="flex gap-4 overflow-x-auto pb-2">
64
+ <div v-for="column in columns" :key="column" class="flex w-72 shrink-0 flex-col gap-2">
65
+ <div class="flex items-center justify-between px-1">
66
+ <Badge :color="column">{{ humanizeKey(column) }}</Badge>
67
+ <span class="text-xs text-muted-foreground">{{ cardsFor(column).length }}</span>
68
+ </div>
69
+ <div class="flex flex-col gap-2">
70
+ <div
71
+ v-for="card in cardsFor(column)"
72
+ :key="String(card.id)"
73
+ class="rounded-lg border bg-card p-3 shadow-sm"
74
+ >
75
+ <p class="text-sm font-medium">{{ recordLabel(card, titleField) }}</p>
76
+ <dl v-if="cardFields.length" class="mt-1 space-y-0.5">
77
+ <div v-for="f in cardFields" :key="f" class="flex justify-between gap-2 text-xs">
78
+ <dt class="text-muted-foreground">{{ humanizeKey(f) }}</dt>
79
+ <dd class="truncate">{{ formatValue(card[f]) }}</dd>
80
+ </div>
81
+ </dl>
82
+ </div>
83
+ <p v-if="cardsFor(column).length === 0" class="px-1 text-xs text-muted-foreground">Empty</p>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </template>
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, toRef } from 'vue'
3
+ import type { ListParams, MaestroClient, RuntimeView } from '@bemaestro/sdk/runtime'
4
+ import { useViewData, type DataRecord } from '../composables/use-view-data.js'
5
+ import { formatValue, humanizeKey } from '../lib/format.js'
6
+ import StateMessage from '../primitives/StateMessage.vue'
7
+
8
+ const props = defineProps<{ view: RuntimeView; client: MaestroClient }>()
9
+
10
+ const PAGE_SIZE = 25
11
+ const offset = ref(0)
12
+ const search = ref('')
13
+
14
+ const collectionKey = toRef(() => props.view.collectionKey)
15
+ const params = computed<ListParams>(() => {
16
+ const configured = (props.view.config as { columns?: string[]; sort?: string; filter?: string }) ?? {}
17
+ return {
18
+ limit: PAGE_SIZE,
19
+ offset: offset.value,
20
+ withTotal: 1,
21
+ sort: configured.sort,
22
+ filter: configured.filter,
23
+ fields: configured.columns?.length ? configured.columns.join(',') : undefined,
24
+ search: search.value || undefined,
25
+ }
26
+ })
27
+
28
+ const { rows, total, loading, error } = useViewData(props.client, { collectionKey, params })
29
+
30
+ // Explicit columns from config, else inferred from the first row's keys.
31
+ const columns = computed<string[]>(() => {
32
+ const configured = (props.view.config as { columns?: string[] })?.columns
33
+ if (configured?.length) return configured
34
+ const first = rows.value[0]
35
+ return first ? Object.keys(first).filter((k) => k !== 'id') : []
36
+ })
37
+
38
+ const pageCount = computed(() =>
39
+ total.value != null ? Math.max(1, Math.ceil(total.value / PAGE_SIZE)) : 1,
40
+ )
41
+ const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
42
+
43
+ function cell(row: DataRecord, key: string) {
44
+ return formatValue(row[key])
45
+ }
46
+ function next() {
47
+ if (page.value < pageCount.value) offset.value += PAGE_SIZE
48
+ }
49
+ function prev() {
50
+ if (offset.value > 0) offset.value -= PAGE_SIZE
51
+ }
52
+ function onSearch(e: Event) {
53
+ offset.value = 0
54
+ search.value = (e.target as HTMLInputElement).value
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <div class="flex flex-col gap-3">
60
+ <input
61
+ class="h-9 w-full max-w-xs rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
62
+ type="search"
63
+ placeholder="Search…"
64
+ :value="search"
65
+ @input="onSearch"
66
+ />
67
+
68
+ <StateMessage v-if="error" variant="error" title="Failed to load" :description="error.message" />
69
+ <StateMessage v-else-if="loading && rows.length === 0" variant="loading" title="Loading…" />
70
+ <StateMessage
71
+ v-else-if="rows.length === 0"
72
+ variant="empty"
73
+ title="No records"
74
+ description="Nothing to show here yet."
75
+ />
76
+
77
+ <div v-else class="overflow-x-auto rounded-lg border">
78
+ <table class="w-full text-sm">
79
+ <thead class="border-b bg-muted/50 text-left text-muted-foreground">
80
+ <tr>
81
+ <th v-for="col in columns" :key="col" class="px-3 py-2 font-medium whitespace-nowrap">
82
+ {{ humanizeKey(col) }}
83
+ </th>
84
+ </tr>
85
+ </thead>
86
+ <tbody>
87
+ <tr v-for="row in rows" :key="String(row.id)" class="border-b last:border-0 hover:bg-muted/30">
88
+ <td v-for="col in columns" :key="col" class="px-3 py-2 whitespace-nowrap">
89
+ {{ cell(row, col) }}
90
+ </td>
91
+ </tr>
92
+ </tbody>
93
+ </table>
94
+ </div>
95
+
96
+ <div v-if="total != null" class="flex items-center justify-between text-xs text-muted-foreground">
97
+ <span>{{ total }} total</span>
98
+ <div class="flex items-center gap-2">
99
+ <button
100
+ class="rounded-md border px-2 py-1 disabled:opacity-40"
101
+ :disabled="offset === 0"
102
+ @click="prev"
103
+ >
104
+ Prev
105
+ </button>
106
+ <span>Page {{ page }} / {{ pageCount }}</span>
107
+ <button
108
+ class="rounded-md border px-2 py-1 disabled:opacity-40"
109
+ :disabled="page >= pageCount"
110
+ @click="next"
111
+ >
112
+ Next
113
+ </button>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </template>
package/src/styles.css ADDED
@@ -0,0 +1,5 @@
1
+ /*
2
+ * Consumers must expose this package's classes to their Tailwind build, e.g. in
3
+ * the app's main CSS: `@source '../../../packages/views/src';`
4
+ * This file is a placeholder for any package-owned, non-utility CSS.
5
+ */