@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.
- package/LICENSE +193 -0
- package/README.md +45 -0
- package/package.json +55 -0
- package/src/ViewRenderer.vue +47 -0
- package/src/composables/use-view-data.ts +53 -0
- package/src/index.ts +29 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/format.ts +31 -0
- package/src/lib/view-binding.ts +40 -0
- package/src/primitives/Badge.vue +34 -0
- package/src/primitives/Card.vue +11 -0
- package/src/primitives/RichTextField.vue +485 -0
- package/src/primitives/StateMessage.vue +29 -0
- package/src/renderers/CalendarView.vue +118 -0
- package/src/renderers/DashboardView.vue +73 -0
- package/src/renderers/DetailView.vue +79 -0
- package/src/renderers/FormView.vue +160 -0
- package/src/renderers/GalleryView.vue +72 -0
- package/src/renderers/KanbanView.vue +87 -0
- package/src/renderers/TableView.vue +117 -0
- package/src/styles.css +5 -0
|
@@ -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>
|