@proteos/ui 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
5
 
6
6
  import { RecordFilterBuilderPanel } from './record-filter-builder-panel'
7
7
  import { RecordFilterButton } from './record-filter-button'
8
+ import { formatValue } from './chip-value'
9
+ import { FIELD_LABEL_SEPARATOR, joinFieldLabel } from './field-label'
8
10
  import { RecordFilterChips } from './record-filter-chips'
9
11
  import { __resetShowDetailsStores } from './show-details-store'
10
12
  import type { FilterField, FilterGroup } from './types'
@@ -435,3 +437,87 @@ describe('RecordFilterChips — nested groups', () => {
435
437
  expect(screen.getByText(/· 2/)).toBeInTheDocument()
436
438
  })
437
439
  })
440
+
441
+ /* The chip rail composes its own breadcrumb from `parentLabel`, while the
442
+ consumer composes `label` when it builds the field list. Both must use
443
+ FIELD_LABEL_SEPARATOR, or one chip reads "Company · Name" beside another
444
+ reading "Company / Name". */
445
+ describe('RecordFilterChips — breadcrumb separator', () => {
446
+ const nestedFields: FilterField[] = [
447
+ {
448
+ path: 'billing_address.city',
449
+ label: `Billing Address${FIELD_LABEL_SEPARATOR}City`,
450
+ shortLabel: 'City',
451
+ parentLabel: 'Billing Address',
452
+ type: 'string',
453
+ },
454
+ ]
455
+
456
+ it('separates the parent from the leaf with a middle dot', () => {
457
+ // Chips only render when expanded; the store defaults to collapsed.
458
+ window.localStorage.setItem('rf.showDetails', '1')
459
+ render(
460
+ <RecordFilterChips
461
+ fields={nestedFields}
462
+ filter={{
463
+ logicalOperator: 'and',
464
+ elements: [{ field: 'billing_address.city', operator: 'eq', value: 'Berlin' }],
465
+ }}
466
+ onChange={() => undefined}
467
+ />,
468
+ )
469
+
470
+ expect(screen.getByText('Billing Address', { exact: false }).textContent).toContain(
471
+ FIELD_LABEL_SEPARATOR,
472
+ )
473
+ expect(document.body.textContent).not.toContain('Billing Address / ')
474
+ })
475
+
476
+ it('exposes the separator as the middle dot', () => {
477
+ expect(FIELD_LABEL_SEPARATOR).toBe(' · ')
478
+ expect(joinFieldLabel('Company', undefined, 'Name')).toBe('Company · Name')
479
+ })
480
+ })
481
+
482
+ /* An enum stores a system value and displays a declared label. Both the
483
+ multi-value branch and the chip badge already resolved it; a single value
484
+ did not, so the same filter read "is technical_decider" or "is any of
485
+ Technical Decider" depending only on the operator. */
486
+ describe('formatValue — enum labels', () => {
487
+ const roleField: FilterField = {
488
+ path: 'buying_role',
489
+ label: 'Buying Role',
490
+ shortLabel: 'Buying Role',
491
+ type: 'enum',
492
+ options: [
493
+ { value: 'technical_decider', label: 'Technical Decider' },
494
+ { value: 'economic_decider', label: 'Economic Decider' },
495
+ ],
496
+ }
497
+
498
+ it('renders the label for a single value', () => {
499
+ expect(formatValue(roleField, 'eq', 'technical_decider')).toBe('Technical Decider')
500
+ expect(formatValue(roleField, 'ne', 'economic_decider')).toBe('Economic Decider')
501
+ })
502
+
503
+ it('renders labels for a multi-value operator', () => {
504
+ expect(formatValue(roleField, 'in', ['technical_decider', 'economic_decider'])).toBe(
505
+ 'Technical Decider, Economic Decider',
506
+ )
507
+ })
508
+
509
+ // A value the entity no longer declares still has to render as something.
510
+ it('falls back to the stored value when no option matches', () => {
511
+ expect(formatValue(roleField, 'eq', 'retired_role')).toBe('retired_role')
512
+ })
513
+
514
+ it('leaves non-enum fields alone', () => {
515
+ const nameField: FilterField = {
516
+ path: 'name',
517
+ label: 'Name',
518
+ shortLabel: 'Name',
519
+ type: 'string',
520
+ }
521
+ expect(formatValue(nameField, 'eq', 'technical_decider')).toBe('technical_decider')
522
+ })
523
+ })
@@ -34,7 +34,8 @@ export type FilterFieldOption = {
34
34
  export type FilterField = {
35
35
  /** Dot-path to the attribute, e.g. `'owner.email'`. Stable identifier. */
36
36
  path: string
37
- /** Full nested label, e.g. `'Owner / Email'`. Used in pickers. */
37
+ /** Full nested label, e.g. `'Owner · Email'` — parts joined with
38
+ * `FIELD_LABEL_SEPARATOR`. Used in pickers. */
38
39
  label: string
39
40
  /** Leaf label only, e.g. `'Email'`. Used in chips. */
40
41
  shortLabel: string
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from './components/message-composer'
33
33
  export * from './components/modal'
34
34
  export * from './components/multi-select'
35
35
  export * from './components/nav'
36
+ export * from './components/orb'
36
37
  export * from './components/popover'
37
38
  export * from './components/progress-bar'
38
39
  export * from './components/radio-group'
package/src/styles.css CHANGED
@@ -122,6 +122,7 @@
122
122
  /* Status pop — one-shot scale+fade entrance for the large success/error
123
123
  status icons. Soft ease, no overshoot (nothing bounces). */
124
124
  --animate-status-pop: status-pop var(--duration-slow) var(--ease-out-soft) both;
125
+
125
126
  }
126
127
 
127
128
  @keyframes indeterminate-progress {
package/src/test/setup.ts CHANGED
@@ -4,6 +4,36 @@ import { afterEach, expect } from 'vitest'
4
4
 
5
5
  expect.extend(matchers)
6
6
 
7
+ // jsdom implements neither Element.scrollTo nor IntersectionObserver, both of
8
+ // which the virtualized DataTable calls on mount. Stub them so scrolling
9
+ // components can be rendered in tests at all.
10
+ if (!Element.prototype.scrollTo) {
11
+ Element.prototype.scrollTo = () => {}
12
+ }
13
+ if (!('ResizeObserver' in globalThis)) {
14
+ class NoopResizeObserver implements ResizeObserver {
15
+ disconnect() {}
16
+ observe() {}
17
+ unobserve() {}
18
+ }
19
+ globalThis.ResizeObserver = NoopResizeObserver as unknown as typeof ResizeObserver
20
+ }
21
+ if (!('IntersectionObserver' in globalThis)) {
22
+ class NoopIntersectionObserver implements IntersectionObserver {
23
+ readonly root = null
24
+ readonly rootMargin = ''
25
+ readonly thresholds: ReadonlyArray<number> = []
26
+ disconnect() {}
27
+ observe() {}
28
+ unobserve() {}
29
+ takeRecords(): IntersectionObserverEntry[] {
30
+ return []
31
+ }
32
+ }
33
+ globalThis.IntersectionObserver =
34
+ NoopIntersectionObserver as unknown as typeof IntersectionObserver
35
+ }
36
+
7
37
  afterEach(() => {
8
38
  cleanup()
9
39
  })