@globalbrain/sefirot 4.44.0 → 4.46.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.
Files changed (32) hide show
  1. package/README.md +5 -5
  2. package/config/vite.js +1 -24
  3. package/lib/blocks/lens/FieldData.ts +27 -0
  4. package/lib/blocks/lens/Rule.ts +6 -0
  5. package/lib/blocks/lens/components/LensCatalog.vue +61 -2
  6. package/lib/blocks/lens/components/LensCatalogControl.vue +9 -0
  7. package/lib/blocks/lens/components/LensCatalogStateFilterCondition.vue +13 -7
  8. package/lib/blocks/lens/components/LensFormFilter.vue +22 -3
  9. package/lib/blocks/lens/components/LensFormFilterCondition.vue +6 -0
  10. package/lib/blocks/lens/components/LensFormView.vue +31 -4
  11. package/lib/blocks/lens/components/LensTable.vue +18 -5
  12. package/lib/blocks/lens/composables/SetupLens.ts +4 -0
  13. package/lib/blocks/lens/fields/BooleanField.ts +76 -0
  14. package/lib/blocks/lens/fields/DateField.ts +9 -1
  15. package/lib/blocks/lens/fields/DatetimeField.ts +8 -1
  16. package/lib/blocks/lens/fields/Field.ts +24 -1
  17. package/lib/blocks/lens/fields/RelatedManyField.ts +29 -5
  18. package/lib/blocks/lens/fields/RelatedOneField.ts +119 -0
  19. package/lib/blocks/lens/fields/TextField.ts +2 -0
  20. package/lib/blocks/lens/filter-inputs/SelectFilterInput.ts +5 -1
  21. package/lib/blocks/lens/validation/RuleMapper.ts +3 -1
  22. package/lib/components/SInputFileUpload.vue +17 -2
  23. package/lib/components/SInputFileUploadItem.vue +57 -16
  24. package/lib/http/Http.ts +8 -13
  25. package/lib/support/Chart.ts +7 -3
  26. package/lib/support/Day.ts +5 -4
  27. package/lib/support/File.ts +25 -0
  28. package/lib/support/Utils.ts +13 -0
  29. package/lib/validation/validators/maxLength.ts +3 -1
  30. package/lib/validation/validators/maxTotalFileSize.ts +10 -2
  31. package/lib/validation/validators/minLength.ts +3 -1
  32. package/package.json +34 -35
