@pienter/ui 0.5.0 → 0.7.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.
- package/CHANGELOG.md +58 -0
- package/CONVENTIONS.md +45 -0
- package/README.md +30 -0
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/form/block-editor/BlockEditor.vue +454 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/combobox/Combobox.vue +21 -34
- package/components/form/number-field/NumberField.vue +0 -1
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/text-input/text-input.css +2 -0
- package/components/layout/index/Index.vue +353 -0
- package/components/layout/index/index.css +114 -0
- package/components/layout/index/useIndex.ts +390 -0
- package/components/layout/table/table.css +2 -1
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +15 -0
- package/components/navigation/sidebar/Sidebar.vue +11 -8
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/composables/useMenu.ts +20 -27
- package/package.json +12 -2
- package/styles/0-settings/colors.css +10 -0
- package/utils/a11y/focus.ts +9 -3
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
|
@@ -146,7 +146,6 @@ function handleStep(direction: 'decrement' | 'increment'): void {
|
|
|
146
146
|
// listeners observe stepper clicks the same as typed edits.
|
|
147
147
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
148
148
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
149
|
-
emit('update:modelValue', input.valueAsNumber);
|
|
150
149
|
}
|
|
151
150
|
</script>
|
|
152
151
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export type { RecordFormField } from './types.js';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts" generic="T extends object">
|
|
6
|
+
import { computed } from 'vue';
|
|
7
|
+
import TextInput from '../text-input/TextInput.vue';
|
|
8
|
+
import PuiTextarea from '../textarea/Textarea.vue';
|
|
9
|
+
import PuiSelect from '../select/Select.vue';
|
|
10
|
+
import Checkbox from '../checkbox/Checkbox.vue';
|
|
11
|
+
import {
|
|
12
|
+
readPath,
|
|
13
|
+
updatePath,
|
|
14
|
+
type ValidationIssue,
|
|
15
|
+
type ValuePath,
|
|
16
|
+
} from '../../../utils/cms/index.js';
|
|
17
|
+
import type { RecordFormField } from './types.js';
|
|
18
|
+
import { pathFor, messagesFor } from './fields.js';
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(
|
|
21
|
+
defineProps<{
|
|
22
|
+
modelValue: T;
|
|
23
|
+
fields: readonly RecordFormField[];
|
|
24
|
+
issues?: readonly ValidationIssue[];
|
|
25
|
+
busy?: boolean;
|
|
26
|
+
}>(),
|
|
27
|
+
{ issues: () => [], busy: false },
|
|
28
|
+
);
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
'update:modelValue': [value: T];
|
|
31
|
+
'field-change': [path: ValuePath];
|
|
32
|
+
}>();
|
|
33
|
+
const fieldsWithErrors = computed(() =>
|
|
34
|
+
props.fields.map((field) => ({
|
|
35
|
+
field,
|
|
36
|
+
errors: messagesFor(field, props.issues),
|
|
37
|
+
})),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function valueFor(field: RecordFormField): unknown {
|
|
41
|
+
return readPath(props.modelValue, pathFor(field));
|
|
42
|
+
}
|
|
43
|
+
function textFor(field: RecordFormField): string {
|
|
44
|
+
const value = valueFor(field);
|
|
45
|
+
return typeof value === 'string' ? value : '';
|
|
46
|
+
}
|
|
47
|
+
function update(field: RecordFormField, value: unknown): void {
|
|
48
|
+
if (props.busy || field.disabled) return;
|
|
49
|
+
const path = pathFor(field);
|
|
50
|
+
emit('update:modelValue', updatePath(props.modelValue, path, value));
|
|
51
|
+
emit('field-change', path);
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<div class="pui-record-fields">
|
|
57
|
+
<template
|
|
58
|
+
v-for="{ field, errors } in fieldsWithErrors"
|
|
59
|
+
:key="field.name"
|
|
60
|
+
>
|
|
61
|
+
<slot name="before-field" :field="field" />
|
|
62
|
+
<slot
|
|
63
|
+
:name="`field:${field.name}`"
|
|
64
|
+
:field="field"
|
|
65
|
+
:value="valueFor(field)"
|
|
66
|
+
:update="(value: unknown) => update(field, value)"
|
|
67
|
+
:errors="errors"
|
|
68
|
+
:disabled="busy || !!field.disabled"
|
|
69
|
+
>
|
|
70
|
+
<PuiTextarea
|
|
71
|
+
v-if="field.type === 'textarea'"
|
|
72
|
+
:label="field.label"
|
|
73
|
+
:name="JSON.stringify(pathFor(field))"
|
|
74
|
+
:model-value="textFor(field)"
|
|
75
|
+
:hint="field.hint"
|
|
76
|
+
:required="field.required"
|
|
77
|
+
:disabled="busy || field.disabled"
|
|
78
|
+
:placeholder="field.placeholder"
|
|
79
|
+
:rows="field.rows"
|
|
80
|
+
:errors="errors"
|
|
81
|
+
@update:model-value="update(field, $event)"
|
|
82
|
+
/>
|
|
83
|
+
<PuiSelect
|
|
84
|
+
v-else-if="field.type === 'select'"
|
|
85
|
+
:label="field.label"
|
|
86
|
+
:name="JSON.stringify(pathFor(field))"
|
|
87
|
+
:model-value="textFor(field)"
|
|
88
|
+
:hint="field.hint"
|
|
89
|
+
:required="field.required"
|
|
90
|
+
:disabled="busy || field.disabled"
|
|
91
|
+
:placeholder="field.placeholder"
|
|
92
|
+
:options="field.options"
|
|
93
|
+
:errors="errors"
|
|
94
|
+
@update:model-value="update(field, $event)"
|
|
95
|
+
/>
|
|
96
|
+
<Checkbox
|
|
97
|
+
v-else-if="field.type === 'checkbox'"
|
|
98
|
+
:label="field.label"
|
|
99
|
+
:name="JSON.stringify(pathFor(field))"
|
|
100
|
+
:model-value="valueFor(field) === true"
|
|
101
|
+
:hint="field.hint"
|
|
102
|
+
:required="field.required"
|
|
103
|
+
:disabled="busy || field.disabled"
|
|
104
|
+
:errors="errors"
|
|
105
|
+
@update:model-value="update(field, $event)"
|
|
106
|
+
/>
|
|
107
|
+
<TextInput
|
|
108
|
+
v-else-if="field.type !== 'custom'"
|
|
109
|
+
:label="field.label"
|
|
110
|
+
:name="JSON.stringify(pathFor(field))"
|
|
111
|
+
:model-value="textFor(field)"
|
|
112
|
+
:type="field.type ?? 'text'"
|
|
113
|
+
:hint="field.hint"
|
|
114
|
+
:required="field.required"
|
|
115
|
+
:disabled="busy || field.disabled"
|
|
116
|
+
:placeholder="field.placeholder"
|
|
117
|
+
:autocomplete="field.autocomplete"
|
|
118
|
+
:errors="errors"
|
|
119
|
+
@update:model-value="update(field, $event)"
|
|
120
|
+
/>
|
|
121
|
+
</slot>
|
|
122
|
+
</template>
|
|
123
|
+
</div>
|
|
124
|
+
</template>
|
|
125
|
+
|
|
126
|
+
<style>
|
|
127
|
+
@import './record-form.css';
|
|
128
|
+
</style>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export type { RecordFormField } from './types.js';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts" generic="T extends object">
|
|
6
|
+
import { computed } from 'vue';
|
|
7
|
+
import Button from '../../action/button/Button.vue';
|
|
8
|
+
import Form from '../form/Form.vue';
|
|
9
|
+
import RecordFields from './RecordFields.vue';
|
|
10
|
+
import {
|
|
11
|
+
cloneRecord,
|
|
12
|
+
type ValidationIssue,
|
|
13
|
+
type ValuePath,
|
|
14
|
+
} from '../../../utils/cms/index.js';
|
|
15
|
+
import { pathFor, containsPath, messagesFor } from './fields.js';
|
|
16
|
+
import type { RecordFormField } from './types.js';
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(
|
|
19
|
+
defineProps<{
|
|
20
|
+
/** Caller-owned write payload; every supplied property is retained. */
|
|
21
|
+
modelValue: T;
|
|
22
|
+
fields: readonly RecordFormField[];
|
|
23
|
+
issues?: readonly ValidationIssue[];
|
|
24
|
+
errors?: string[];
|
|
25
|
+
busy?: boolean;
|
|
26
|
+
submitLabel?: string;
|
|
27
|
+
statusMessage?: string;
|
|
28
|
+
}>(),
|
|
29
|
+
{
|
|
30
|
+
issues: () => [],
|
|
31
|
+
errors: () => [],
|
|
32
|
+
busy: false,
|
|
33
|
+
submitLabel: 'Save changes',
|
|
34
|
+
statusMessage: undefined,
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
'update:modelValue': [value: T];
|
|
40
|
+
'field-change': [path: ValuePath];
|
|
41
|
+
submit: [value: T];
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const fieldErrors = computed(() =>
|
|
45
|
+
Object.fromEntries(
|
|
46
|
+
props.fields.map((field) => [
|
|
47
|
+
JSON.stringify(pathFor(field)),
|
|
48
|
+
messagesFor(field, props.issues),
|
|
49
|
+
]),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const summary = computed(() => [
|
|
54
|
+
...props.errors,
|
|
55
|
+
...props.issues.flatMap((issue) => {
|
|
56
|
+
const field = props.fields.find((field) =>
|
|
57
|
+
containsPath(pathFor(field), issue.path),
|
|
58
|
+
);
|
|
59
|
+
const label = field?.label ?? issue.path.join(' / ');
|
|
60
|
+
return issue.messages.map((message) =>
|
|
61
|
+
label ? `${label}: ${message}` : message,
|
|
62
|
+
);
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
function submit(): void {
|
|
67
|
+
if (!props.busy) emit('submit', cloneRecord(props.modelValue));
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<Form
|
|
73
|
+
class="pui-record-form"
|
|
74
|
+
:busy="busy"
|
|
75
|
+
:errors="summary"
|
|
76
|
+
:field-errors="fieldErrors"
|
|
77
|
+
:status-message="statusMessage"
|
|
78
|
+
@submit="submit"
|
|
79
|
+
>
|
|
80
|
+
<slot name="fields">
|
|
81
|
+
<RecordFields
|
|
82
|
+
class="pui-record-form__fields"
|
|
83
|
+
:model-value="modelValue"
|
|
84
|
+
:fields="fields"
|
|
85
|
+
:issues="issues"
|
|
86
|
+
:busy="busy"
|
|
87
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
88
|
+
@field-change="emit('field-change', $event)"
|
|
89
|
+
>
|
|
90
|
+
<template
|
|
91
|
+
v-for="name in Object.keys($slots).filter(
|
|
92
|
+
(name) =>
|
|
93
|
+
name === 'before-field' ||
|
|
94
|
+
name.startsWith('field:'),
|
|
95
|
+
)"
|
|
96
|
+
:key="name"
|
|
97
|
+
#[name]="scope"
|
|
98
|
+
>
|
|
99
|
+
<slot :name="name" v-bind="scope" />
|
|
100
|
+
</template>
|
|
101
|
+
</RecordFields>
|
|
102
|
+
</slot>
|
|
103
|
+
<slot />
|
|
104
|
+
<template #actions>
|
|
105
|
+
<slot name="actions" :busy="busy">
|
|
106
|
+
<Button type="submit" variant="primary" :loading="busy">
|
|
107
|
+
{{ submitLabel }}
|
|
108
|
+
</Button>
|
|
109
|
+
</slot>
|
|
110
|
+
</template>
|
|
111
|
+
</Form>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
@import './record-form.css';
|
|
116
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ValidationIssue, ValuePath } from '../../../utils/cms/index.js';
|
|
2
|
+
import type { RecordFormField } from './types.js';
|
|
3
|
+
|
|
4
|
+
export function pathFor(field: RecordFormField): ValuePath {
|
|
5
|
+
return field.path ?? [field.name];
|
|
6
|
+
}
|
|
7
|
+
export function containsPath(parent: ValuePath, child: ValuePath): boolean {
|
|
8
|
+
return (
|
|
9
|
+
parent.length <= child.length &&
|
|
10
|
+
parent.every((part, index) => part === child[index])
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
export function messagesFor(
|
|
14
|
+
field: RecordFormField,
|
|
15
|
+
issues: readonly ValidationIssue[],
|
|
16
|
+
): string[] {
|
|
17
|
+
return issues
|
|
18
|
+
.filter((issue) => containsPath(pathFor(field), issue.path))
|
|
19
|
+
.flatMap((issue) => issue.messages);
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pui-record-form {
|
|
3
|
+
gap: var(--space-s);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.pui-record-fields {
|
|
7
|
+
display: grid;
|
|
8
|
+
grid-template-columns: var(
|
|
9
|
+
--pui-record-fields-columns,
|
|
10
|
+
var(--pui-record-form-columns, minmax(0, 1fr))
|
|
11
|
+
);
|
|
12
|
+
gap: var(--space-s);
|
|
13
|
+
min-inline-size: 0;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ValuePath } from '../../../utils/cms/index.js';
|
|
2
|
+
|
|
3
|
+
interface RecordFieldBase {
|
|
4
|
+
name: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** Defaults to the literal property name, without splitting dots. */
|
|
7
|
+
path?: ValuePath;
|
|
8
|
+
hint?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RecordFormField = RecordFieldBase &
|
|
14
|
+
(
|
|
15
|
+
| {
|
|
16
|
+
type?: 'text' | 'email';
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
autocomplete?: string;
|
|
19
|
+
}
|
|
20
|
+
| { type: 'textarea'; placeholder?: string; rows?: number }
|
|
21
|
+
| {
|
|
22
|
+
type: 'select';
|
|
23
|
+
options: { value: string; label: string }[];
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
}
|
|
26
|
+
| { type: 'checkbox' }
|
|
27
|
+
| { type: 'custom' }
|
|
28
|
+
);
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="pui-index" :data-state="loading ? 'loading' : undefined">
|
|
3
|
+
<header
|
|
4
|
+
v-if="title || description || $slots.actions"
|
|
5
|
+
class="pui-index__header"
|
|
6
|
+
>
|
|
7
|
+
<div v-if="title || description" class="pui-index__heading">
|
|
8
|
+
<h1 v-if="title" class="pui-index__title">{{ title }}</h1>
|
|
9
|
+
<p v-if="description" class="pui-index__description">
|
|
10
|
+
{{ description }}
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
<div v-if="$slots.actions" class="pui-index__actions">
|
|
14
|
+
<slot name="actions" :reload="reload" :loading="loading" />
|
|
15
|
+
</div>
|
|
16
|
+
</header>
|
|
17
|
+
|
|
18
|
+
<div class="pui-index__body">
|
|
19
|
+
<div
|
|
20
|
+
v-if="
|
|
21
|
+
!invalidQuery &&
|
|
22
|
+
(searchable || $slots.filters || queryOptions.sorts.length)
|
|
23
|
+
"
|
|
24
|
+
class="pui-index__toolbar"
|
|
25
|
+
>
|
|
26
|
+
<div v-if="searchable" class="pui-index__search">
|
|
27
|
+
<TextInput
|
|
28
|
+
type="search"
|
|
29
|
+
:label="searchLabel"
|
|
30
|
+
:placeholder="searchPlaceholder"
|
|
31
|
+
:model-value="search"
|
|
32
|
+
@update:model-value="setSearch"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
<slot
|
|
36
|
+
name="filters"
|
|
37
|
+
:filters="query.filters"
|
|
38
|
+
:set-filters="setFilters"
|
|
39
|
+
:set-filter="setFilter"
|
|
40
|
+
/>
|
|
41
|
+
<div v-if="queryOptions.sorts.length" class="pui-index__sort">
|
|
42
|
+
<Select
|
|
43
|
+
:label="sortLabel"
|
|
44
|
+
:aria-label="sortLabel"
|
|
45
|
+
:model-value="formatSort(query.sort) ?? ''"
|
|
46
|
+
:options="sortOptions"
|
|
47
|
+
@update:model-value="
|
|
48
|
+
updateCriteria({ sort: parseSort($event) })
|
|
49
|
+
"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div
|
|
55
|
+
v-if="selectable && selected.length && $slots.selection"
|
|
56
|
+
class="pui-index__selection"
|
|
57
|
+
>
|
|
58
|
+
<slot
|
|
59
|
+
name="selection"
|
|
60
|
+
:selected="selected"
|
|
61
|
+
:reload="reload"
|
|
62
|
+
:loading="loading"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div v-if="errors.length" class="pui-index__error">
|
|
67
|
+
<slot
|
|
68
|
+
name="error"
|
|
69
|
+
:error="error"
|
|
70
|
+
:errors="errors"
|
|
71
|
+
:retry="reload"
|
|
72
|
+
:reset="reset"
|
|
73
|
+
:invalid-query="invalidQuery"
|
|
74
|
+
>
|
|
75
|
+
<Alert
|
|
76
|
+
tone="danger"
|
|
77
|
+
:title="
|
|
78
|
+
invalidQuery
|
|
79
|
+
? 'This list query is invalid'
|
|
80
|
+
: 'Could not load results'
|
|
81
|
+
"
|
|
82
|
+
>
|
|
83
|
+
<ul>
|
|
84
|
+
<li v-for="message in errors" :key="message">
|
|
85
|
+
{{ message }}
|
|
86
|
+
</li>
|
|
87
|
+
</ul>
|
|
88
|
+
<Button
|
|
89
|
+
size="sm"
|
|
90
|
+
@click="invalidQuery ? reset() : reload()"
|
|
91
|
+
>{{
|
|
92
|
+
invalidQuery ? 'Reset filters' : 'Try again'
|
|
93
|
+
}}</Button
|
|
94
|
+
>
|
|
95
|
+
</Alert>
|
|
96
|
+
</slot>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div v-if="showResults" class="pui-index__results">
|
|
100
|
+
<DataTable
|
|
101
|
+
class="pui-index__table"
|
|
102
|
+
:rows="rows"
|
|
103
|
+
:columns="columns"
|
|
104
|
+
:row-key="rowKey"
|
|
105
|
+
:sort="query.sort"
|
|
106
|
+
:loading="loading"
|
|
107
|
+
:selectable="selectable"
|
|
108
|
+
:selected="selected"
|
|
109
|
+
:clickable="clickable"
|
|
110
|
+
@sort="
|
|
111
|
+
updateCriteria({ sort: toggleSort(query.sort, $event) })
|
|
112
|
+
"
|
|
113
|
+
@update:selected="emit('update:selected', $event)"
|
|
114
|
+
@row-click="emit('row-click', $event)"
|
|
115
|
+
>
|
|
116
|
+
<template
|
|
117
|
+
v-for="name in cellSlots"
|
|
118
|
+
:key="name"
|
|
119
|
+
#[name]="scope"
|
|
120
|
+
>
|
|
121
|
+
<slot :name="name" v-bind="scope" />
|
|
122
|
+
</template>
|
|
123
|
+
<template #empty
|
|
124
|
+
><slot name="empty">No results.</slot></template
|
|
125
|
+
>
|
|
126
|
+
</DataTable>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<PaginationFooter v-if="showResults" class="pui-index__footer">
|
|
131
|
+
<template #summary>
|
|
132
|
+
<slot name="summary" :total="total" :from="from" :to="to">
|
|
133
|
+
{{ from }}–{{ to }} of {{ total }}
|
|
134
|
+
</slot>
|
|
135
|
+
</template>
|
|
136
|
+
<div class="pui-index__pagination">
|
|
137
|
+
<div v-if="pageSizes.length" class="pui-index__page-size">
|
|
138
|
+
<Select
|
|
139
|
+
:label="pageSizeLabel"
|
|
140
|
+
:aria-label="pageSizeLabel"
|
|
141
|
+
:model-value="String(query.page_size)"
|
|
142
|
+
:options="pageSizeOptions"
|
|
143
|
+
@update:model-value="
|
|
144
|
+
updateCriteria({
|
|
145
|
+
page_size: Number($event),
|
|
146
|
+
})
|
|
147
|
+
"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
<Pagination
|
|
151
|
+
:current-page="query.page"
|
|
152
|
+
:total-pages="totalPages"
|
|
153
|
+
@update:current-page="setPage($event)"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
</PaginationFooter>
|
|
157
|
+
</div>
|
|
158
|
+
</template>
|
|
159
|
+
|
|
160
|
+
<script setup lang="ts" generic="T extends object">
|
|
161
|
+
import { computed, watch } from 'vue';
|
|
162
|
+
import DataTable from '../table/DataTable.vue';
|
|
163
|
+
import type { Column } from '../table/types.js';
|
|
164
|
+
import TextInput from '../../form/text-input/TextInput.vue';
|
|
165
|
+
import Select from '../../form/select/Select.vue';
|
|
166
|
+
import Alert from '../../feedback/alert/Alert.vue';
|
|
167
|
+
import Pagination from '../../navigation/pagination/Pagination.vue';
|
|
168
|
+
import PaginationFooter from '../../navigation/pagination/PaginationFooter.vue';
|
|
169
|
+
import type {
|
|
170
|
+
ApiError,
|
|
171
|
+
ListQuery,
|
|
172
|
+
ListLoader,
|
|
173
|
+
QueryOptions,
|
|
174
|
+
} from '../../../utils/cms/index.js';
|
|
175
|
+
import Button from '../../action/button/Button.vue';
|
|
176
|
+
import { useIndex } from './useIndex.js';
|
|
177
|
+
import {
|
|
178
|
+
formatSort,
|
|
179
|
+
parseSort,
|
|
180
|
+
toggleSort,
|
|
181
|
+
} from '../../../utils/sort/index.js';
|
|
182
|
+
|
|
183
|
+
const props = withDefaults(
|
|
184
|
+
defineProps<{
|
|
185
|
+
/** Returns the matching page using the shared backend response contract. */
|
|
186
|
+
load: ListLoader<T>;
|
|
187
|
+
columns: Column[];
|
|
188
|
+
queryOptions: QueryOptions;
|
|
189
|
+
/** Synchronize list parameters with the current Vue Router route. */
|
|
190
|
+
syncQuery?: boolean;
|
|
191
|
+
/** Delay in milliseconds before committing typed search text. */
|
|
192
|
+
searchDebounce?: number;
|
|
193
|
+
title?: string;
|
|
194
|
+
description?: string;
|
|
195
|
+
searchable?: boolean;
|
|
196
|
+
searchLabel?: string;
|
|
197
|
+
searchPlaceholder?: string;
|
|
198
|
+
sortLabel?: string;
|
|
199
|
+
/** Labels for sort keys, including fields without a visible column. */
|
|
200
|
+
sortLabels?: Record<string, string>;
|
|
201
|
+
/** An empty array hides the page-size control. */
|
|
202
|
+
pageSizes?: number[];
|
|
203
|
+
pageSizeLabel?: string;
|
|
204
|
+
rowKey?: string;
|
|
205
|
+
selectable?: boolean;
|
|
206
|
+
selected?: (string | number)[];
|
|
207
|
+
clickable?: boolean;
|
|
208
|
+
}>(),
|
|
209
|
+
{
|
|
210
|
+
syncQuery: false,
|
|
211
|
+
searchDebounce: 250,
|
|
212
|
+
title: undefined,
|
|
213
|
+
description: undefined,
|
|
214
|
+
searchable: true,
|
|
215
|
+
searchLabel: 'Search',
|
|
216
|
+
searchPlaceholder: undefined,
|
|
217
|
+
sortLabel: 'Sort by',
|
|
218
|
+
sortLabels: () => ({}),
|
|
219
|
+
pageSizes: () => [10, 20, 50],
|
|
220
|
+
pageSizeLabel: 'Rows per page',
|
|
221
|
+
rowKey: 'id',
|
|
222
|
+
selectable: false,
|
|
223
|
+
selected: () => [],
|
|
224
|
+
clickable: false,
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const emit = defineEmits<{
|
|
229
|
+
'update:selected': [keys: (string | number)[]];
|
|
230
|
+
'row-click': [row: T];
|
|
231
|
+
}>();
|
|
232
|
+
|
|
233
|
+
const slots = defineSlots<{
|
|
234
|
+
actions?: (scope: {
|
|
235
|
+
reload: () => Promise<void>;
|
|
236
|
+
loading: boolean;
|
|
237
|
+
}) => unknown;
|
|
238
|
+
filters?: (scope: {
|
|
239
|
+
filters: ListQuery['filters'];
|
|
240
|
+
setFilters: (filters: ListQuery['filters']) => void;
|
|
241
|
+
setFilter: (name: string, value: string | undefined) => void;
|
|
242
|
+
}) => unknown;
|
|
243
|
+
selection?: (scope: {
|
|
244
|
+
selected: (string | number)[];
|
|
245
|
+
reload: () => Promise<void>;
|
|
246
|
+
loading: boolean;
|
|
247
|
+
}) => unknown;
|
|
248
|
+
error?: (scope: {
|
|
249
|
+
error: ApiError | null;
|
|
250
|
+
errors: string[];
|
|
251
|
+
retry: () => Promise<void>;
|
|
252
|
+
reset: () => Promise<void>;
|
|
253
|
+
invalidQuery: boolean;
|
|
254
|
+
}) => unknown;
|
|
255
|
+
empty?: () => unknown;
|
|
256
|
+
summary?: (scope: { total: number; from: number; to: number }) => unknown;
|
|
257
|
+
[name: `cell:${string}`]: (scope: { row: T; value: unknown }) => unknown;
|
|
258
|
+
}>();
|
|
259
|
+
|
|
260
|
+
const unsupportedSortColumns = computed(() =>
|
|
261
|
+
props.columns.filter(
|
|
262
|
+
(column) =>
|
|
263
|
+
column.sortable && !props.queryOptions.sorts.includes(column.key),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
function validateSortColumns(unsupported: Column[]) {
|
|
267
|
+
if (unsupported.length) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[pui] Index sortable columns must be allowed by queryOptions.sorts: ${unsupported.map((column) => column.key).join(', ')}.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
validateSortColumns(unsupportedSortColumns.value);
|
|
274
|
+
watch(unsupportedSortColumns, validateSortColumns);
|
|
275
|
+
|
|
276
|
+
const {
|
|
277
|
+
query,
|
|
278
|
+
search,
|
|
279
|
+
rows,
|
|
280
|
+
meta,
|
|
281
|
+
loading,
|
|
282
|
+
error,
|
|
283
|
+
errors,
|
|
284
|
+
invalidQuery,
|
|
285
|
+
updateCriteria,
|
|
286
|
+
setSearch,
|
|
287
|
+
setPage,
|
|
288
|
+
setFilters,
|
|
289
|
+
setFilter,
|
|
290
|
+
reload,
|
|
291
|
+
reset,
|
|
292
|
+
} = useIndex<T>(props);
|
|
293
|
+
defineExpose({ reload });
|
|
294
|
+
const total = computed(() => meta.value.total);
|
|
295
|
+
const showResults = computed(
|
|
296
|
+
() =>
|
|
297
|
+
!invalidQuery.value &&
|
|
298
|
+
(!errors.value.length || rows.value.length > 0 || loading.value),
|
|
299
|
+
);
|
|
300
|
+
const sortOptions = computed(() => [
|
|
301
|
+
{ value: '', label: 'Default order' },
|
|
302
|
+
...props.queryOptions.sorts.flatMap((key) => {
|
|
303
|
+
const label =
|
|
304
|
+
props.sortLabels[key] ??
|
|
305
|
+
props.columns.find((column) => column.key === key)?.label ??
|
|
306
|
+
key
|
|
307
|
+
.replaceAll('_', ' ')
|
|
308
|
+
.replace(/^./, (letter) => letter.toUpperCase());
|
|
309
|
+
return [
|
|
310
|
+
{
|
|
311
|
+
value: formatSort({ key, direction: 'asc' })!,
|
|
312
|
+
label: `${label} (ascending)`,
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
value: formatSort({ key, direction: 'desc' })!,
|
|
316
|
+
label: `${label} (descending)`,
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
}),
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
const cellSlots = computed(() =>
|
|
323
|
+
props.columns
|
|
324
|
+
.map((column) => `cell:${column.key}` as const)
|
|
325
|
+
.filter((name) => slots[name]),
|
|
326
|
+
);
|
|
327
|
+
const totalPages = computed(() =>
|
|
328
|
+
Math.max(1, Math.ceil(total.value / query.value.page_size)),
|
|
329
|
+
);
|
|
330
|
+
const from = computed(() =>
|
|
331
|
+
rows.value.length ? (meta.value.page - 1) * meta.value.page_size + 1 : 0,
|
|
332
|
+
);
|
|
333
|
+
const to = computed(() =>
|
|
334
|
+
rows.value.length
|
|
335
|
+
? Math.min(total.value, from.value + rows.value.length - 1)
|
|
336
|
+
: 0,
|
|
337
|
+
);
|
|
338
|
+
const pageSizeOptions = computed(() =>
|
|
339
|
+
[...new Set([...props.pageSizes, query.value.page_size])]
|
|
340
|
+
.filter(
|
|
341
|
+
(size) =>
|
|
342
|
+
Number.isInteger(size) &&
|
|
343
|
+
size > 0 &&
|
|
344
|
+
size <= (props.queryOptions.maxPageSize ?? 100),
|
|
345
|
+
)
|
|
346
|
+
.sort((a, b) => a - b)
|
|
347
|
+
.map((size) => ({ value: String(size), label: String(size) })),
|
|
348
|
+
);
|
|
349
|
+
</script>
|
|
350
|
+
|
|
351
|
+
<style>
|
|
352
|
+
@import './index.css';
|
|
353
|
+
</style>
|