@globalbrain/sefirot 4.63.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.
@@ -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 (!isEmptyFilterValue(filter[2])) {
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 (isEmptyFilterValue(filter[2])) {
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
- ? pruneMissingFields(props.filters.map(lensConditionToCondition))
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 that reference a field absent from `props.fields` (for
109
- // example a stale saved filter pointing at a field that no longer
110
- // exists). Such a condition can't be rendered or edited, and would
111
- // otherwise pass validation with a null input and silently re-emit the
112
- // stale filter on Apply. Groups left empty by pruning are removed too.
113
- function pruneMissingFields(conditions: any[]): any[] {
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: pruneMissingFields(c.conditions) } : c))
116
- .filter((c) => ('connector' in c ? c.conditions.length > 0 : c.field === null || props.fields[c.field]))
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
- return [c.field, c.operator, c.value]
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 selected = this.inFilterValueFor(this.data.key, filters)
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) => { onFilterUpdated?.([this.data.key, 'in', xor(selected, [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
- return {
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 selected = this.inFilterValueFor(this.data.key, filters)
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) => { onFilterUpdated?.([this.data.key, 'in', xor(selected, [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
- return {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.63.0",
3
+ "version": "4.64.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",