package/README.md CHANGED
@@ -27,31 +27,31 @@ Sefirot follows the official [Vue Style Guide](https://v3.vuejs.org/style-guide/
27
27
  ### Development
28
28
 
29
29
  ```bash
30
- $ pnpm run story
30
+ $ pnpm story
31
31
  ```
32
32
 
33
33
  Serve Histoire at http://localhost:4010.
34
34
 
35
35
  ```bash
36
- $ pnpm run docs
36
+ $ pnpm docs
37
37
  ```
38
38
 
39
39
  Serve documentation website at http://localhost:4011.
40
40
 
41
41
  ```bash
42
- $ pnpm run lint
42
+ $ pnpm lint
43
43
  ```
44
44
 
45
45
  Lint files using a rule of Standard JS.
46
46
 
47
47
  ```bash
48
- $ pnpm run test
48
+ $ pnpm test
49
49
  ```
50
50
 
51
51
  Run the tests.
52
52
 
53
53
  ```bash
54
- $ pnpm run coverage
54
+ $ pnpm coverage
55
55
  ```
56
56
 
57
57
  Output test coverage in `coverage` directory.
package/config/vite.js CHANGED
@@ -13,13 +13,6 @@ const files = (await Array.fromAsync(glob(`**/*.ts`, { cwd: lib })))
13
13
  .filter((file) => !file.endsWith('.d.ts'))
14
14
  .map((file) => `sefirot/${file}`.replace(/(?:\/index)?\.ts$/, ''))
15
15
 
16
- // @ts-ignore
17
- const isRolldown = !!vite.rolldownVersion
18
-
19
- const define = {
20
- 'navigator.userAgent': '""'
21
- }
22
-
23
16
  /** @type {import('vite').UserConfig} */
24
17
  export const baseConfig = {
25
18
  plugins: [
@@ -52,18 +45,7 @@ export const baseConfig = {
52
45
  },
53
46
 
54
47
  ssr: {
55
- noExternal: [
56
- /sentry/
57
- ],
58
- optimizeDeps: {
59
- include: [
60
- 'file-saver'
61
- ],
62
- // @ts-ignore
63
- esbuildOptions: isRolldown ? undefined : { define },
64
- // @ts-ignore
65
- rolldownOptions: isRolldown ? { transform: { define } } : undefined
66
- }
48
+ noExternal: [/(?:^|\/)dayjs/]
67
49
  },
68
50
 
69
51
  optimizeDeps: {
@@ -71,12 +53,7 @@ export const baseConfig = {
71
53
  include: [
72
54
  ...files,
73
55
  '@globalbrain/sefirot/dompurify',
74
- 'dayjs',
75
- 'dayjs/plugin/relativeTime',
76
- 'dayjs/plugin/timezone',
77
- 'dayjs/plugin/utc',
78
56
  'dompurify',
79
- 'file-saver',
80
57
  'markdown-it > argparse',
81
58
  'markdown-it > entities',
82
59
  'pinia',
@@ -17,6 +17,7 @@ import { type Rule } from './Rule'
17
17
  * }
18
18
  */
19
19
  export interface FieldDataRegistry {
20
+ boolean: BooleanFieldData
20
21
  content: ContentFieldData
21
22
  date: DateFieldData
22
23
  datetime: DatetimeFieldData
@@ -26,6 +27,7 @@ export interface FieldDataRegistry {
26
27
  link: LinkFieldData
27
28
  number: NumberFieldData
28
29
  related_many: RelatedManyFieldData
30
+ related_one: RelatedOneFieldData
29
31
  select: SelectFieldData
30
32
  slack_message: SlackMessageFieldData
31
33
  text: TextFieldData
@@ -48,6 +50,14 @@ export interface FieldDataBase {
48
50
  rules: Rule[]
49
51
  }
50
52
 
53
+ export interface BooleanFieldData extends FieldDataBase {
54
+ type: 'boolean'
55
+ labelTrueEn: string | null
56
+ labelTrueJa: string | null
57
+ labelFalseEn: string | null
58
+ labelFalseJa: string | null
59
+ }
60
+
51
61
  export interface ContentFieldData extends FieldDataBase {
52
62
  type: 'content'
53
63
  bodyEn: string
@@ -131,10 +141,25 @@ export interface SelectFieldDataOption {
131
141
  export interface RelatedManyFieldData extends FieldDataBase {
132
142
  type: 'related_many'
133
143
  title: string
144
+ image?: string | null
145
+ resourceEndpointMethod: 'get' | 'post'
146
+ resourceEndpointPath: string
147
+ resourceEndpointDataKey: string | null
148
+ resourceTitle: string
149
+ resourceImage?: string | null
150
+ displayAs?: 'pills' | 'avatars' | null
151
+ }
152
+
153
+ export interface RelatedOneFieldData extends FieldDataBase {
154
+ type: 'related_one'
155
+ title: string
156
+ image?: string | null
134
157
  resourceEndpointMethod: 'get' | 'post'
135
158
  resourceEndpointPath: string
136
159
  resourceEndpointDataKey: string | null
137
160
  resourceTitle: string
161
+ resourceImage?: string | null
162
+ displayAs?: 'text' | 'avatar' | null
138
163
  }
139
164
 
140
165
  export interface SlackMessageFieldData extends FieldDataBase {
@@ -147,6 +172,8 @@ export interface TextFieldData extends FieldDataBase {
147
172
  placeholderJa: string | null
148
173
  helpEn: string | null
149
174
  helpJa: string | null
175
+ unitBefore: string | null
176
+ unitAfter: string | null
150
177
  }
151
178
 
152
179
  export interface TextareaFieldData extends FieldDataBase {
@@ -1,6 +1,7 @@
1
1
  export type Rule =
2
2
  | MaxLengthRule
3
3
  | RequiredRule
4
+ | SlackChannelNameRule
4
5
  | BeforeRule
5
6
  | BeforeOrEqualRule
6
7
  | AfterRule
@@ -15,6 +16,11 @@ export interface RequiredRule {
15
16
  type: 'required'
16
17
  }
17
18
 
19
+ export interface SlackChannelNameRule {
20
+ type: 'slack_channel_name'
21
+ offset: number
22
+ }
23
+
18
24
  export interface BeforeRule {
19
25
  type: 'before'
20
26
  date: string
@@ -3,6 +3,7 @@ import { useDebounceFn, useElementSize } from '@vueuse/core'
3
3
  import { computed, ref, watch } from 'vue'
4
4
  import SDivider from '../../../components/SDivider.vue'
5
5
  import { useQuery } from '../../../composables/Api'
6
+ import { useLang } from '../../../composables/Lang'
6
7
  import { usePower } from '../../../composables/Power'
7
8
  import { type FieldData } from '../FieldData'
8
9
  import { type LensQuery, type LensQuerySort } from '../LensQuery'
@@ -102,6 +103,13 @@ export interface Props {
102
103
  // the catalog will hide entire catalog component and renders the given
103
104
  // `#empty-state` slot instead.
104
105
  showEmptyState?: boolean
106
+
107
+ // Whether to populate the selectable column list from every field the
108
+ // backend exposes for the entity, rather than only the columns passed
109
+ // in `selectable` / `select`. Use this when the catalog should let the
110
+ // user add any available column to the view (e.g. a saved-view editor),
111
+ // not just toggle the ones already selected.
112
+ loadSelectable?: boolean
105
113
  }
106
114
 
107
115
  const props = withDefaults(defineProps<Props>(), {
@@ -116,6 +124,7 @@ const emit = defineEmits<{
116
124
  'select-updated': [select: string[]]
117
125
  'selectable-updated': [selectable: string[]]
118
126
  'filters-updated': [filters: any[]]
127
+ 'query-filter-updated': [filter: any[]]
119
128
  'sort-updated': [sort: LensQuerySort[]]
120
129
  'overrides-updated': [overrides: Record<string, Partial<FieldData>>]
121
130
  'cell-clicked': [value: any, record: any]
@@ -155,6 +164,13 @@ const queryFilter = computed(() => {
155
164
  })]
156
165
  })
157
166
 
167
+ // Surface the free-text query filter so callers can fold it into their
168
+ // own export / side-channel requests (the search box lives inside the
169
+ // catalog, so the parent has no other way to observe it).
170
+ watch(queryFilter, (filter) => {
171
+ emit('query-filter-updated', filter)
172
+ })
173
+
158
174
  const _filters = ref(props.filters ?? [])
159
175
 
160
176
  const _sort = ref<LensQuerySort[]>([])
@@ -165,12 +181,15 @@ const _overrides = ref(props.overrides ?? {})
165
181
  const page = ref(1)
166
182
  const perPage = ref(100)
167
183
 
184
+ const lang = useLang()
185
+
168
186
  const { data: result, execute: refresh, loading } = useQuery(async (http) => {
169
187
  const input = {
170
188
  entity: props.entity ?? '__no_entity__',
171
189
  select: withIndexField(_select.value),
172
190
  filters: createInputFilters(queryFilter.value, _filters.value),
173
191
  sort: _sort.value.length > 0 ? _sort.value : defaultSort.value ?? [],
192
+ settings: { lang },
174
193
  page: page.value,
175
194
  perPage: perPage.value
176
195
  }
@@ -262,6 +281,20 @@ watch(result, (res) => {
262
281
  if (_selectable.value.length === 0) {
263
282
  _selectable.value = withoutIndexField(res!.query.select)
264
283
  }
284
+ // When `loadSelectable` is set, widen the selectable column list to
285
+ // every field the backend exposes for the entity (minus the index
286
+ // field), so the column picker can offer columns that aren't part of
287
+ // the current selection. Existing entries are preserved and order is
288
+ // kept stable by appending only the not-yet-present keys.
289
+ if (props.loadSelectable && res!.fields) {
290
+ const present = new Set(_selectable.value)
291
+ for (const key of withoutIndexField(Object.keys(res!.fields))) {
292
+ if (!present.has(key)) {
293
+ _selectable.value.push(key)
294
+ present.add(key)
295
+ }
296
+ }
297
+ }
265
298
  }, { once: true })
266
299
 
267
300
  // Columns to render in the table. We always defer to `_select` (caller
@@ -328,14 +361,21 @@ function onInlineFilterUpdated(filter: any[]) {
328
361
  emit('filters-updated', _filters.value)
329
362
  }
330
363
 
364
+ // A filter value "clears" the filter when it's nullish or an empty
365
+ // array. Scalar operators (e.g. a boolean `=`) carry a single value, so
366
+ // `false` is a real value to keep — only `null` clears.
367
+ function isEmptyFilterValue(value: any): boolean {
368
+ return value == null || (Array.isArray(value) && value.length === 0)
369
+ }
370
+
331
371
  function applyNewFilter(filter: any[]) {
332
- if (filter[2].length > 0) {
372
+ if (!isEmptyFilterValue(filter[2])) {
333
373
  _filters.value.push(filter)
334
374
  }
335
375
  }
336
376
 
337
377
  function replaceFilter(index: number, filter: any[]) {
338
- if (filter[2].length === 0) {
378
+ if (isEmptyFilterValue(filter[2])) {
339
379
  _filters.value.splice(index, 1)
340
380
  return
341
381
  }
@@ -395,7 +435,23 @@ function onNext() {
395
435
  doRefresh()
396
436
  }
397
437
 
438
+ // Re-runs the current query against the endpoint, preserving the
439
+ // catalog's in-memory state (select / filters / sort / page). Exposed
440
+ // so callers can reflect server-side changes — e.g. after a bulk action
441
+ // mutates rows — without remounting the component.
442
+ async function refreshCatalog(): Promise<void> {
443
+ // A refresh is requested precisely when server-side data may have
444
+ // changed while the query input did not (e.g. after a bulk action).
445
+ // Clear the memoized input so the equality shortcut in the fetcher
446
+ // misses and a real request is issued instead of returning the stale
447
+ // cached result.
448
+ prevFetchInput = null
449
+ await doRefresh()
450
+ }
451
+
398
452
  defineExpose({
453
+ refresh: refreshCatalog,
454
+
399
455
  /**
400
456
  * Retrieve the current records in the catalog. This method is required when
401
457
  * the parent component needs to access the records directly, for example, to
@@ -457,6 +513,9 @@ defineExpose({
457
513
  <template v-if="$slots['controls-sub-right']" #sub-right>
458
514
  <slot name="controls-sub-right" />
459
515
  </template>
516
+ <template v-if="$slots['selected-actions']" #selected-actions>
517
+ <slot name="selected-actions" />
518
+ </template>
460
519
  </LensCatalogControl>
461
520
  <div v-else class="control-skeleton" />
462
521
  <div v-if="!hideConditions && result && (_filters.length > 0 || _sort.length > 0)" ref="conditionBlocksEl" class="condition-blocks">
@@ -138,6 +138,9 @@ function createFilterPresetOptions(): ActionList {
138
138
  <IconX class="selected-reset-icon" />
139
139
  </button>
140
140
  </div>
141
+ <div v-if="$slots['selected-actions']" class="selected-actions">
142
+ <slot name="selected-actions" />
143
+ </div>
141
144
  </div>
142
145
  </template>
143
146
  </div>
@@ -165,6 +168,12 @@ function createFilterPresetOptions(): ActionList {
165
168
  gap: 8px;
166
169
  }
167
170
 
171
+ .selected-actions {
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 8px;
175
+ }
176
+
168
177
  .selected-box {
169
178
  display: flex;
170
179
  align-items: center;
@@ -21,15 +21,21 @@ const props = defineProps<Props>()
21
21
  const fieldFactory = useFieldFactory()
22
22
 
23
23
  const field = computed(() => {
24
- return fieldFactory.make(props.fields[props.condition.field])
24
+ const fieldData = props.fields[props.condition.field]
25
+ // A still-applied filter can reference a field that's no longer in the
26
+ // field set (e.g. a stale saved filter). Keep the chip rendering rather
27
+ // than crashing on `make(undefined)`, so the active filter stays
28
+ // visible and counted instead of silently filtering the table.
29
+ return fieldData ? fieldFactory.make(fieldData) : null
25
30
  })
26
31
 
27
32
  const input = computed(() => {
28
- return field.value.filterInputByOperator(props.condition.operator)
33
+ return field.value?.filterInputByOperator(props.condition.operator) ?? null
29
34
  })
30
35
 
31
36
  const fieldText = computed(() => {
32
- return field.value.label()
37
+ // Fall back to the raw field key when the field has no definition.
38
+ return field.value ? field.value.label() : props.condition.field
33
39
  })
34
40
 
35
41
  const operatorText = computed(() => {
@@ -37,6 +43,9 @@ const operatorText = computed(() => {
37
43
  })
38
44
 
39
45
  const valueText = computedAsync(async () => {
46
+ if (!input.value) {
47
+ return String(props.condition.value ?? '')
48
+ }
40
49
  return input.value.valueToText(props.condition.value)
41
50
  }, '...')
42
51
  </script>
@@ -45,10 +54,7 @@ const valueText = computedAsync(async () => {
45
54
  <div class="LensCatalogStateFilterCondition">
46
55
  <div class="field">{{ fieldText }}</div>
47
56
  <div class="operator">{{ operatorText }}</div>
48
- <div v-if="input === null" class="value">
49
- ...
50
- </div>
51
- <div v-else class="value">
57
+ <div class="value">
52
58
  {{ valueText }}
53
59
  </div>
54
60
  </div>
@@ -57,6 +57,12 @@ const { validateAndNotify } = useValidation()
57
57
  const fieldOptions = computed(() => {
58
58
  return props.filterable.filter((key) => {
59
59
  const fieldData = props.fields[key]
60
+ // Skip keys with no field definition — e.g. `__empty__` spacer
61
+ // columns that appear in `select` / `selectable` but carry no field
62
+ // metadata. Without this guard `make(undefined)` throws.
63
+ if (!fieldData) {
64
+ return false
65
+ }
60
66
  const field = fieldFactory.make(fieldData)
61
67
  return field.availableFilterOperators().length > 0
62
68
  }).map((key) => {
@@ -89,14 +95,27 @@ const fieldOptions = computed(() => {
89
95
  // ]
90
96
  // }
91
97
  function lensFiltersToGroup() {
98
+ const conditions = props.filters.length > 0
99
+ ? pruneMissingFields(props.filters.map(lensConditionToCondition))
100
+ : []
101
+
92
102
  return {
93
103
  connector: '$and' as const,
94
- conditions: props.filters.length > 0
95
- ? props.filters.map(lensConditionToCondition)
96
- : [createEmptyCondition()]
104
+ conditions: conditions.length > 0 ? conditions : [createEmptyCondition()]
97
105
  }
98
106
  }
99
107
 
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[] {
114
+ 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]))
117
+ }
118
+
100
119
  function lensConditionToCondition(filter: any[]) {
101
120
  const fieldOrConnector = filter[0]
102
121
 
@@ -48,6 +48,12 @@ const field = computed(() => {
48
48
  return null
49
49
  }
50
50
  const fieldData = props.fields[model.value.field]
51
+ // Guard against a condition referencing a key with no field
52
+ // definition (e.g. a stale saved filter, or a spacer key) so
53
+ // `make(undefined)` doesn't throw.
54
+ if (!fieldData) {
55
+ return null
56
+ }
51
57
  return fieldFactory.make(fieldData)
52
58
  })
53
59
 
@@ -52,14 +52,16 @@ const { t } = useTrans({
52
52
  a_select_all: 'Select all',
53
53
  a_clear_all: 'Clear all',
54
54
  a_cancel: 'Cancel',
55
- a_apply: 'Apply changes'
55
+ a_apply: 'Apply changes',
56
+ empty_column: '(Empty column)'
56
57
  },
57
58
  ja: {
58
59
  title: 'テーブルの表示を更新する',
59
60
  a_select_all: 'すべて選択',
60
61
  a_clear_all: 'すべて解除',
61
62
  a_cancel: 'キャンセル',
62
- a_apply: '変更を適用'
63
+ a_apply: '変更を適用',
64
+ empty_column: '(空列)'
63
65
  }
64
66
  })
65
67
 
@@ -90,12 +92,32 @@ useDraggable(el, selectOptions, {
90
92
  })
91
93
 
92
94
  function createSelectOptions(): SelectOption[] {
95
+ // `select` is a subsequence of `selectable` (the editor derives both
96
+ // from the same ordered list), so we walk them in lockstep to recover
97
+ // which specific `__empty__` spacer was selected. Real fields are keyed
98
+ // by membership (order-robust); spacers, which share the `__empty__`
99
+ // key and can't be told apart by `_selectDict`, are matched positionally
100
+ // via the shared cursor.
101
+ let cursor = 0
93
102
  return _selectable.value.map((s) => {
103
+ const isEmpty = s === '__empty__'
104
+ let value: boolean
105
+ if (isEmpty) {
106
+ value = _select.value[cursor] === '__empty__'
107
+ if (value) {
108
+ cursor++
109
+ }
110
+ } else {
111
+ value = !!_selectDict.value[s]
112
+ if (_select.value[cursor] === s) {
113
+ cursor++
114
+ }
115
+ }
94
116
  return {
95
117
  uid: _uid++,
96
118
  key: s,
97
- value: _selectDict.value[s],
98
- isEmpty: s === '__empty__',
119
+ value,
120
+ isEmpty,
99
121
  field: props.fields[s],
100
122
  override: _overrides[s] || {}
101
123
  }
@@ -103,6 +125,11 @@ function createSelectOptions(): SelectOption[] {
103
125
  }
104
126
 
105
127
  function getName(s: SelectOption): string {
128
+ // Empty spacer columns carry no field definition; show a localized
129
+ // placeholder label so the row is identifiable in the view editor.
130
+ if (s.isEmpty) {
131
+ return t.empty_column
132
+ }
106
133
  return lang === 'ja'
107
134
  ? s.override.labelJa || s.field?.labelJa || ''
108
135
  : s.override.labelEn || s.field?.labelEn || ''
@@ -43,11 +43,16 @@ const records = computed(() => props.result?.data ?? [])
43
43
  const columnKeys = computed(() => {
44
44
  const requested = props.select ?? props.result?.query.select ?? []
45
45
  const fetched = props.result?.query.select
46
- if (!fetched) {
47
- return requested
48
- }
49
- const fetchedSet = new Set(fetched)
50
- return requested.filter((k) => fetchedSet.has(k))
46
+ const fetchedSet = fetched ? new Set(fetched) : null
47
+ const visible = fetchedSet
48
+ ? requested.filter((k) => fetchedSet.has(k))
49
+ : requested
50
+ // Give each `__empty__` spacer a unique render key. The select can
51
+ // contain several `__empty__` entries (blank spacer columns, also used
52
+ // to inject empty columns into Excel exports); without unique keys they
53
+ // would collide on the same `columns` map entry and only render once.
54
+ let emptyIndex = 0
55
+ return visible.map((k) => (k === '__empty__' ? `__empty__::${emptyIndex++}` : k))
51
56
  })
52
57
 
53
58
  const orders = computed(() => [
@@ -79,6 +84,14 @@ const columns = computedAsync(async () => {
79
84
 
80
85
  // Build the list of columns based on the resolved column key list.
81
86
  for (const key of keys) {
87
+ // Render `__empty__` spacer keys (uniquified to `__empty__::N`) as
88
+ // blank columns, matching the empty columns the backend injects into
89
+ // Excel exports. These carry no field definition.
90
+ if (key.startsWith('__empty__')) {
91
+ columns[key] = { width: '128px', cell: { type: 'empty' } }
92
+ continue
93
+ }
94
+
82
95
  const _fieldData = cloneDeep(r.fields[key])
83
96
 
84
97
  if (!_fieldData) {
@@ -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 { BooleanField } from '../fields/BooleanField'
4
5
  import { ContentField } from '../fields/ContentField'
5
6
  import { DateField } from '../fields/DateField'
6
7
  import { DatetimeField } from '../fields/DatetimeField'
@@ -10,6 +11,7 @@ import { IdField } from '../fields/IdField'
10
11
  import { LinkField } from '../fields/LinkField'
11
12
  import { NumberField } from '../fields/NumberField'
12
13
  import { RelatedManyField } from '../fields/RelatedManyField'
14
+ import { RelatedOneField } from '../fields/RelatedOneField'
13
15
  import { SelectField } from '../fields/SelectField'
14
16
  import { SlackMessageField } from '../fields/SlackMessageField'
15
17
  import { TextField } from '../fields/TextField'
@@ -32,6 +34,7 @@ export function useSetupLens(): SetupLens {
32
34
  registerDefaultFields()
33
35
 
34
36
  function registerDefaultFields(): void {
37
+ fieldRegistry.register('boolean', (ctx, field) => new BooleanField(ctx, field))
35
38
  fieldRegistry.register('content', (ctx, field) => new ContentField(ctx, field))
36
39
  fieldRegistry.register('date', (ctx, field) => new DateField(ctx, field))
37
40
  fieldRegistry.register('datetime', (ctx, field) => new DatetimeField(ctx, field))
@@ -41,6 +44,7 @@ export function useSetupLens(): SetupLens {
41
44
  fieldRegistry.register('number', (ctx, field) => new NumberField(ctx, field))
42
45
  fieldRegistry.register('id', (ctx, field) => new IdField(ctx, field))
43
46
  fieldRegistry.register('related_many', (ctx, field) => new RelatedManyField(ctx, field, resourceFetcher))
47
+ fieldRegistry.register('related_one', (ctx, field) => new RelatedOneField(ctx, field, resourceFetcher))
44
48
  fieldRegistry.register('select', (ctx, field) => new SelectField(ctx, field))
45
49
  fieldRegistry.register('slack_message', (ctx, field) => new SlackMessageField(ctx, field))
46
50
  fieldRegistry.register('text', (ctx, field) => new TextField(ctx, field))
@@ -0,0 +1,76 @@
1
+ import { type DropdownSection } from '../../../composables/Dropdown'
2
+ import { type TableCell } from '../../../composables/Table'
3
+ import { type BooleanFieldData } from '../FieldData'
4
+ import { type FilterOperator } from '../FilterOperator'
5
+ import { type FilterInput } from '../filter-inputs/FilterInput'
6
+ import { SelectFilterInput } from '../filter-inputs/SelectFilterInput'
7
+ import { Field } from './Field'
8
+
9
+ const DEFAULTS = {
10
+ en: { true: 'Yes', false: 'No' },
11
+ ja: { true: 'はい', false: 'いいえ' }
12
+ } as const
13
+
14
+ export class BooleanField extends Field<BooleanFieldData> {
15
+ override tableCell(v: any, _r: any): TableCell {
16
+ return {
17
+ type: 'text',
18
+ value: this.formatValue(v)
19
+ }
20
+ }
21
+
22
+ override async tableFilterMenu(filters: any[], onFilterUpdated: (filters: any[]) => void): Promise<DropdownSection | null> {
23
+ // A boolean has exactly two states, so the inline menu is a single
24
+ // select on `=`. Re-clicking the active value clears the filter.
25
+ const selected = this.eqFilterValueFor(this.data.key, filters)
26
+
27
+ return {
28
+ type: 'filter',
29
+ search: false,
30
+ selected,
31
+ options: this.filterOptions(),
32
+ onClick: (v) => { onFilterUpdated?.([this.data.key, '=', selected === v ? null : v]) }
33
+ }
34
+ }
35
+
36
+ override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
37
+ // A boolean is a two-state value, so `=` / `!=` express the filter
38
+ // more naturally than set membership. The single-value cast preserves
39
+ // the boolean (it no longer stringifies), so option matching and the
40
+ // backend comparison stay correct.
41
+ const selectOne = new SelectFilterInput().options(() => Promise.resolve(this.filterOptions()))
42
+ return {
43
+ '=': selectOne,
44
+ '!=': selectOne
45
+ }
46
+ }
47
+
48
+ override dataListItemComponent(): any {
49
+ return this.defineDataListItemComponent((value) => this.formatValue(value))
50
+ }
51
+
52
+ override formInputComponent(): any {
53
+ throw new Error('Not implemented.')
54
+ }
55
+
56
+ protected filterOptions(): Array<{ value: boolean; label: string }> {
57
+ return [
58
+ { value: true, label: this.formatValue(true)! },
59
+ { value: false, label: this.formatValue(false)! }
60
+ ]
61
+ }
62
+
63
+ protected formatValue(v: any): string | null {
64
+ if (v === null || v === undefined) {
65
+ return null
66
+ }
67
+ if (v) {
68
+ return this.ctx.lang === 'ja'
69
+ ? (this.data.labelTrueJa ?? DEFAULTS.ja.true)
70
+ : (this.data.labelTrueEn ?? DEFAULTS.en.true)
71
+ }
72
+ return this.ctx.lang === 'ja'
73
+ ? (this.data.labelFalseJa ?? DEFAULTS.ja.false)
74
+ : (this.data.labelFalseEn ?? DEFAULTS.en.false)
75
+ }
76
+ }
@@ -20,9 +20,17 @@ export class DateField extends Field<DateFieldData> {
20
20
  override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
21
21
  const text = new TextFilterInput()
22
22
 
23
+ // Comparison operators (`>`, `>=`, `<`, `<=`) back date-range filters
24
+ // such as `invested_date >= X` AND `invested_date < Y`. The value is
25
+ // an ISO date string, edited as text — same input the `=` / `!=`
26
+ // operators already use for this field.
23
27
  return {
24
28
  '=': text,
25
- '!=': text
29
+ '!=': text,
30
+ '>': text,
31
+ '>=': text,
32
+ '<': text,
33
+ '<=': text
26
34
  }
27
35
  }
28
36