@globalbrain/sefirot 4.62.0 → 4.64.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 +17 -0
- package/lib/blocks/lens/FilterOperator.ts +52 -1
- package/lib/blocks/lens/components/LensCatalog.vue +9 -12
- package/lib/blocks/lens/components/LensCatalogStateFilterCondition.vue +10 -2
- package/lib/blocks/lens/components/LensFormFilter.vue +31 -10
- package/lib/blocks/lens/components/LensFormFilterCondition.vue +7 -3
- package/lib/blocks/lens/fields/Field.ts +36 -0
- package/lib/blocks/lens/fields/RelatedManyField.ts +32 -4
- package/lib/blocks/lens/fields/RelatedOneField.ts +32 -4
- package/lib/blocks/lens/filter-inputs/EmptyFilterInput.ts +26 -0
- package/lib/http/Http.ts +240 -16
- package/lib/stores/HttpConfig.ts +6 -0
- package/package.json +1 -1
|
@@ -71,6 +71,23 @@ export interface FieldDataBase {
|
|
|
71
71
|
* means a null blank.
|
|
72
72
|
*/
|
|
73
73
|
emptyValue?: any
|
|
74
|
+
/**
|
|
75
|
+
* Whether the backend translates the valueless `empty` / `notEmpty`
|
|
76
|
+
* filter operators for this field. The operators are strictly opt-in:
|
|
77
|
+
* the filter UI offers them only when the backend's field definition
|
|
78
|
+
* declares support, so applications whose backends don't understand
|
|
79
|
+
* the operators see no change. Absent means unsupported.
|
|
80
|
+
*
|
|
81
|
+
* The object form also customizes the label of the pinned "empty"
|
|
82
|
+
* option in the column filter dropdown (e.g. "No assignees"); a plain
|
|
83
|
+
* `true` keeps the generic default label.
|
|
84
|
+
*/
|
|
85
|
+
emptyOperators?: boolean | EmptyOperatorsDeclaration
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface EmptyOperatorsDeclaration {
|
|
89
|
+
labelEn?: string
|
|
90
|
+
labelJa?: string
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
export interface AvatarFieldData extends FieldDataBase {
|
|
@@ -9,6 +9,8 @@ export type FilterOperator =
|
|
|
9
9
|
| 'contains'
|
|
10
10
|
| 'startsWith'
|
|
11
11
|
| 'endsWith'
|
|
12
|
+
| 'empty'
|
|
13
|
+
| 'notEmpty'
|
|
12
14
|
|
|
13
15
|
export const FilterOperatorLabelDict: Record<FilterOperator, string> = {
|
|
14
16
|
'=': 'is',
|
|
@@ -20,5 +22,54 @@ export const FilterOperatorLabelDict: Record<FilterOperator, string> = {
|
|
|
20
22
|
'in': 'in',
|
|
21
23
|
'contains': 'contains',
|
|
22
24
|
'startsWith': 'starts with',
|
|
23
|
-
'endsWith': 'ends with'
|
|
25
|
+
'endsWith': 'ends with',
|
|
26
|
+
'empty': 'is empty',
|
|
27
|
+
'notEmpty': 'is not empty'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether the operator forms a complete condition without a value (the
|
|
32
|
+
* condition value is always `null`). The filter UI renders no value input
|
|
33
|
+
* for such conditions.
|
|
34
|
+
*/
|
|
35
|
+
export function isValuelessFilterOperator(operator: string | null): boolean {
|
|
36
|
+
return operator === 'empty' || operator === 'notEmpty'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the condition tuple `[field, operator, value]` no longer
|
|
41
|
+
* carries an effective filter and should be cleared. A condition clears
|
|
42
|
+
* when its value is nullish or an empty array — scalar operators (e.g. a
|
|
43
|
+
* boolean `=`) carry a single value, so `false` is a real value to keep;
|
|
44
|
+
* only `null` clears. Valueless operators are complete without a value,
|
|
45
|
+
* so they never count as cleared.
|
|
46
|
+
*/
|
|
47
|
+
export function isClearedFilterCondition(condition: any[]): boolean {
|
|
48
|
+
if (isValuelessFilterOperator(condition[1])) {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
return condition[2] == null || (Array.isArray(condition[2]) && condition[2].length === 0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalizes the value of valueless operator conditions to `null` across
|
|
56
|
+
* a filters array, groups included. Filters can enter from outside the
|
|
57
|
+
* form UI — the `filters` prop or a hand-edited URL query — where a
|
|
58
|
+
* valueless condition may carry a stray value, so the catalog normalizes
|
|
59
|
+
* them before sending a search, preserving the `[field, operator, null]`
|
|
60
|
+
* wire shape.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeValuelessFilterConditions(filters: any[]): any[] {
|
|
63
|
+
return filters.map((f) => {
|
|
64
|
+
if (!Array.isArray(f)) {
|
|
65
|
+
return f
|
|
66
|
+
}
|
|
67
|
+
const entry = f as any[]
|
|
68
|
+
if (entry[0] === '$and' || entry[0] === '$or') {
|
|
69
|
+
return [entry[0], normalizeValuelessFilterConditions(entry[1] ?? [])]
|
|
70
|
+
}
|
|
71
|
+
return isValuelessFilterOperator(entry[1]) && entry[2] !== null
|
|
72
|
+
? [entry[0], entry[1], null]
|
|
73
|
+
: f
|
|
74
|
+
})
|
|
24
75
|
}
|
|
@@ -9,6 +9,7 @@ import { usePower } from '../../../composables/Power'
|
|
|
9
9
|
import { useSnackbars } from '../../../stores/Snackbars'
|
|
10
10
|
import { day } from '../../../support/Day'
|
|
11
11
|
import { type FieldData } from '../FieldData'
|
|
12
|
+
import { isClearedFilterCondition, normalizeValuelessFilterConditions } from '../FilterOperator'
|
|
12
13
|
import { type LensQuery, type LensQuerySort } from '../LensQuery'
|
|
13
14
|
import { type LensResult } from '../LensResult'
|
|
14
15
|
import { useCatalogUrlQuerySync } from '../composables/CatalogUrlQuerySync'
|
|
@@ -676,13 +677,16 @@ function withoutIndexField(fields: string[]): string[] {
|
|
|
676
677
|
}
|
|
677
678
|
|
|
678
679
|
// Create lens filters option by combining query (free search) filters,
|
|
679
|
-
// user selected filters, and fixed filters.
|
|
680
|
+
// user selected filters, and fixed filters. Filters coming from outside
|
|
681
|
+
// the form UI (the `filters` prop, a hand-edited URL query) may carry a
|
|
682
|
+
// stray value on a valueless condition, so normalize those to `null`
|
|
683
|
+
// before they reach the wire.
|
|
680
684
|
function createInputFilters(queryFilters: any[], filters: any[]) {
|
|
681
|
-
return [
|
|
685
|
+
return normalizeValuelessFilterConditions([
|
|
682
686
|
...(props.fixedFilters ?? []),
|
|
683
687
|
queryFilters,
|
|
684
688
|
...filters
|
|
685
|
-
].filter((f) => f?.length > 0)
|
|
689
|
+
].filter((f) => f?.length > 0))
|
|
686
690
|
}
|
|
687
691
|
|
|
688
692
|
// Re-run the search for the new query state, dropping any stashed
|
|
@@ -723,21 +727,14 @@ function onInlineFilterUpdated(filter: any[]) {
|
|
|
723
727
|
emit('filters-updated', _filters.value)
|
|
724
728
|
}
|
|
725
729
|
|
|
726
|
-
// A filter value "clears" the filter when it's nullish or an empty
|
|
727
|
-
// array. Scalar operators (e.g. a boolean `=`) carry a single value, so
|
|
728
|
-
// `false` is a real value to keep — only `null` clears.
|
|
729
|
-
function isEmptyFilterValue(value: any): boolean {
|
|
730
|
-
return value == null || (Array.isArray(value) && value.length === 0)
|
|
731
|
-
}
|
|
732
|
-
|
|
733
730
|
function applyNewFilter(filter: any[]) {
|
|
734
|
-
if (!
|
|
731
|
+
if (!isClearedFilterCondition(filter)) {
|
|
735
732
|
_filters.value.push(filter)
|
|
736
733
|
}
|
|
737
734
|
}
|
|
738
735
|
|
|
739
736
|
function replaceFilter(index: number, filter: any[]) {
|
|
740
|
-
if (
|
|
737
|
+
if (isClearedFilterCondition(filter)) {
|
|
741
738
|
_filters.value.splice(index, 1)
|
|
742
739
|
return
|
|
743
740
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { computedAsync } from '@vueuse/core'
|
|
3
3
|
import { computed } from 'vue'
|
|
4
4
|
import { type FieldData } from '../FieldData'
|
|
5
|
-
import { type FilterOperator, FilterOperatorLabelDict } from '../FilterOperator'
|
|
5
|
+
import { type FilterOperator, FilterOperatorLabelDict, isValuelessFilterOperator } from '../FilterOperator'
|
|
6
6
|
import { useFieldFactory } from '../composables/FieldFactory'
|
|
7
7
|
|
|
8
8
|
export interface Props {
|
|
@@ -29,7 +29,15 @@ const field = computed(() => {
|
|
|
29
29
|
return fieldData ? fieldFactory.make(fieldData) : null
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
+
// A valueless condition (the `empty` / `notEmpty` operators) has no
|
|
33
|
+
// value to show, so the chip hides the value segment entirely and never
|
|
34
|
+
// resolves the input.
|
|
35
|
+
const valueless = computed(() => isValuelessFilterOperator(props.condition.operator))
|
|
36
|
+
|
|
32
37
|
const input = computed(() => {
|
|
38
|
+
if (valueless.value) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
33
41
|
return field.value?.filterInputByOperator(props.condition.operator) ?? null
|
|
34
42
|
})
|
|
35
43
|
|
|
@@ -54,7 +62,7 @@ const valueText = computedAsync(async () => {
|
|
|
54
62
|
<div class="LensCatalogStateFilterCondition">
|
|
55
63
|
<div class="field">{{ fieldText }}</div>
|
|
56
64
|
<div class="operator">{{ operatorText }}</div>
|
|
57
|
-
<div class="value">
|
|
65
|
+
<div v-if="!valueless" class="value">
|
|
58
66
|
{{ valueText }}
|
|
59
67
|
</div>
|
|
60
68
|
</div>
|
|
@@ -11,6 +11,7 @@ import { useData } from '../../../composables/Data'
|
|
|
11
11
|
import { useLang, useTrans } from '../../../composables/Lang'
|
|
12
12
|
import { useValidation } from '../../../composables/Validation'
|
|
13
13
|
import { type FieldData } from '../FieldData'
|
|
14
|
+
import { isValuelessFilterOperator } from '../FilterOperator'
|
|
14
15
|
import { useFieldFactory } from '../composables/FieldFactory'
|
|
15
16
|
import { type FilterCondition } from './LensFormFilterCondition.vue'
|
|
16
17
|
import LensFormFilterGroup, { type FilterGroup } from './LensFormFilterGroup.vue'
|
|
@@ -96,7 +97,7 @@ const fieldOptions = computed(() => {
|
|
|
96
97
|
// }
|
|
97
98
|
function lensFiltersToGroup() {
|
|
98
99
|
const conditions = props.filters.length > 0
|
|
99
|
-
?
|
|
100
|
+
? pruneUneditableConditions(props.filters.map(lensConditionToCondition))
|
|
100
101
|
: []
|
|
101
102
|
|
|
102
103
|
return {
|
|
@@ -105,15 +106,32 @@ function lensFiltersToGroup() {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
// Drop conditions
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
109
|
+
// Drop conditions the form cannot render or edit: ones referencing a
|
|
110
|
+
// field absent from `props.fields` (a stale saved filter pointing at a
|
|
111
|
+
// field that no longer exists), and ones whose operator the field does
|
|
112
|
+
// not offer (e.g. a hand-edited URL pairing `empty` with a field that
|
|
113
|
+
// doesn't declare support). Mounting such a condition would throw in its
|
|
114
|
+
// input resolution, and it would otherwise pass validation with a null
|
|
115
|
+
// input and silently re-emit the stale filter on Apply. Groups left
|
|
116
|
+
// empty by pruning are removed too.
|
|
117
|
+
function pruneUneditableConditions(conditions: any[]): any[] {
|
|
114
118
|
return conditions
|
|
115
|
-
.map((c) => ('connector' in c ? { ...c, conditions:
|
|
116
|
-
.filter((c) => ('connector' in c ? c.conditions.length > 0 : c
|
|
119
|
+
.map((c) => ('connector' in c ? { ...c, conditions: pruneUneditableConditions(c.conditions) } : c))
|
|
120
|
+
.filter((c) => ('connector' in c ? c.conditions.length > 0 : isEditableCondition(c)))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isEditableCondition(c: any): boolean {
|
|
124
|
+
if (c.field === null) {
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
const fieldData = props.fields[c.field]
|
|
128
|
+
if (!fieldData) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
if (c.operator === null) {
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
return fieldFactory.make(fieldData).availableFilterOperators().includes(c.operator)
|
|
117
135
|
}
|
|
118
136
|
|
|
119
137
|
function lensConditionToCondition(filter: any[]) {
|
|
@@ -142,7 +160,10 @@ function groupToLensFilters(conditions: (FilterGroup | FilterCondition)[]): any[
|
|
|
142
160
|
if ('connector' in c) {
|
|
143
161
|
return [c.connector, groupToLensFilters(c.conditions)]
|
|
144
162
|
}
|
|
145
|
-
|
|
163
|
+
// A valueless condition always emits `null`: an initially loaded
|
|
164
|
+
// condition may carry a stray value (e.g. from a hand-edited URL)
|
|
165
|
+
// that the operator-change cast never saw.
|
|
166
|
+
return [c.field, c.operator, isValuelessFilterOperator(c.operator) ? null : c.value]
|
|
146
167
|
})
|
|
147
168
|
}
|
|
148
169
|
|
|
@@ -9,7 +9,7 @@ import { useValidation } from '../../../composables/Validation'
|
|
|
9
9
|
import { type Option } from '../../../support/InputDropdown'
|
|
10
10
|
import { required } from '../../../validation/rules'
|
|
11
11
|
import { type FieldData } from '../FieldData'
|
|
12
|
-
import { type FilterOperator, FilterOperatorLabelDict } from '../FilterOperator'
|
|
12
|
+
import { type FilterOperator, FilterOperatorLabelDict, isValuelessFilterOperator } from '../FilterOperator'
|
|
13
13
|
import { useFieldFactory } from '../composables/FieldFactory'
|
|
14
14
|
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
15
15
|
|
|
@@ -66,8 +66,12 @@ const operatorOptions = computed(() => {
|
|
|
66
66
|
|
|
67
67
|
const input = ref(null) as Ref<FilterInput | null>
|
|
68
68
|
|
|
69
|
+
// A valueless condition (the `empty` / `notEmpty` operators) has no
|
|
70
|
+
// value control: the value cell renders nothing instead of an input.
|
|
71
|
+
const valueless = computed(() => isValuelessFilterOperator(model.value.operator))
|
|
72
|
+
|
|
69
73
|
const inputComponent = computed(() => {
|
|
70
|
-
return input.value ? markRaw(input.value.component()) : null
|
|
74
|
+
return input.value && !valueless.value ? markRaw(input.value.component()) : null
|
|
71
75
|
})
|
|
72
76
|
|
|
73
77
|
const { validation, reset: resetValidation } = useValidation(() => ({
|
|
@@ -160,7 +164,7 @@ async function resolveInput() {
|
|
|
160
164
|
</div>
|
|
161
165
|
<div class="value">
|
|
162
166
|
<div v-if="input === null" class="skeleton" />
|
|
163
|
-
<Suspense v-else :key="model.field ?? '__empty__'">
|
|
167
|
+
<Suspense v-else-if="!valueless" :key="model.field ?? '__empty__'">
|
|
164
168
|
<component :is="inputComponent" v-model="model.value" />
|
|
165
169
|
<template #fallback>
|
|
166
170
|
<div class="resolving">
|
|
@@ -17,6 +17,13 @@ import * as RuleMapper from '../validation/RuleMapper'
|
|
|
17
17
|
*/
|
|
18
18
|
const DEFAULT_COLUMN_WIDTH = 168
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The option value of the pinned "empty" option in column filter
|
|
22
|
+
* dropdowns. A sentinel that can never collide with real option values
|
|
23
|
+
* coming from backend resources.
|
|
24
|
+
*/
|
|
25
|
+
export const EmptyFilterOption = Symbol('empty-filter-option')
|
|
26
|
+
|
|
20
27
|
export abstract class Field<T extends FieldData> {
|
|
21
28
|
/**
|
|
22
29
|
* The field context, that holds global app context
|
|
@@ -163,6 +170,35 @@ export abstract class Field<T extends FieldData> {
|
|
|
163
170
|
return null
|
|
164
171
|
}
|
|
165
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Whether an `empty` condition for the given key is present in the
|
|
175
|
+
* filters array.
|
|
176
|
+
*/
|
|
177
|
+
protected emptyFilterActiveFor(key: string, filters: any[]): boolean {
|
|
178
|
+
return filters.some((f) => f[0] === key && f[1] === 'empty')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether the backend declares the valueless `empty` / `notEmpty`
|
|
183
|
+
* filter operators for this field. See `FieldDataBase.emptyOperators`.
|
|
184
|
+
*/
|
|
185
|
+
protected emptyOperatorsEnabled(): boolean {
|
|
186
|
+
return !!this.data.emptyOperators
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The label for the pinned "empty" option in the column filter
|
|
191
|
+
* dropdown. The backend's declaration may customize it per language
|
|
192
|
+
* (e.g. "No assignees"); the fallback is a generic localized "None".
|
|
193
|
+
*/
|
|
194
|
+
protected emptyOptionLabel(): string {
|
|
195
|
+
const declaration = this.data.emptyOperators
|
|
196
|
+
const label = typeof declaration === 'object' && declaration !== null
|
|
197
|
+
? (this.ctx.lang === 'ja' ? declaration.labelJa : declaration.labelEn)
|
|
198
|
+
: null
|
|
199
|
+
return label ?? (this.ctx.lang === 'ja' ? 'なし' : 'None')
|
|
200
|
+
}
|
|
201
|
+
|
|
166
202
|
/**
|
|
167
203
|
* Returns the table cell definition for the field.
|
|
168
204
|
*/
|
|
@@ -7,9 +7,10 @@ import { type TableCell } from '../../../composables/Table'
|
|
|
7
7
|
import { type RelatedManyFieldData } from '../FieldData'
|
|
8
8
|
import { type FilterOperator } from '../FilterOperator'
|
|
9
9
|
import { type ResourceFetcher } from '../ResourceFetcher'
|
|
10
|
+
import { EmptyFilterInput } from '../filter-inputs/EmptyFilterInput'
|
|
10
11
|
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
11
12
|
import { SelectFilterInput } from '../filter-inputs/SelectFilterInput'
|
|
12
|
-
import { Field } from './Field'
|
|
13
|
+
import { EmptyFilterOption, Field } from './Field'
|
|
13
14
|
|
|
14
15
|
export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
15
16
|
fetcher: ResourceFetcher
|
|
@@ -31,7 +32,11 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
31
32
|
return null
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
const
|
|
35
|
+
const emptyActive = this.emptyFilterActiveFor(this.data.key, filters)
|
|
36
|
+
|
|
37
|
+
const selected = emptyActive
|
|
38
|
+
? [EmptyFilterOption]
|
|
39
|
+
: this.inFilterValueFor(this.data.key, filters)
|
|
35
40
|
|
|
36
41
|
const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
|
|
37
42
|
const data = key ? res[key] : res
|
|
@@ -50,12 +55,26 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
50
55
|
value: this.resolveValue(item[this.data.filterKey])
|
|
51
56
|
})
|
|
52
57
|
|
|
58
|
+
// The pinned "empty" option filters records with no related items at
|
|
59
|
+
// all. It is mutually exclusive with the value options: selecting it
|
|
60
|
+
// swaps the whole condition to a valueless `empty`, and selecting a
|
|
61
|
+
// value swaps back to `in`.
|
|
62
|
+
if (this.emptyOperatorsEnabled()) {
|
|
63
|
+
options.unshift({ label: this.emptyOptionLabel(), value: EmptyFilterOption })
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
return {
|
|
54
67
|
type: 'filter',
|
|
55
68
|
search: true,
|
|
56
69
|
selected,
|
|
57
70
|
options,
|
|
58
|
-
onClick: (v) => {
|
|
71
|
+
onClick: (v) => {
|
|
72
|
+
if (v === EmptyFilterOption) {
|
|
73
|
+
onFilterUpdated?.(emptyActive ? [this.data.key, 'in', []] : [this.data.key, 'empty', null])
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
onFilterUpdated?.([this.data.key, 'in', xor(emptyActive ? [] : selected, [v])])
|
|
77
|
+
}
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
|
|
@@ -105,11 +124,20 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
105
124
|
const selectOne = new SelectFilterInput().options(optionsResolver)
|
|
106
125
|
const selectMany = new SelectFilterInput().options(optionsResolver).multiple()
|
|
107
126
|
|
|
108
|
-
|
|
127
|
+
const filters: Partial<Record<FilterOperator, FilterInput>> = {
|
|
109
128
|
'=': selectOne,
|
|
110
129
|
'!=': selectOne,
|
|
111
130
|
'in': selectMany
|
|
112
131
|
}
|
|
132
|
+
|
|
133
|
+
// The valueless operators are strictly opt-in via the backend's field
|
|
134
|
+
// definition — see `FieldDataBase.emptyOperators`.
|
|
135
|
+
if (this.data.emptyOperators) {
|
|
136
|
+
filters.empty = new EmptyFilterInput()
|
|
137
|
+
filters.notEmpty = new EmptyFilterInput()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return filters
|
|
113
141
|
}
|
|
114
142
|
|
|
115
143
|
// Lens id fields serialize as `{ value, display, path? }`; filters need the
|
|
@@ -6,9 +6,10 @@ import { type TableCell } from '../../../composables/Table'
|
|
|
6
6
|
import { type RelatedOneFieldData } from '../FieldData'
|
|
7
7
|
import { type FilterOperator } from '../FilterOperator'
|
|
8
8
|
import { type ResourceFetcher } from '../ResourceFetcher'
|
|
9
|
+
import { EmptyFilterInput } from '../filter-inputs/EmptyFilterInput'
|
|
9
10
|
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
10
11
|
import { SelectFilterInput } from '../filter-inputs/SelectFilterInput'
|
|
11
|
-
import { Field } from './Field'
|
|
12
|
+
import { EmptyFilterOption, Field } from './Field'
|
|
12
13
|
|
|
13
14
|
export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
14
15
|
fetcher: ResourceFetcher
|
|
@@ -30,7 +31,11 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
|
30
31
|
return null
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
+
const emptyActive = this.emptyFilterActiveFor(this.data.key, filters)
|
|
35
|
+
|
|
36
|
+
const selected = emptyActive
|
|
37
|
+
? [EmptyFilterOption]
|
|
38
|
+
: this.inFilterValueFor(this.data.key, filters)
|
|
34
39
|
|
|
35
40
|
const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
|
|
36
41
|
const data = key ? res[key] : res
|
|
@@ -49,12 +54,26 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
|
49
54
|
value: this.resolveValue(item[this.data.filterKey])
|
|
50
55
|
})
|
|
51
56
|
|
|
57
|
+
// The pinned "empty" option filters records with no related record.
|
|
58
|
+
// It is mutually exclusive with the value options: selecting it swaps
|
|
59
|
+
// the whole condition to a valueless `empty`, and selecting a value
|
|
60
|
+
// swaps back to `in`.
|
|
61
|
+
if (this.emptyOperatorsEnabled()) {
|
|
62
|
+
options.unshift({ label: this.emptyOptionLabel(), value: EmptyFilterOption })
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
return {
|
|
53
66
|
type: 'filter',
|
|
54
67
|
search: true,
|
|
55
68
|
selected,
|
|
56
69
|
options,
|
|
57
|
-
onClick: (v) => {
|
|
70
|
+
onClick: (v) => {
|
|
71
|
+
if (v === EmptyFilterOption) {
|
|
72
|
+
onFilterUpdated?.(emptyActive ? [this.data.key, 'in', []] : [this.data.key, 'empty', null])
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
onFilterUpdated?.([this.data.key, 'in', xor(emptyActive ? [] : selected, [v])])
|
|
76
|
+
}
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
79
|
|
|
@@ -107,11 +126,20 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
|
107
126
|
const selectOne = new SelectFilterInput().options(optionsResolver)
|
|
108
127
|
const selectMany = new SelectFilterInput().options(optionsResolver).multiple()
|
|
109
128
|
|
|
110
|
-
|
|
129
|
+
const filters: Partial<Record<FilterOperator, FilterInput>> = {
|
|
111
130
|
'=': selectOne,
|
|
112
131
|
'!=': selectOne,
|
|
113
132
|
'in': selectMany
|
|
114
133
|
}
|
|
134
|
+
|
|
135
|
+
// The valueless operators are strictly opt-in via the backend's field
|
|
136
|
+
// definition — see `FieldDataBase.emptyOperators`.
|
|
137
|
+
if (this.data.emptyOperators) {
|
|
138
|
+
filters.empty = new EmptyFilterInput()
|
|
139
|
+
filters.notEmpty = new EmptyFilterInput()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return filters
|
|
115
143
|
}
|
|
116
144
|
|
|
117
145
|
// Lens id fields serialize as `{ value, display, path? }`; filters need the
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ValidationArgs } from '@vuelidate/core'
|
|
2
|
+
import { FilterInput } from './FilterInput'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The filter input for the valueless `empty` / `notEmpty` operators (see
|
|
6
|
+
* `isValuelessFilterOperator`). The condition is complete with just the
|
|
7
|
+
* field and the operator, so this input requires no value, renders no
|
|
8
|
+
* control, and always normalizes the condition value back to `null`.
|
|
9
|
+
*/
|
|
10
|
+
export class EmptyFilterInput extends FilterInput {
|
|
11
|
+
rules(): Record<string, ValidationArgs> {
|
|
12
|
+
return {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
castValue(_value: any): any {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async valueToText(_value: any): Promise<string> {
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
component(): any {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
package/lib/http/Http.ts
CHANGED
|
@@ -3,9 +3,107 @@ import { parse as parseCookie } from '@tinyhttp/cookie'
|
|
|
3
3
|
import { FetchError, type FetchOptions, type FetchResponse } from 'ofetch'
|
|
4
4
|
import { stringify } from 'qs'
|
|
5
5
|
import { saveAs } from '../support/File'
|
|
6
|
-
import { objectToFormData } from '../support/Http'
|
|
6
|
+
import { getHttpStatusCode, objectToFormData } from '../support/Http'
|
|
7
7
|
|
|
8
8
|
type Config = ReturnType<typeof import('../stores/HttpConfig').useHttpConfig>
|
|
9
|
+
type BuiltRequest = [url: string, options: FetchOptions]
|
|
10
|
+
|
|
11
|
+
export type HttpRequestOptions = FetchOptions & {
|
|
12
|
+
/**
|
|
13
|
+
* Prevent this request from recursively recovering the application session.
|
|
14
|
+
* Authentication bootstrap requests should disable session recovery.
|
|
15
|
+
*/
|
|
16
|
+
sessionRecovery?: false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const xsrfRefreshes = new WeakMap<Config, Promise<void>>()
|
|
20
|
+
const sessionRecoveries = new WeakMap<Config, Promise<boolean>>()
|
|
21
|
+
const sessionRecoveryGenerations = new WeakMap<Config, number>()
|
|
22
|
+
const relativeUrlBases = [
|
|
23
|
+
new URL('http://a.sefirot.invalid'),
|
|
24
|
+
new URL('http://b.sefirot.invalid')
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function getSessionRecoveryGeneration(config: Config): number {
|
|
28
|
+
return sessionRecoveryGenerations.get(config) ?? 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isLocalUrlReference(url: string): boolean {
|
|
32
|
+
if (URL.canParse(url)) {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return relativeUrlBases.every(
|
|
37
|
+
(base) => URL.canParse(url, base)
|
|
38
|
+
&& new URL(url, base).origin === base.origin
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isReplayableBody(body: unknown): boolean {
|
|
43
|
+
if (body == null) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof body !== 'object') {
|
|
48
|
+
return ['string', 'number', 'boolean'].includes(typeof body)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
Array.isArray(body)
|
|
53
|
+
|| body instanceof Blob
|
|
54
|
+
|| body instanceof FormData
|
|
55
|
+
|| body instanceof URLSearchParams
|
|
56
|
+
|| body instanceof ArrayBuffer
|
|
57
|
+
|| ArrayBuffer.isView(body)
|
|
58
|
+
) {
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const source = body as {
|
|
63
|
+
constructor?: { name?: string }
|
|
64
|
+
pipeTo?: unknown
|
|
65
|
+
pipe?: unknown
|
|
66
|
+
next?: unknown
|
|
67
|
+
toJSON?: unknown
|
|
68
|
+
[Symbol.iterator]?: unknown
|
|
69
|
+
[Symbol.asyncIterator]?: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
typeof source.pipeTo === 'function'
|
|
74
|
+
|| typeof source.pipe === 'function'
|
|
75
|
+
|| typeof source.next === 'function'
|
|
76
|
+
|| typeof source[Symbol.iterator] === 'function'
|
|
77
|
+
|| typeof source[Symbol.asyncIterator] === 'function'
|
|
78
|
+
) {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return source.constructor?.name === 'Object'
|
|
83
|
+
|| typeof source.toJSON === 'function'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function runSingleFlight<T>(
|
|
87
|
+
activeRequests: WeakMap<Config, Promise<T>>,
|
|
88
|
+
config: Config,
|
|
89
|
+
request: () => Promise<T>
|
|
90
|
+
): Promise<T> {
|
|
91
|
+
const activeRequest = activeRequests.get(config)
|
|
92
|
+
if (activeRequest) {
|
|
93
|
+
return activeRequest
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nextRequest = Promise.resolve().then(request)
|
|
97
|
+
activeRequests.set(config, nextRequest)
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return await nextRequest
|
|
101
|
+
} finally {
|
|
102
|
+
if (activeRequests.get(config) === nextRequest) {
|
|
103
|
+
activeRequests.delete(config)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
9
107
|
|
|
10
108
|
export class Http {
|
|
11
109
|
private config: Config
|
|
@@ -22,16 +120,77 @@ export class Http {
|
|
|
22
120
|
let xsrfToken = parseCookie(document.cookie)['XSRF-TOKEN']
|
|
23
121
|
|
|
24
122
|
if (!xsrfToken) {
|
|
25
|
-
await this.
|
|
123
|
+
await this.refreshXsrfToken()
|
|
26
124
|
xsrfToken = parseCookie(document.cookie)['XSRF-TOKEN']
|
|
27
125
|
}
|
|
28
126
|
|
|
29
127
|
return xsrfToken
|
|
30
128
|
}
|
|
31
129
|
|
|
32
|
-
private
|
|
130
|
+
private isApplicationRequest(url: string, options: HttpRequestOptions = {}): boolean {
|
|
131
|
+
const pageUrl = typeof location === 'undefined' ? undefined : location.href
|
|
132
|
+
const applicationBaseUrl = this.config.baseUrl || pageUrl
|
|
133
|
+
const requestBaseUrl = Object.hasOwn(options, 'baseURL')
|
|
134
|
+
? options.baseURL
|
|
135
|
+
: this.config.baseUrl
|
|
136
|
+
if (
|
|
137
|
+
!applicationBaseUrl
|
|
138
|
+
|| (!pageUrl && isLocalUrlReference(applicationBaseUrl))
|
|
139
|
+
) {
|
|
140
|
+
return isLocalUrlReference(url)
|
|
141
|
+
&& (requestBaseUrl == null || isLocalUrlReference(requestBaseUrl))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const applicationUrl = new URL(applicationBaseUrl, pageUrl)
|
|
146
|
+
const resolvedRequestBaseUrl = requestBaseUrl
|
|
147
|
+
? new URL(requestBaseUrl, pageUrl)
|
|
148
|
+
: pageUrl
|
|
149
|
+
|
|
150
|
+
return new URL(url, resolvedRequestBaseUrl).origin === applicationUrl.origin
|
|
151
|
+
} catch {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async refreshXsrfToken(): Promise<void> {
|
|
157
|
+
const xsrfUrl = this.config.xsrfUrl
|
|
158
|
+
if (!xsrfUrl) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return runSingleFlight(xsrfRefreshes, this.config, async () => {
|
|
163
|
+
await this.config.client(...(await this.buildRequest(
|
|
164
|
+
xsrfUrl,
|
|
165
|
+
{ method: 'HEAD' }
|
|
166
|
+
)))
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async recoverSession(): Promise<boolean> {
|
|
171
|
+
const recoverSession = this.config.recoverSession
|
|
172
|
+
if (!recoverSession) {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return runSingleFlight(sessionRecoveries, this.config, async () => {
|
|
177
|
+
const recovered = await recoverSession()
|
|
178
|
+
if (recovered) {
|
|
179
|
+
sessionRecoveryGenerations.set(
|
|
180
|
+
this.config,
|
|
181
|
+
getSessionRecoveryGeneration(this.config) + 1
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return recovered
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async buildRequest(url: string, _options: HttpRequestOptions = {}): Promise<BuiltRequest> {
|
|
33
189
|
const { method, params, query, ...options } = _options
|
|
34
|
-
|
|
190
|
+
delete options.sessionRecovery
|
|
191
|
+
|
|
192
|
+
const xsrfToken = this.isApplicationRequest(url, _options)
|
|
193
|
+
&& ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || '')
|
|
35
194
|
&& (await this.ensureXsrfToken())
|
|
36
195
|
|
|
37
196
|
const queryString = stringify(
|
|
@@ -57,25 +216,90 @@ export class Http {
|
|
|
57
216
|
]
|
|
58
217
|
}
|
|
59
218
|
|
|
60
|
-
private async
|
|
61
|
-
|
|
219
|
+
private async execute<T>(
|
|
220
|
+
url: string,
|
|
221
|
+
options: HttpRequestOptions,
|
|
222
|
+
send: (request: BuiltRequest) => Promise<T>
|
|
223
|
+
): Promise<T> {
|
|
224
|
+
const applicationRequest = this.isApplicationRequest(url, options)
|
|
225
|
+
const replayable = isReplayableBody(options.body)
|
|
226
|
+
|
|
227
|
+
const attempt = async (
|
|
228
|
+
xsrfRecovered = false,
|
|
229
|
+
sessionRecovered = false
|
|
230
|
+
): Promise<T> => {
|
|
231
|
+
const request = await this.buildRequest(url, options)
|
|
232
|
+
const sessionRecoveryGeneration = getSessionRecoveryGeneration(this.config)
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
return await send(request)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const status = getHttpStatusCode(error)
|
|
238
|
+
|
|
239
|
+
if (
|
|
240
|
+
status === 419
|
|
241
|
+
&& applicationRequest
|
|
242
|
+
&& replayable
|
|
243
|
+
&& this.config.xsrfUrl
|
|
244
|
+
&& !xsrfRecovered
|
|
245
|
+
) {
|
|
246
|
+
await this.refreshXsrfToken()
|
|
247
|
+
return attempt(true, sessionRecovered)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const canRecoverSession =
|
|
251
|
+
applicationRequest
|
|
252
|
+
&& replayable
|
|
253
|
+
&& options.sessionRecovery !== false
|
|
254
|
+
&& this.config.recoverSession != null
|
|
255
|
+
&& !sessionRecovered
|
|
256
|
+
|
|
257
|
+
if (status === 401 && canRecoverSession) {
|
|
258
|
+
const alreadyRecovered =
|
|
259
|
+
sessionRecoveryGeneration !== getSessionRecoveryGeneration(this.config)
|
|
260
|
+
|
|
261
|
+
if (alreadyRecovered || await this.recoverSession()) {
|
|
262
|
+
return attempt(xsrfRecovered, true)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return attempt()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async performRequest<T>(url: string, options: HttpRequestOptions = {}): Promise<T> {
|
|
274
|
+
return this.execute(
|
|
275
|
+
url,
|
|
276
|
+
options,
|
|
277
|
+
(request) => this.config.client(...request)
|
|
278
|
+
)
|
|
62
279
|
}
|
|
63
280
|
|
|
64
|
-
private async performRequestRaw<T>(
|
|
281
|
+
private async performRequestRaw<T>(
|
|
282
|
+
url: string,
|
|
283
|
+
options: HttpRequestOptions = {}
|
|
284
|
+
): Promise<FetchResponse<T>> {
|
|
65
285
|
// 'raw' is unavailable in useRequestFetch() during SSR, but performRequestRaw is only
|
|
66
286
|
// called by download, which runs client-side, so asserting raw's existence is safe
|
|
67
|
-
return this.
|
|
287
|
+
return this.execute(
|
|
288
|
+
url,
|
|
289
|
+
options,
|
|
290
|
+
(request) => this.config.client.raw!(...request)
|
|
291
|
+
)
|
|
68
292
|
}
|
|
69
293
|
|
|
70
|
-
async get<T = any>(url: string, options?:
|
|
294
|
+
async get<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
71
295
|
return this.performRequest<T>(url, { method: 'GET', ...options })
|
|
72
296
|
}
|
|
73
297
|
|
|
74
|
-
async head<T = any>(url: string, options?:
|
|
298
|
+
async head<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
75
299
|
return this.performRequest<T>(url, { method: 'HEAD', ...options })
|
|
76
300
|
}
|
|
77
301
|
|
|
78
|
-
async post<T = any>(url: string, body?: any, options?:
|
|
302
|
+
async post<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
79
303
|
if (body && !(body instanceof FormData)) {
|
|
80
304
|
let hasFile = false
|
|
81
305
|
|
|
@@ -99,23 +323,23 @@ export class Http {
|
|
|
99
323
|
return this.performRequest<T>(url, { method: 'POST', body, ...options })
|
|
100
324
|
}
|
|
101
325
|
|
|
102
|
-
async put<T = any>(url: string, body?: any, options?:
|
|
326
|
+
async put<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
103
327
|
return this.performRequest<T>(url, { method: 'PUT', body, ...options })
|
|
104
328
|
}
|
|
105
329
|
|
|
106
|
-
async patch<T = any>(url: string, body?: any, options?:
|
|
330
|
+
async patch<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
107
331
|
return this.performRequest<T>(url, { method: 'PATCH', body, ...options })
|
|
108
332
|
}
|
|
109
333
|
|
|
110
|
-
async delete<T = any>(url: string, options?:
|
|
334
|
+
async delete<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
111
335
|
return this.performRequest<T>(url, { method: 'DELETE', ...options })
|
|
112
336
|
}
|
|
113
337
|
|
|
114
|
-
async upload<T = any>(url: string, body?: any, options?:
|
|
338
|
+
async upload<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
115
339
|
return this.post<T>(url, objectToFormData(body), options)
|
|
116
340
|
}
|
|
117
341
|
|
|
118
|
-
async download(url: string, options?:
|
|
342
|
+
async download(url: string, options?: HttpRequestOptions): Promise<void> {
|
|
119
343
|
const { _data: blob, headers } =
|
|
120
344
|
await this.performRequestRaw<Blob>(url, { method: 'GET', responseType: 'blob', ...options })
|
|
121
345
|
|
package/lib/stores/HttpConfig.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface HttpOptions {
|
|
|
15
15
|
baseUrl?: string
|
|
16
16
|
xsrfUrl?: string | false
|
|
17
17
|
client?: HttpClient
|
|
18
|
+
recoverSession?: false | (() => Awaitable<boolean>)
|
|
18
19
|
lang?: Lang
|
|
19
20
|
payloadKey?: string
|
|
20
21
|
headers?: () => Awaitable<Record<string, string>>
|
|
@@ -25,6 +26,7 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
25
26
|
const baseUrl = ref<string | undefined>(undefined)
|
|
26
27
|
const xsrfUrl = ref<string | false>('/api/csrf-cookie')
|
|
27
28
|
const client = ref<HttpClient>(ofetch)
|
|
29
|
+
const recoverSession = ref<(() => Awaitable<boolean>) | undefined>(undefined)
|
|
28
30
|
const lang = ref<Lang | undefined>(undefined)
|
|
29
31
|
const payloadKey = ref<string>('__payload__')
|
|
30
32
|
const headers = ref<() => Awaitable<Record<string, string>>>(async () => ({}))
|
|
@@ -34,6 +36,9 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
34
36
|
if (options.baseUrl != null) { baseUrl.value = options.baseUrl }
|
|
35
37
|
if (options.xsrfUrl != null) { xsrfUrl.value = options.xsrfUrl }
|
|
36
38
|
if (options.client != null) { client.value = options.client }
|
|
39
|
+
if (options.recoverSession != null) {
|
|
40
|
+
recoverSession.value = options.recoverSession || undefined
|
|
41
|
+
}
|
|
37
42
|
if (options.lang != null) { lang.value = options.lang }
|
|
38
43
|
if (options.payloadKey != null) { payloadKey.value = options.payloadKey }
|
|
39
44
|
if (options.headers != null) { headers.value = options.headers }
|
|
@@ -44,6 +49,7 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
44
49
|
baseUrl: computed(() => baseUrl.value),
|
|
45
50
|
xsrfUrl: computed(() => xsrfUrl.value),
|
|
46
51
|
client: computed(() => client.value),
|
|
52
|
+
recoverSession: computed(() => recoverSession.value),
|
|
47
53
|
lang: computed(() => lang.value),
|
|
48
54
|
payloadKey: computed(() => payloadKey.value),
|
|
49
55
|
headers: computed(() => headers.value),
|