@globalbrain/sefirot 4.46.0 → 4.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/blocks/lens/FieldData.ts +27 -0
- package/lib/blocks/lens/ResourceFetcher.ts +5 -1
- package/lib/blocks/lens/Rule.ts +10 -0
- package/lib/blocks/lens/components/LensCatalog.vue +826 -39
- package/lib/blocks/lens/components/LensSheet.vue +465 -0
- package/lib/blocks/lens/components/LensSheetField.vue +210 -0
- package/lib/blocks/lens/components/LensTable.vue +79 -5
- package/lib/blocks/lens/components/LensTableEditableCell.vue +285 -0
- package/lib/blocks/lens/composables/CatalogUrlQuerySync.ts +146 -0
- package/lib/blocks/lens/composables/LensEdit.ts +59 -0
- package/lib/blocks/lens/composables/LensInlineEdit.ts +36 -0
- package/lib/blocks/lens/composables/ResourceFetcher.ts +20 -7
- package/lib/blocks/lens/composables/SetupLens.ts +2 -0
- package/lib/blocks/lens/fields/AvatarField.ts +43 -0
- package/lib/blocks/lens/fields/ContentField.ts +21 -10
- package/lib/blocks/lens/fields/Field.ts +26 -1
- package/lib/blocks/lens/fields/FileUploadField.ts +8 -0
- package/lib/blocks/lens/fields/RelatedManyField.ts +44 -7
- package/lib/blocks/lens/fields/RelatedOneField.ts +42 -7
- package/lib/blocks/lens/validation/RuleMapper.ts +22 -3
- package/lib/blocks/lens/validation/ServerErrors.ts +25 -0
- package/lib/components/SInputSwitches.vue +2 -2
- package/lib/components/SSheet.vue +104 -0
- package/lib/composables/Api.ts +18 -10
- package/lib/composables/Url.ts +19 -2
- package/lib/styles/variables.css +1 -0
- package/lib/validation/rules/index.ts +1 -0
- package/lib/validation/rules/slackChannelLink.ts +15 -0
- package/lib/validation/validators/index.ts +1 -0
- package/lib/validation/validators/slackChannelLink.ts +52 -0
- package/package.json +17 -17
|
@@ -6,8 +6,11 @@ export function useResourceFetcher(): ResourceFetcher {
|
|
|
6
6
|
const data = ref<Record<string, any>>({})
|
|
7
7
|
const pendingList: Record<string, Promise<any> | undefined> = {}
|
|
8
8
|
|
|
9
|
-
const { execute: resourceFetcher } = useGet<
|
|
10
|
-
|
|
9
|
+
const { execute: resourceFetcher } = useGet<
|
|
10
|
+
any,
|
|
11
|
+
[method: ResourceFetchMethod, url: string, body?: Record<string, any> | null]
|
|
12
|
+
>(async (http, method, url, body) => {
|
|
13
|
+
const key = `${method}:${url}:${body ? JSON.stringify(body) : ''}`
|
|
11
14
|
|
|
12
15
|
if (data.value[key]) {
|
|
13
16
|
return data.value[key]
|
|
@@ -17,11 +20,21 @@ export function useResourceFetcher(): ResourceFetcher {
|
|
|
17
20
|
return pendingList[key]
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// `post` carries a request body (e.g. a lens search payload); `get` ignores
|
|
24
|
+
// it. Send no body at all when none is configured — `http.post(url, {})`
|
|
25
|
+
// would serialize and POST an empty JSON object, which endpoints that
|
|
26
|
+
// branch on an absent payload could reject.
|
|
27
|
+
pendingList[key] = (
|
|
28
|
+
method === 'post'
|
|
29
|
+
? (body != null ? http.post(url, body) : http.post(url))
|
|
30
|
+
: http.get(url)
|
|
31
|
+
).then(
|
|
32
|
+
(response) => {
|
|
33
|
+
data.value[key] = response
|
|
34
|
+
delete pendingList[key]
|
|
35
|
+
return response
|
|
36
|
+
}
|
|
37
|
+
)
|
|
25
38
|
|
|
26
39
|
return pendingList[key]
|
|
27
40
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FieldDataType } from '../FieldData'
|
|
2
2
|
import { type FieldDataFor, type FieldProvider, FieldRegistry } from '../FieldRegistry'
|
|
3
3
|
import { provideFieldRegistry } from '../composables/FieldRegistry'
|
|
4
|
+
import { AvatarField } from '../fields/AvatarField'
|
|
4
5
|
import { BooleanField } from '../fields/BooleanField'
|
|
5
6
|
import { ContentField } from '../fields/ContentField'
|
|
6
7
|
import { DateField } from '../fields/DateField'
|
|
@@ -34,6 +35,7 @@ export function useSetupLens(): SetupLens {
|
|
|
34
35
|
registerDefaultFields()
|
|
35
36
|
|
|
36
37
|
function registerDefaultFields(): void {
|
|
38
|
+
fieldRegistry.register('avatar', (ctx, field) => new AvatarField(ctx, field))
|
|
37
39
|
fieldRegistry.register('boolean', (ctx, field) => new BooleanField(ctx, field))
|
|
38
40
|
fieldRegistry.register('content', (ctx, field) => new ContentField(ctx, field))
|
|
39
41
|
fieldRegistry.register('date', (ctx, field) => new DateField(ctx, field))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { h } from 'vue'
|
|
2
|
+
import SAvatar from '../../../components/SAvatar.vue'
|
|
3
|
+
import { type TableCell } from '../../../composables/Table'
|
|
4
|
+
import { type AvatarFieldData } from '../FieldData'
|
|
5
|
+
import { type FilterOperator } from '../FilterOperator'
|
|
6
|
+
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
7
|
+
import { Field } from './Field'
|
|
8
|
+
|
|
9
|
+
export class AvatarField extends Field<AvatarFieldData> {
|
|
10
|
+
override tableCell(v: any, r: any): TableCell {
|
|
11
|
+
// The display name lives on a sibling key of the record (resolved by the
|
|
12
|
+
// active language), not on the field value, which is the image URL.
|
|
13
|
+
const nameKey = this.ctx.lang === 'ja' ? this.data.nameJa : this.data.nameEn
|
|
14
|
+
return {
|
|
15
|
+
type: 'avatar',
|
|
16
|
+
// The field value is the image URL itself. A missing value renders an
|
|
17
|
+
// empty cell (both `image` and `name` falsy), unless a name companion
|
|
18
|
+
// is configured, in which case the avatar falls back to its initials.
|
|
19
|
+
image: v ?? null,
|
|
20
|
+
// Empty string (not null) when unset: a null name makes the cell
|
|
21
|
+
// renderer fall back to the raw row value, which here is the image URL
|
|
22
|
+
// and would break SAvatar.
|
|
23
|
+
name: nameKey ? (r?.[nameKey] ?? '') : ''
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
|
|
28
|
+
return {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
override dataListItemComponent(): any {
|
|
32
|
+
return this.defineDataListItemComponent((value) => {
|
|
33
|
+
// Render nothing for a missing image so SDataListItem shows its empty
|
|
34
|
+
// placeholder ("—"), matching the empty table cell and the other
|
|
35
|
+
// data-list fields, instead of an empty bordered avatar.
|
|
36
|
+
return value ? h(SAvatar, { size: 'small', avatar: value }) : null
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override formInputComponent(): any {
|
|
41
|
+
throw new Error('Not implemented.')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { h } from 'vue'
|
|
1
|
+
import { defineComponent, h } from 'vue'
|
|
2
2
|
import SContent from '../../../components/SContent.vue'
|
|
3
3
|
import SMarkdown from '../../../components/SMarkdown.vue'
|
|
4
4
|
import { type TableCell } from '../../../composables/Table'
|
|
@@ -8,6 +8,13 @@ import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
|
8
8
|
import { Field } from './Field'
|
|
9
9
|
|
|
10
10
|
export class ContentField extends Field<ContentFieldData> {
|
|
11
|
+
// `content` is a display-only field: `formInputComponent()` renders static
|
|
12
|
+
// Markdown (instructions) rather than an editable input, so it must never be
|
|
13
|
+
// seeded / validated / submitted as a value, nor made inline-editable.
|
|
14
|
+
override isSubmittable(): boolean {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
override tableCell(_v: any, _r: any): TableCell {
|
|
12
19
|
return { type: 'empty' }
|
|
13
20
|
}
|
|
@@ -17,18 +24,22 @@ export class ContentField extends Field<ContentFieldData> {
|
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
override dataListItemComponent(): any {
|
|
20
|
-
|
|
27
|
+
// `content` is display-only: render its Markdown body (the same static
|
|
28
|
+
// block as the create form) so a detail-visible content field shows its
|
|
29
|
+
// configured instructions instead of an empty value row.
|
|
30
|
+
return defineComponent(() => () => this.renderBody(), { props: ['value'] })
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
override formInputComponent(): any {
|
|
24
|
-
return this.defineFormInputComponent((_props, _ctx) =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
return this.defineFormInputComponent((_props, _ctx) => () => this.renderBody())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Render the field's localized Markdown body. */
|
|
38
|
+
private renderBody(): any {
|
|
39
|
+
return h(SMarkdown, {
|
|
40
|
+
content: this.ctx.lang === 'ja' ? this.data.bodyJa : this.data.bodyEn
|
|
41
|
+
}, {
|
|
42
|
+
default: (slot: any) => h(SContent, { innerHTML: slot.rendered })
|
|
32
43
|
})
|
|
33
44
|
}
|
|
34
45
|
}
|
|
@@ -110,7 +110,10 @@ export abstract class Field<T extends FieldData> {
|
|
|
110
110
|
* Renders the table filter menu for the field. The filter maybe null when
|
|
111
111
|
* the field has no available filters.
|
|
112
112
|
*/
|
|
113
|
-
async tableFilterMenu(
|
|
113
|
+
async tableFilterMenu(
|
|
114
|
+
_filters: any[],
|
|
115
|
+
_onFilterUpdated: (filters: any[]) => void
|
|
116
|
+
): Promise<DropdownSection | null> {
|
|
114
117
|
return null
|
|
115
118
|
}
|
|
116
119
|
|
|
@@ -212,6 +215,28 @@ export abstract class Field<T extends FieldData> {
|
|
|
212
215
|
})
|
|
213
216
|
}
|
|
214
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Whether this field contributes a value to create / update payloads. Most
|
|
220
|
+
* fields do. Display-only fields (e.g. `content`, which renders static
|
|
221
|
+
* instructions in the form) return false so the sheet renders them but
|
|
222
|
+
* never seeds, validates, submits, or makes them inline-editable.
|
|
223
|
+
*/
|
|
224
|
+
isSubmittable(): boolean {
|
|
225
|
+
return true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether this field can be edited in place (inline cell / record sheet) with
|
|
230
|
+
* an optimistic display. False for fields whose input value isn't the
|
|
231
|
+
* displayed value and needs a server round-trip first (e.g. a file upload,
|
|
232
|
+
* which holds raw `File` objects before upload) — patching the row with that
|
|
233
|
+
* raw value would render incorrectly or crash. Such fields remain editable in
|
|
234
|
+
* the create form, which is blocking.
|
|
235
|
+
*/
|
|
236
|
+
supportsOptimisticUpdate(): boolean {
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
|
|
215
240
|
/**
|
|
216
241
|
* Returns the value should be used when the input is empty.
|
|
217
242
|
*/
|
|
@@ -16,6 +16,14 @@ export class FileUploadField extends Field<FileUploadFieldData> {
|
|
|
16
16
|
this.downloader = downloader
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// The form input holds raw `File` objects until they're uploaded, which only
|
|
20
|
+
// happens server-side; an optimistic patch would store those `File`s on the
|
|
21
|
+
// row and crash the persisted-path renderer. So file uploads aren't editable
|
|
22
|
+
// inline / in the sheet (only on create, which is blocking).
|
|
23
|
+
override supportsOptimisticUpdate(): boolean {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
override tableCell(v: any, _r: any): TableCell {
|
|
20
28
|
return {
|
|
21
29
|
type: 'text',
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { xor } from 'lodash-es'
|
|
2
|
+
import { h } from 'vue'
|
|
3
|
+
import SDescAvatar from '../../../components/SDescAvatar.vue'
|
|
4
|
+
import SDescPill from '../../../components/SDescPill.vue'
|
|
2
5
|
import { type DropdownSection } from '../../../composables/Dropdown'
|
|
3
6
|
import { type TableCell } from '../../../composables/Table'
|
|
4
7
|
import { type RelatedManyFieldData } from '../FieldData'
|
|
@@ -16,7 +19,10 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
16
19
|
this.fetcher = fetcher
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
override async tableFilterMenu(
|
|
22
|
+
override async tableFilterMenu(
|
|
23
|
+
filters: any[],
|
|
24
|
+
onFilterUpdated: (filters: any[]) => void
|
|
25
|
+
): Promise<DropdownSection | null> {
|
|
20
26
|
const method = this.data.resourceEndpointMethod
|
|
21
27
|
const url = this.data.resourceEndpointPath
|
|
22
28
|
const key = this.data.resourceEndpointDataKey
|
|
@@ -27,7 +33,7 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
27
33
|
|
|
28
34
|
const selected = this.inFilterValueFor(this.data.key, filters)
|
|
29
35
|
|
|
30
|
-
const res = await this.fetcher(method, url)
|
|
36
|
+
const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
|
|
31
37
|
const data = key ? res[key] : res
|
|
32
38
|
|
|
33
39
|
const isAvatar = this.data.displayAs === 'avatars'
|
|
@@ -37,11 +43,11 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
37
43
|
type: 'avatar' as const,
|
|
38
44
|
label: item[this.data.resourceTitle],
|
|
39
45
|
image: this.data.resourceImage ? item[this.data.resourceImage] : null,
|
|
40
|
-
value: item[this.data.filterKey]
|
|
46
|
+
value: this.resolveValue(item[this.data.filterKey])
|
|
41
47
|
}
|
|
42
48
|
: {
|
|
43
49
|
label: item[this.data.resourceTitle],
|
|
44
|
-
value: item[this.data.filterKey]
|
|
50
|
+
value: this.resolveValue(item[this.data.filterKey])
|
|
45
51
|
})
|
|
46
52
|
|
|
47
53
|
return {
|
|
@@ -88,10 +94,10 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
const optionsResolver = async () => {
|
|
91
|
-
const res = await this.fetcher(method, url)
|
|
97
|
+
const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
|
|
92
98
|
const data = key ? res[key] : res
|
|
93
99
|
return data.map((item: any) => ({
|
|
94
|
-
value: item[this.data.filterKey],
|
|
100
|
+
value: this.resolveValue(item[this.data.filterKey]),
|
|
95
101
|
label: item[this.data.resourceTitle]
|
|
96
102
|
}))
|
|
97
103
|
}
|
|
@@ -106,8 +112,39 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
|
|
115
|
+
// Lens id fields serialize as `{ value, display, path? }`; filters need the
|
|
116
|
+
// raw scalar. Unwrap that shape, passing plain scalars through untouched.
|
|
117
|
+
private resolveValue(raw: any): any {
|
|
118
|
+
return raw !== null && typeof raw === 'object' && 'value' in raw ? raw.value : raw
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
override dataListItemComponent(): any {
|
|
110
|
-
|
|
122
|
+
return this.defineDataListItemComponent((value) => {
|
|
123
|
+
// related_many serializes as an array of relation objects (or null).
|
|
124
|
+
// Read the display name / image from the same keys `tableCell()` uses,
|
|
125
|
+
// instead of letting the generic fallback render `[object Object]`.
|
|
126
|
+
const items = (value ?? []) as any[]
|
|
127
|
+
|
|
128
|
+
if (items.length === 0) {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.data.displayAs === 'avatars') {
|
|
133
|
+
return h(SDescAvatar, {
|
|
134
|
+
avatar: items.map((item) => ({
|
|
135
|
+
avatar: this.data.image ? (item[this.data.image] ?? null) : null,
|
|
136
|
+
name: item[this.data.title] ?? null
|
|
137
|
+
}))
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 'pills' (and the null default): render one pill per related item.
|
|
142
|
+
return h(SDescPill, {
|
|
143
|
+
pill: items.map((item) => ({
|
|
144
|
+
label: item[this.data.title] ?? ''
|
|
145
|
+
}))
|
|
146
|
+
})
|
|
147
|
+
})
|
|
111
148
|
}
|
|
112
149
|
|
|
113
150
|
override formInputComponent() {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { xor } from 'lodash-es'
|
|
2
|
+
import { h } from 'vue'
|
|
3
|
+
import SDescAvatar from '../../../components/SDescAvatar.vue'
|
|
2
4
|
import { type DropdownSection } from '../../../composables/Dropdown'
|
|
3
5
|
import { type TableCell } from '../../../composables/Table'
|
|
4
6
|
import { type RelatedOneFieldData } from '../FieldData'
|
|
@@ -16,7 +18,10 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
|
16
18
|
this.fetcher = fetcher
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
override async tableFilterMenu(
|
|
21
|
+
override async tableFilterMenu(
|
|
22
|
+
filters: any[],
|
|
23
|
+
onFilterUpdated: (filters: any[]) => void
|
|
24
|
+
): Promise<DropdownSection | null> {
|
|
20
25
|
const method = this.data.resourceEndpointMethod
|
|
21
26
|
const url = this.data.resourceEndpointPath
|
|
22
27
|
const key = this.data.resourceEndpointDataKey
|
|
@@ -27,7 +32,7 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
|
27
32
|
|
|
28
33
|
const selected = this.inFilterValueFor(this.data.key, filters)
|
|
29
34
|
|
|
30
|
-
const res = await this.fetcher(method, url)
|
|
35
|
+
const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
|
|
31
36
|
const data = key ? res[key] : res
|
|
32
37
|
|
|
33
38
|
const isAvatar = this.data.displayAs === 'avatar'
|
|
@@ -37,11 +42,11 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
|
37
42
|
type: 'avatar' as const,
|
|
38
43
|
label: item[this.data.resourceTitle],
|
|
39
44
|
image: this.data.resourceImage ? item[this.data.resourceImage] : null,
|
|
40
|
-
value: item[this.data.filterKey]
|
|
45
|
+
value: this.resolveValue(item[this.data.filterKey])
|
|
41
46
|
}
|
|
42
47
|
: {
|
|
43
48
|
label: item[this.data.resourceTitle],
|
|
44
|
-
value: item[this.data.filterKey]
|
|
49
|
+
value: this.resolveValue(item[this.data.filterKey])
|
|
45
50
|
})
|
|
46
51
|
|
|
47
52
|
return {
|
|
@@ -91,10 +96,10 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
const optionsResolver = async () => {
|
|
94
|
-
const res = await this.fetcher(method, url)
|
|
99
|
+
const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
|
|
95
100
|
const data = key ? res[key] : res
|
|
96
101
|
return data.map((item: any) => ({
|
|
97
|
-
value: item[this.data.filterKey],
|
|
102
|
+
value: this.resolveValue(item[this.data.filterKey]),
|
|
98
103
|
label: item[this.data.resourceTitle]
|
|
99
104
|
}))
|
|
100
105
|
}
|
|
@@ -109,8 +114,38 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
// Lens id fields serialize as `{ value, display, path? }`; filters need the
|
|
118
|
+
// raw scalar. Unwrap that shape, passing plain scalars through untouched.
|
|
119
|
+
// (Mirrors `RelatedManyField` — relevant when a related-one resource reuses
|
|
120
|
+
// a Lens search endpoint whose `filterKey` is an id field.)
|
|
121
|
+
private resolveValue(raw: any): any {
|
|
122
|
+
return raw !== null && typeof raw === 'object' && 'value' in raw ? raw.value : raw
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
override dataListItemComponent(): any {
|
|
113
|
-
|
|
126
|
+
return this.defineDataListItemComponent((value) => {
|
|
127
|
+
// related_one serializes as a single relation object (or null). Read the
|
|
128
|
+
// display name / image from the same keys `tableCell()` uses, instead of
|
|
129
|
+
// letting the generic fallback render the raw object as JSON.
|
|
130
|
+
if (value === null || value === undefined) {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const name = value[this.data.title] ?? null
|
|
135
|
+
|
|
136
|
+
if (this.data.displayAs === 'avatar') {
|
|
137
|
+
return h(SDescAvatar, {
|
|
138
|
+
avatar: {
|
|
139
|
+
avatar: this.data.image ? (value[this.data.image] ?? null) : null,
|
|
140
|
+
name
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 'text' (and the null default): render the title as plain text, falling
|
|
146
|
+
// back to the empty placeholder when the title key is missing.
|
|
147
|
+
return name
|
|
148
|
+
})
|
|
114
149
|
}
|
|
115
150
|
|
|
116
151
|
override formInputComponent() {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { type ValidationArgs, type ValidationRuleWithParams } from '@vuelidate/core'
|
|
2
2
|
import { type Day, day } from '../../../support/Day'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
after,
|
|
5
|
+
afterOrEqual,
|
|
6
|
+
before,
|
|
7
|
+
beforeOrEqual,
|
|
8
|
+
maxLength,
|
|
9
|
+
required,
|
|
10
|
+
slackChannelLink,
|
|
11
|
+
slackChannelName
|
|
12
|
+
} from '../../../validation/rules'
|
|
4
13
|
import { type Rule } from '../Rule'
|
|
5
14
|
|
|
6
15
|
/**
|
|
@@ -8,17 +17,27 @@ import { type Rule } from '../Rule'
|
|
|
8
17
|
*/
|
|
9
18
|
export function map(rules: Rule[]): ValidationArgs {
|
|
10
19
|
return rules.reduce((carry: ValidationArgs<any>, rule: Rule) => {
|
|
11
|
-
|
|
20
|
+
const mapped = mapRule(rule)
|
|
21
|
+
if (mapped) {
|
|
22
|
+
carry[rule.type] = mapped
|
|
23
|
+
}
|
|
12
24
|
return carry
|
|
13
25
|
}, {})
|
|
14
26
|
}
|
|
15
27
|
|
|
16
|
-
function mapRule(rule: Rule): ValidationRuleWithParams {
|
|
28
|
+
function mapRule(rule: Rule): ValidationRuleWithParams | null {
|
|
17
29
|
switch (rule.type) {
|
|
18
30
|
case 'max_length':
|
|
19
31
|
return maxLength(rule.length)
|
|
20
32
|
case 'required':
|
|
21
33
|
return required()
|
|
34
|
+
case 'unique':
|
|
35
|
+
// Uniqueness can only be verified server-side (it queries the
|
|
36
|
+
// database). The backend enforces it and returns a 422 with field
|
|
37
|
+
// errors, so there is no client-side equivalent to apply here.
|
|
38
|
+
return null
|
|
39
|
+
case 'slack_channel_link':
|
|
40
|
+
return slackChannelLink()
|
|
22
41
|
case 'slack_channel_name':
|
|
23
42
|
return slackChannelName({ offset: rule.offset })
|
|
24
43
|
case 'before':
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isFetchError } from '../../../http/Http'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract Laravel-style validation errors from a failed request. The backend
|
|
5
|
+
* enforces rules that can't run client-side (e.g. `unique`) and rejects with a
|
|
6
|
+
* 422 whose body is `{ message, errors: { <fieldKey>: string[] } }`, keyed by
|
|
7
|
+
* the bare field key.
|
|
8
|
+
*
|
|
9
|
+
* Returns the `errors` map so a form can feed it into Vuelidate's
|
|
10
|
+
* `$externalResults` and surface a fixable per-field message, or `null` when
|
|
11
|
+
* the error isn't a usable 422 (so the caller can rethrow it).
|
|
12
|
+
*/
|
|
13
|
+
export function extractServerErrors(error: unknown): Record<string, string[]> | null {
|
|
14
|
+
if (!isFetchError(error) || error.status !== 422) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const errors = (error.data as any)?.errors
|
|
19
|
+
|
|
20
|
+
if (!errors || typeof errors !== 'object') {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return errors
|
|
25
|
+
}
|
|
@@ -29,7 +29,7 @@ const props = defineProps<{
|
|
|
29
29
|
}>()
|
|
30
30
|
|
|
31
31
|
const emit = defineEmits<{
|
|
32
|
-
'update:
|
|
32
|
+
'update:model-value': [value: any[]]
|
|
33
33
|
}>()
|
|
34
34
|
|
|
35
35
|
const classes = computed(() => [
|
|
@@ -45,7 +45,7 @@ function onChange(value: any): void {
|
|
|
45
45
|
.filter((v) => v !== value)
|
|
46
46
|
.concat(props.modelValue.includes(value) ? [] : [value])
|
|
47
47
|
|
|
48
|
-
emit('update:
|
|
48
|
+
emit('update:model-value', difference)
|
|
49
49
|
}
|
|
50
50
|
</script>
|
|
51
51
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onKeyStroke } from '@vueuse/core'
|
|
3
|
+
import { ref } from 'vue'
|
|
4
|
+
|
|
5
|
+
export interface Props {
|
|
6
|
+
open: boolean
|
|
7
|
+
closable?: boolean
|
|
8
|
+
position?: 'right' | 'left'
|
|
9
|
+
width?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
13
|
+
closable: true,
|
|
14
|
+
position: 'right',
|
|
15
|
+
width: '480px'
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
close: []
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const el = ref<any>(null)
|
|
23
|
+
|
|
24
|
+
function onClick(e: MouseEvent) {
|
|
25
|
+
if (!props.closable) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (e.button === 0 && e.target === el.value) {
|
|
30
|
+
emit('close')
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onKeyStroke('Escape', () => {
|
|
35
|
+
if (props.open && props.closable) {
|
|
36
|
+
emit('close')
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<Teleport to="#sefirot-modals">
|
|
43
|
+
<Transition name="s-sheet">
|
|
44
|
+
<div
|
|
45
|
+
v-if="open"
|
|
46
|
+
ref="el"
|
|
47
|
+
class="SSheet"
|
|
48
|
+
:class="[`pos-${position}`]"
|
|
49
|
+
@mousedown="onClick"
|
|
50
|
+
>
|
|
51
|
+
<div class="panel" :style="{ width }">
|
|
52
|
+
<slot />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</Transition>
|
|
56
|
+
</Teleport>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<style scoped>
|
|
60
|
+
.SSheet {
|
|
61
|
+
position: fixed;
|
|
62
|
+
top: 0;
|
|
63
|
+
right: 0;
|
|
64
|
+
bottom: 0;
|
|
65
|
+
left: 0;
|
|
66
|
+
z-index: var(--z-index-sheet);
|
|
67
|
+
display: flex;
|
|
68
|
+
padding: 8px;
|
|
69
|
+
transition: opacity 0.25s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.SSheet.pos-right { justify-content: flex-end; }
|
|
73
|
+
.SSheet.pos-left { justify-content: flex-start; }
|
|
74
|
+
|
|
75
|
+
.SSheet.s-sheet-enter-from,
|
|
76
|
+
.SSheet.s-sheet-leave-to {
|
|
77
|
+
opacity: 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.panel {
|
|
81
|
+
display: flex;
|
|
82
|
+
flex-direction: column;
|
|
83
|
+
max-width: calc(100% - 16px);
|
|
84
|
+
height: 100%;
|
|
85
|
+
border: 1px solid var(--c-divider);
|
|
86
|
+
border-radius: 12px;
|
|
87
|
+
background-color: var(--c-bg-1);
|
|
88
|
+
box-shadow: var(--shadow-depth-3);
|
|
89
|
+
transition: transform 0.25s, opacity 0.25s;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.SSheet.pos-right.s-sheet-enter-from .panel,
|
|
94
|
+
.SSheet.pos-right.s-sheet-leave-to .panel {
|
|
95
|
+
opacity: 0;
|
|
96
|
+
transform: translateX(16px);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.SSheet.pos-left.s-sheet-enter-from .panel,
|
|
100
|
+
.SSheet.pos-left.s-sheet-leave-to .panel {
|
|
101
|
+
opacity: 0;
|
|
102
|
+
transform: translateX(-16px);
|
|
103
|
+
}
|
|
104
|
+
</style>
|
package/lib/composables/Api.ts
CHANGED
|
@@ -59,11 +59,15 @@ export function useQuery<Data = any>(
|
|
|
59
59
|
async function execute({ assign = true, silent = false } = {}): Promise<Data> {
|
|
60
60
|
if (!silent) { loading.value = true }
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
try {
|
|
63
|
+
const res: Data = await req(new Http(httpConfig))
|
|
64
|
+
if (assign) { data.value = res }
|
|
65
|
+
return res
|
|
66
|
+
} finally {
|
|
67
|
+
// Always clear loading, even when the request rejects — otherwise a failed
|
|
68
|
+
// fetch leaves the consumer stuck in its loading/skeleton state.
|
|
69
|
+
loading.value = false
|
|
70
|
+
}
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
return { loading, data, execute }
|
|
@@ -79,11 +83,15 @@ export function useMutation<Data = any, Args extends any[] = any[]>(
|
|
|
79
83
|
async function execute(...args: Args): Promise<Data> {
|
|
80
84
|
loading.value = true
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
try {
|
|
87
|
+
const res: Data = await req(new Http(httpConfig), ...args)
|
|
88
|
+
data.value = res
|
|
89
|
+
return res
|
|
90
|
+
} finally {
|
|
91
|
+
// Always clear loading, even when the request rejects, so a failed
|
|
92
|
+
// mutation doesn't leave the caller wedged in its loading state.
|
|
93
|
+
loading.value = false
|
|
94
|
+
}
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
return { loading, data, execute }
|