|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * The builder's list values hold what the spec rule's list holds (#26348). |
| 11 | + * |
| 12 | + * `ViewFilterRuleSchema` (`@objectstack/spec/ui`) takes `in` / `not_in` / |
| 13 | + * `between` values as arrays of strings and numbers. The builder's row used to |
| 14 | + * declare a wider list member of its own, `(string | number | boolean)[]`, and |
| 15 | + * its one list writer for a scalar, `normalizeToArray`, kept a boolean a |
| 16 | + * boolean. So an `equals true` row (a boolean scalar, which the spec admits) on |
| 17 | + * a `select` column, switched to `In`, emitted `[true]`, and the schema refused |
| 18 | + * the rule the builder had just produced. |
| 19 | + * |
| 20 | + * The emitted list now matches the spec's list element at the source: a boolean |
| 21 | + * carried into a list or a range is written as its string spelling, the |
| 22 | + * conversion the builder already makes for a boolean that reaches a text |
| 23 | + * column. The row's value is the spec rule's value and nothing wider. |
| 24 | + * |
| 25 | + * Type-level pins are judged by `tsc -p tsconfig.test.json`; a green vitest run |
| 26 | + * says nothing about them. |
| 27 | + */ |
| 28 | +import { describe, it, expect, vi } from 'vitest'; |
| 29 | +import React from 'react'; |
| 30 | +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
| 31 | +import '@testing-library/jest-dom'; |
| 32 | +import { ViewFilterRuleSchema, type ViewFilterRule } from '@objectstack/spec/ui'; |
| 33 | +import type { FilterGroup } from '@object-ui/types'; |
| 34 | +import { |
| 35 | + FilterBuilder, |
| 36 | + reshapeFilterValue, |
| 37 | + retypeFilterValue, |
| 38 | + type FilterBuilderProps, |
| 39 | + type FilterBuilderRow, |
| 40 | +} from '../custom/filter-builder'; |
| 41 | + |
| 42 | +type Equal<A, B> = |
| 43 | + (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false; |
| 44 | +function expectType<T extends true>(_: T = true as T): void { /* compile-time only */ } |
| 45 | + |
| 46 | +/* ── The shape ────────────────────────────────────────────────────────────── */ |
| 47 | + |
| 48 | +// The row's value IS the spec rule's value, with no wider list member beside it. |
| 49 | +expectType<Equal<FilterBuilderRow['value'], ViewFilterRule['value']>>(); |
| 50 | +// So a boolean list is refused where it is written, as the schema refuses it. |
| 51 | +const booleanList: FilterBuilderRow = { |
| 52 | + id: 'c1', |
| 53 | + field: 'stage', |
| 54 | + operator: 'in', |
| 55 | + // @ts-expect-error a list entry is a string or a number, as the spec's is |
| 56 | + value: [true], |
| 57 | +}; |
| 58 | +// A boolean SCALAR stays admitted: the spec admits it, and `equals true` is a row. |
| 59 | +const booleanScalar: FilterBuilderRow = { id: 'c1', field: 'active', operator: 'equals', value: true }; |
| 60 | +void booleanList; |
| 61 | +void booleanScalar; |
| 62 | + |
| 63 | +const FIELDS: NonNullable<FilterBuilderProps['fields']> = [ |
| 64 | + { |
| 65 | + value: 'stage', |
| 66 | + label: 'Stage', |
| 67 | + type: 'select', |
| 68 | + options: [ |
| 69 | + { value: 'true', label: 'True' }, |
| 70 | + { value: 'won', label: 'Won' }, |
| 71 | + ], |
| 72 | + }, |
| 73 | + { value: 'closed_at', label: 'Closed at', type: 'date' }, |
| 74 | +]; |
| 75 | + |
| 76 | +function renderAuthored(row: FilterGroup['conditions'][number]) { |
| 77 | + const onChange = vi.fn(); |
| 78 | + // Hoisted out of the JSX: the sync effect re-seeds state when the `value` |
| 79 | + // prop's identity changes, which would undo the interaction under test. |
| 80 | + const value: FilterGroup = { logic: 'and', conditions: [row] }; |
| 81 | + render(<FilterBuilder fields={FIELDS} value={value} onChange={onChange} />); |
| 82 | + return onChange; |
| 83 | +} |
| 84 | + |
| 85 | +function lastRow(onChange: ReturnType<typeof vi.fn>): FilterBuilderRow { |
| 86 | + expect(onChange.mock.calls.length, 'the builder never called onChange').toBeGreaterThan(0); |
| 87 | + return onChange.mock.calls.at(-1)?.[0].conditions[0]; |
| 88 | +} |
| 89 | + |
| 90 | +/** Drive the row's REAL operator dropdown (index 1: field, operator, value). */ |
| 91 | +async function pickOperator(label: string) { |
| 92 | + fireEvent.keyDown(screen.getAllByRole('combobox')[1], { key: 'ArrowDown' }); |
| 93 | + const option = await waitFor(() => { |
| 94 | + const found = screen.getAllByRole('option').find((o) => o.textContent === label); |
| 95 | + expect(found, `no "${label}" option in the operator dropdown`).toBeTruthy(); |
| 96 | + return found!; |
| 97 | + }); |
| 98 | + fireEvent.click(option); |
| 99 | +} |
| 100 | + |
| 101 | +function specAccepts(row: FilterBuilderRow): boolean { |
| 102 | + return ViewFilterRuleSchema.safeParse({ field: row.field, operator: row.operator, value: row.value }) |
| 103 | + .success; |
| 104 | +} |
| 105 | + |
| 106 | +describe('#26348 — a boolean carried into a list is a list the spec accepts', () => { |
| 107 | + it('the schema refuses the list the builder used to emit (non-vacuity)', () => { |
| 108 | + expect(ViewFilterRuleSchema.safeParse({ field: 'stage', operator: 'in', value: [true] }).success).toBe(false); |
| 109 | + expect(ViewFilterRuleSchema.safeParse({ field: 'stage', operator: 'in', value: ['true'] }).success).toBe(true); |
| 110 | + }); |
| 111 | + |
| 112 | + it('an `equals true` row on a select column, switched to In, emits ["true"]', async () => { |
| 113 | + const onChange = renderAuthored({ id: 'c1', field: 'stage', operator: 'equals', value: true }); |
| 114 | + await pickOperator('In'); |
| 115 | + const row = lastRow(onChange); |
| 116 | + expect(row.operator).toBe('in'); |
| 117 | + expect(row.value).toEqual(['true']); |
| 118 | + expect(specAccepts(row)).toBe(true); |
| 119 | + // Still drawn: the list control compares entries with `String()`, so the |
| 120 | + // option whose value is "true" is the one ticked. |
| 121 | + const ticked = screen.getAllByRole('checkbox').filter((c) => c.getAttribute('data-state') === 'checked'); |
| 122 | + expect(ticked).toHaveLength(1); |
| 123 | + expect(ticked[0].closest('label')).toHaveTextContent('True'); |
| 124 | + }); |
| 125 | + |
| 126 | + it('`equals false` switched to Not in emits ["false"]', async () => { |
| 127 | + const onChange = renderAuthored({ id: 'c1', field: 'stage', operator: 'equals', value: false }); |
| 128 | + await pickOperator('Not in'); |
| 129 | + const row = lastRow(onChange); |
| 130 | + expect(row.value).toEqual(['false']); |
| 131 | + expect(specAccepts(row)).toBe(true); |
| 132 | + }); |
| 133 | + |
| 134 | + it('the helpers write a boolean list or range entry as its string spelling', () => { |
| 135 | + expect(reshapeFilterValue(true, 'in')).toEqual(['true']); |
| 136 | + expect(reshapeFilterValue(false, 'not_in')).toEqual(['false']); |
| 137 | + expect(reshapeFilterValue(true, 'between')).toEqual(['true', '']); |
| 138 | + // A scalar target is untouched: `equals true` keeps its boolean. |
| 139 | + expect(reshapeFilterValue(true, 'equals')).toBe(true); |
| 140 | + // A field switch into a list column converts the same way. |
| 141 | + expect(retypeFilterValue(true, 'select', 'in')).toEqual(['true']); |
| 142 | + }); |
| 143 | +}); |
0 commit comments