@globalbrain/sefirot 4.48.1 → 4.50.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.
@@ -146,7 +146,7 @@ export interface DecimalFieldData extends FieldDataBase {
146
146
 
147
147
  export interface SelectFieldData extends FieldDataBase {
148
148
  type: 'select'
149
- displayAs: 'text' | 'state'
149
+ displayAs: 'text' | 'state' | 'pills'
150
150
  inputAs: 'dropdown' | 'radio'
151
151
  placeholderEn: string | null
152
152
  placeholderJa: string | null
@@ -1,19 +1,83 @@
1
1
  export type Rule =
2
2
  | MaxLengthRule
3
+ | MinLengthRule
4
+ | MinValueRule
5
+ | MaxValueRule
3
6
  | RequiredRule
4
7
  | UniqueRule
8
+ | EmailRule
9
+ | UrlRule
10
+ | DecimalRule
11
+ | DecimalOrHyphenRule
12
+ | PositiveIntegerRule
13
+ | NegativeIntegerRule
14
+ | ZeroOrPositiveIntegerRule
15
+ | ZeroOrNegativeIntegerRule
16
+ | CheckedRule
5
17
  | SlackChannelLinkRule
6
18
  | SlackChannelNameRule
7
19
  | BeforeRule
8
20
  | BeforeOrEqualRule
9
21
  | AfterRule
10
22
  | AfterOrEqualRule
23
+ | EachRule
11
24
 
12
25
  export interface MaxLengthRule {
13
26
  type: 'max_length'
14
27
  length: number
15
28
  }
16
29
 
30
+ export interface MinLengthRule {
31
+ type: 'min_length'
32
+ length: number
33
+ }
34
+
35
+ export interface MinValueRule {
36
+ type: 'min_value'
37
+ value: number
38
+ }
39
+
40
+ export interface MaxValueRule {
41
+ type: 'max_value'
42
+ value: number
43
+ }
44
+
45
+ export interface EmailRule {
46
+ type: 'email'
47
+ }
48
+
49
+ export interface UrlRule {
50
+ type: 'url'
51
+ }
52
+
53
+ export interface DecimalRule {
54
+ type: 'decimal'
55
+ }
56
+
57
+ export interface DecimalOrHyphenRule {
58
+ type: 'decimal_or_hyphen'
59
+ }
60
+
61
+ export interface PositiveIntegerRule {
62
+ type: 'positive_integer'
63
+ }
64
+
65
+ export interface NegativeIntegerRule {
66
+ type: 'negative_integer'
67
+ }
68
+
69
+ export interface ZeroOrPositiveIntegerRule {
70
+ type: 'zero_or_positive_integer'
71
+ }
72
+
73
+ export interface ZeroOrNegativeIntegerRule {
74
+ type: 'zero_or_negative_integer'
75
+ }
76
+
77
+ export interface CheckedRule {
78
+ type: 'checked'
79
+ }
80
+
17
81
  export interface RequiredRule {
18
82
  type: 'required'
19
83
  }
@@ -50,3 +114,12 @@ export interface AfterOrEqualRule {
50
114
  type: 'after_or_equal'
51
115
  date: string
52
116
  }
117
+
118
+ /**
119
+ * Applies the nested `rules` to every element of an array value, mirroring
120
+ * Laravel's `field.*` wildcard on the backend.
121
+ */
122
+ export interface EachRule {
123
+ type: 'each'
124
+ rules: Rule[]
125
+ }
@@ -3,6 +3,8 @@ import IconPencilSimple from '~icons/ph/pencil-simple'
3
3
  import { useElementBounding } from '@vueuse/core'
4
4
  import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue'
5
5
  import SButton from '../../../components/SButton.vue'
6
+ import SPill, { type Mode as PillMode } from '../../../components/SPill.vue'
7
+ import SState, { type Mode as StateMode } from '../../../components/SState.vue'
6
8
  import { useManualDropdownPosition } from '../../../composables/Dropdown'
7
9
  import { useTrans } from '../../../composables/Lang'
8
10
  import { useValidation } from '../../../composables/Validation'
@@ -54,21 +56,37 @@ onUnmounted(() => {
54
56
 
55
57
  // Reuse the field's own table-cell rendering for the display value so the
56
58
  // label mapping (e.g. select option labels) matches the read-only columns.
57
- // Falls back to a plain representation for non-text displays.
58
- const displayValue = computed(() => {
59
+ const resolvedCell = computed<any>(() => {
59
60
  try {
60
61
  const cell = props.field.tableColumn().cell
61
- const resolved: any = typeof cell === 'function' ? cell(props.value, props.record) : cell
62
- if (resolved && (resolved.type === 'text' || resolved.type === 'number')) {
63
- return resolved.value ?? ''
64
- }
65
- // `state` cells (e.g. a select with displayAs: 'state') carry the localized
66
- // label, not the raw payload — show that rather than falling through.
67
- if (resolved && resolved.type === 'state') {
68
- return resolved.label ?? ''
69
- }
62
+ return typeof cell === 'function' ? cell(props.value, props.record) : cell
70
63
  } catch {
71
- // Field types without a text cell fall through to the generic display.
64
+ // Field types without a usable cell fall through to the generic display.
65
+ return null
66
+ }
67
+ })
68
+
69
+ // A `pills` cell (e.g. a multi-select with displayAs: 'pills') renders as pills
70
+ // rather than text, mirroring the read-only `STableCellPills` column.
71
+ const displayPills = computed<{ label: string; color?: PillMode }[] | null>(() => {
72
+ const cell = resolvedCell.value
73
+ return cell && cell.type === 'pills' ? cell.pills : null
74
+ })
75
+
76
+ // A `state` cell (e.g. a select with displayAs: 'state') renders as a status
77
+ // badge rather than its bare label, mirroring the read-only `STableCellState`
78
+ // column.
79
+ const displayState = computed<{ label: string; mode?: StateMode } | null>(() => {
80
+ const cell = resolvedCell.value
81
+ return cell && cell.type === 'state' ? { label: cell.label, mode: cell.mode } : null
82
+ })
83
+
84
+ // Falls back to a plain representation for non-text displays (pills and state
85
+ // cells are rendered as their own components in the template above).
86
+ const displayValue = computed(() => {
87
+ const resolved: any = resolvedCell.value
88
+ if (resolved && (resolved.type === 'text' || resolved.type === 'number')) {
89
+ return resolved.value ?? ''
72
90
  }
73
91
 
74
92
  const v = props.value
@@ -184,7 +202,22 @@ function isTextLikeInput(target: EventTarget | null): boolean {
184
202
 
185
203
  <template>
186
204
  <div ref="anchor" class="LensTableEditableCell" :class="{ editing }">
187
- <span class="value">{{ displayValue }}</span>
205
+ <div v-if="displayPills" class="pills">
206
+ <SPill
207
+ v-for="(pill, i) in displayPills"
208
+ :key="i"
209
+ size="mini"
210
+ :mode="pill.color"
211
+ :label="pill.label"
212
+ />
213
+ </div>
214
+ <SState
215
+ v-else-if="displayState"
216
+ size="mini"
217
+ :mode="displayState.mode"
218
+ :label="displayState.label"
219
+ />
220
+ <span v-else class="value">{{ displayValue }}</span>
188
221
  <button
189
222
  class="edit"
190
223
  type="button"
@@ -233,6 +266,15 @@ function isTextLikeInput(target: EventTarget | null): boolean {
233
266
  text-overflow: ellipsis;
234
267
  }
235
268
 
269
+ .pills {
270
+ display: flex;
271
+ flex-wrap: nowrap;
272
+ align-items: center;
273
+ gap: 4px;
274
+ min-width: 0;
275
+ overflow: hidden;
276
+ }
277
+
236
278
  .edit {
237
279
  position: absolute;
238
280
  top: 50%;
@@ -1,5 +1,6 @@
1
1
  import { xor } from 'lodash-es'
2
2
  import { h } from 'vue'
3
+ import SDescPill from '../../../components/SDescPill.vue'
3
4
  import SInputCheckboxes from '../../../components/SInputCheckboxes.vue'
4
5
  import SInputDropdown from '../../../components/SInputDropdown.vue'
5
6
  import SInputRadios from '../../../components/SInputRadios.vue'
@@ -38,6 +39,8 @@ export class SelectField extends Field<SelectFieldData> {
38
39
  switch (this.data.displayAs) {
39
40
  case 'state':
40
41
  return this.tableCellState(v, _r)
42
+ case 'pills':
43
+ return this.tableCellPills(v, _r)
41
44
  case 'text':
42
45
  return this.tableCellText(v, _r)
43
46
  default:
@@ -45,6 +48,18 @@ export class SelectField extends Field<SelectFieldData> {
45
48
  }
46
49
  }
47
50
 
51
+ protected tableCellPills(v: any, _r: any): TableCell {
52
+ v = Array.isArray(v) ? v : [v]
53
+
54
+ return {
55
+ type: 'pills',
56
+ pills: this.optionsForValues(v).map((o) => ({
57
+ label: this.labelForOption(o),
58
+ color: o.mode
59
+ }))
60
+ }
61
+ }
62
+
48
63
  protected tableCellText(v: any, _r: any): TableCell {
49
64
  v = Array.isArray(v) ? v : [v]
50
65
 
@@ -95,6 +110,8 @@ export class SelectField extends Field<SelectFieldData> {
95
110
  switch (this.data.displayAs) {
96
111
  case 'state':
97
112
  return this.renderDataListItemValueForState(value)
113
+ case 'pills':
114
+ return this.renderDataListItemValueForPills(value)
98
115
  case 'text':
99
116
  return this.renderDataListItemValueForText(value)
100
117
  default:
@@ -103,6 +120,17 @@ export class SelectField extends Field<SelectFieldData> {
103
120
  })
104
121
  }
105
122
 
123
+ protected renderDataListItemValueForPills(value: any): any {
124
+ value = Array.isArray(value) ? value : [value]
125
+
126
+ return h(SDescPill, {
127
+ pill: this.optionsForValues(value).map((o) => ({
128
+ label: this.labelForOption(o),
129
+ mode: o.mode
130
+ }))
131
+ })
132
+ }
133
+
106
134
  protected renderDataListItemValueForState(value: any): any {
107
135
  if (this.data.multiple) {
108
136
  throw new Error('Displaying select field as state with multiple option is not supported.')
@@ -5,30 +5,90 @@ import {
5
5
  afterOrEqual,
6
6
  before,
7
7
  beforeOrEqual,
8
+ checked,
9
+ decimal,
10
+ decimalOrHyphen,
11
+ email,
8
12
  maxLength,
13
+ maxValue,
14
+ minLength,
15
+ minValue,
16
+ negativeInteger,
17
+ positiveInteger,
9
18
  required,
19
+ rule,
10
20
  slackChannelLink,
11
- slackChannelName
21
+ slackChannelName,
22
+ url,
23
+ zeroOrNegativeInteger,
24
+ zeroOrPositiveInteger
12
25
  } from '../../../validation/rules'
13
- import { type Rule } from '../Rule'
26
+ import { type EachRule, type Rule } from '../Rule'
14
27
 
15
28
  /**
16
29
  * Maps field rules to vuelidate validation rules.
17
30
  */
18
31
  export function map(rules: Rule[]): ValidationArgs {
19
- return rules.reduce((carry: ValidationArgs<any>, rule: Rule) => {
32
+ const carry = rules.reduce((carry: ValidationArgs<any>, rule: Rule) => {
33
+ // `each` rules are handled separately below so that multiple of them on the
34
+ // same field compose rather than overwrite.
35
+ if (rule.type === 'each') {
36
+ return carry
37
+ }
20
38
  const mapped = mapRule(rule)
21
39
  if (mapped) {
22
40
  carry[rule.type] = mapped
23
41
  }
24
42
  return carry
25
43
  }, {})
44
+
45
+ // `each` validates every element of an array value rather than the value
46
+ // itself, so it maps to a single per-element validator (see `mapEach`). If a
47
+ // field carries more than one `each` rule, merge their child rules so every
48
+ // wildcard rule runs — the backend accumulates them the same way. A single
49
+ // `each` is the common case.
50
+ const eachRules = rules.filter((rule): rule is EachRule => rule.type === 'each')
51
+ if (eachRules.length) {
52
+ carry.each = mapEach({ type: 'each', rules: eachRules.flatMap((rule) => rule.rules) })
53
+ }
54
+
55
+ return carry
56
+ }
57
+
58
+ /**
59
+ * Builds a validator that applies an `each` rule's nested rules to every
60
+ * element of an array value. vuelidate's `forEach` only handles arrays of
61
+ * objects, so for the scalar arrays Lens fields hold (multi-select values,
62
+ * related ids) we run each element through the mapped child validators
63
+ * directly. Mirrors the backend, which expands the same rules onto Laravel's
64
+ * `key.*` wildcard. A non-array value fails; an empty/absent value is left to
65
+ * `required` (the rule is optional on its own).
66
+ */
67
+ function mapEach(eachRule: EachRule): ValidationRuleWithParams {
68
+ // Map each child rule straight to a validator rather than through the
69
+ // type-keyed `map()` object, so two child rules of the same family are both
70
+ // kept (the backend's `key.*` expansion is additive and de-dupes nothing).
71
+ // Server-only children (e.g. `unique`) map to null and are dropped.
72
+ const childValidators = eachRule.rules
73
+ .map((r) => (r.type === 'each' ? mapEach(r) : mapRule(r)))
74
+ .filter((v): v is ValidationRuleWithParams => v !== null)
75
+
76
+ return rule((value) =>
77
+ Array.isArray(value)
78
+ && value.every((item) => childValidators.every((v) => v.$validator(item, {}, {}) === true))
79
+ )
26
80
  }
27
81
 
28
- function mapRule(rule: Rule): ValidationRuleWithParams | null {
82
+ function mapRule(rule: Exclude<Rule, EachRule>): ValidationRuleWithParams | null {
29
83
  switch (rule.type) {
30
84
  case 'max_length':
31
85
  return maxLength(rule.length)
86
+ case 'min_length':
87
+ return minLength(rule.length)
88
+ case 'min_value':
89
+ return minValue(rule.value)
90
+ case 'max_value':
91
+ return maxValue(rule.value)
32
92
  case 'required':
33
93
  return required()
34
94
  case 'unique':
@@ -36,6 +96,24 @@ function mapRule(rule: Rule): ValidationRuleWithParams | null {
36
96
  // database). The backend enforces it and returns a 422 with field
37
97
  // errors, so there is no client-side equivalent to apply here.
38
98
  return null
99
+ case 'email':
100
+ return email()
101
+ case 'url':
102
+ return url()
103
+ case 'decimal':
104
+ return decimal()
105
+ case 'decimal_or_hyphen':
106
+ return decimalOrHyphen()
107
+ case 'positive_integer':
108
+ return positiveInteger()
109
+ case 'negative_integer':
110
+ return negativeInteger()
111
+ case 'zero_or_positive_integer':
112
+ return zeroOrPositiveInteger()
113
+ case 'zero_or_negative_integer':
114
+ return zeroOrNegativeInteger()
115
+ case 'checked':
116
+ return checked()
39
117
  case 'slack_channel_link':
40
118
  return slackChannelLink()
41
119
  case 'slack_channel_name':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.48.1",
3
+ "version": "4.50.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",