Skip to content

Commit 7a04cf0

Browse files
committed
Add remaining components
1 parent 2666dd1 commit 7a04cf0

25 files changed

Lines changed: 2071 additions & 623 deletions

‎AGENTS.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,12 @@ it passes whether or not the `snapshot()` is there:
173173
- Element order and conditional rendering are specified by React's `Rule.tsx` / `RuleGroup.tsx`.
174174
Read them as a spec, not as code to translate.
175175
- `Label` is a plain function component, not a fragment-returning helper with stray whitespace.
176+
- **`defaultControlElements` is an object of getters, deliberately.** `Rule` → `RuleSubQuery` →
177+
`defaultControlElements` → `Rule` is a real import cycle (a subquery builds its own state, which
178+
needs the default controls). Eager entries throw a TDZ `ReferenceError` whenever `Rule.tsx` is
179+
the module the cycle is entered through. Do not "simplify" them back to plain properties.
180+
- A subquery renders **bare `<div>`s** for its group header/body, not a `rule-group` element
181+
(React's `RuleWithSubQueryGroupComponentsWrapper`), and it is not customizable.
176182

177183
### Types
178184

@@ -197,11 +203,11 @@ it passes whether or not the `snapshot()` is there:
197203
**Standing rule: every gate must be proven to fail.** When a step adds a gate, deliberately break
198204
it, record that it went red, then revert. A gate that cannot fail is worse than none.
199205

200-
Current gates (step 3): `check:versions`, `fmt:check`, `build`, `check`, `check:exports`,
201-
`lint`, `test:coverage` (global 80% lines, plus a per-directory 90% lines on
202-
`packages/*/src/reactive/**` — both now non-vacuous, and both proved red at step 3 with no
203-
injected dead code), `test:ssr`. (`conformance` is a stub that exits 0 until step 6; it is not a
204-
gate yet.)
206+
Current gates (step 5): `check:versions`, `fmt:check`, `build`, `check`, `check:exports`,
207+
`lint`, `test:coverage` (global 80% lines, plus a per-directory 90% lines on `packages/*/src/**` —
208+
widened at step 5 from the step-3 `packages/*/src/reactive/**`, which it subsumes; both
209+
non-vacuous, both proved red with no injected dead code), `test:ssr`. (`conformance` is a stub
210+
that exits 0 until step 6; it is not a gate yet.)
205211

206212
All five were proven red at step 1 and reverted: coverage (threshold to 99 + an injected
207213
uncovered function), export-condition **order** (`import` moved first), export-condition
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { defaultCombinators, standardClassnames } from '@react-querybuilder/core';
2+
import { render } from '@solidjs/testing-library';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import type { InlineCombinatorProps } from '../types/props.js';
5+
import { InlineCombinator } from './InlineCombinator.jsx';
6+
import { ValueSelector } from './ValueSelector.jsx';
7+
8+
const baseProps = (overrides: Partial<InlineCombinatorProps> = {}): InlineCombinatorProps =>
9+
({
10+
component: ValueSelector,
11+
options: defaultCombinators,
12+
value: 'and',
13+
title: 'Combinator',
14+
className: 'ruleGroup-combinators',
15+
handleOnChange: () => {},
16+
testID: 'ignored',
17+
rules: [],
18+
ruleGroup: { combinator: 'and', rules: [] },
19+
path: [1],
20+
level: 1,
21+
schema: { classNames: {}, suppressStandardClassnames: false },
22+
...overrides,
23+
}) as unknown as InlineCombinatorProps;
24+
25+
const Custom = () => <span data-testid="custom-selector" />;
26+
27+
describe('InlineCombinator', () => {
28+
it('wraps the selector in a div carrying the between-rules classes', () => {
29+
const { getByTestId } = render(() => (
30+
<InlineCombinator
31+
{...baseProps({
32+
schema: {
33+
classNames: { betweenRules: 'custom-between' },
34+
suppressStandardClassnames: false,
35+
},
36+
} as never)}
37+
/>
38+
));
39+
const wrapper = getByTestId('inline-combinator');
40+
expect(wrapper.tagName).toBe('DIV');
41+
expect(wrapper).toHaveClass(standardClassnames.betweenRules);
42+
expect(wrapper).toHaveClass('custom-between');
43+
});
44+
45+
it('suppresses the standard class when asked', () => {
46+
const { getByTestId } = render(() => (
47+
<InlineCombinator
48+
{...baseProps({
49+
schema: { classNames: {}, suppressStandardClassnames: true },
50+
} as never)}
51+
/>
52+
));
53+
expect(getByTestId('inline-combinator')).not.toHaveClass(standardClassnames.betweenRules);
54+
});
55+
56+
it('forwards every prop but `component` to the selector, overriding its testID', () => {
57+
const handleOnChange = vi.fn();
58+
const { getByTestId } = render(() => <InlineCombinator {...baseProps({ handleOnChange })} />);
59+
const select = getByTestId('combinators') as HTMLSelectElement;
60+
// `testID` is overridden, so the incoming `'ignored'` never reaches the DOM.
61+
expect(getByTestId('inline-combinator').querySelector('[data-testid="ignored"]')).toBeNull();
62+
expect(select.tagName).toBe('SELECT');
63+
expect(select).toHaveClass('ruleGroup-combinators');
64+
expect(select).toHaveAttribute('title', 'Combinator');
65+
expect(select.value).toBe('and');
66+
select.value = 'or';
67+
select.dispatchEvent(new Event('change', { bubbles: true }));
68+
expect(handleOnChange).toHaveBeenCalledWith('or');
69+
});
70+
71+
it('renders whatever selector it is handed', () => {
72+
const { getByTestId } = render(() => (
73+
<InlineCombinator {...baseProps({ component: Custom as never })} />
74+
));
75+
expect(getByTestId('custom-selector')).toBeInTheDocument();
76+
});
77+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { TestID, clsx, standardClassnames } from '@react-querybuilder/core';
2+
import type { JSX } from '@solidjs/web';
3+
import { Dynamic } from '@solidjs/web';
4+
import { omit } from 'solid-js';
5+
import type { InlineCombinatorProps } from '../types/props.js';
6+
7+
/**
8+
* Default `inlineCombinator` component: a small `<div>` around the `combinatorSelector`
9+
* component, rendered when either `showCombinatorsBetweenRules` or independent combinators are
10+
* in play.
11+
*
12+
* Port of React Query Builder's `InlineCombinator`. The selector to render arrives as the
13+
* `component` prop and everything else is forwarded to it, with `testID` overridden to
14+
* `TestID.combinators` — which is why callers render this with `createComponent` rather than
15+
* `<Dynamic>`: `<Dynamic>` consumes a prop named `component` for itself and can never forward
16+
* one.
17+
*/
18+
export const InlineCombinator = (props: InlineCombinatorProps): JSX.Element => {
19+
const className = () =>
20+
clsx(
21+
props.schema.suppressStandardClassnames || standardClassnames.betweenRules,
22+
props.schema.classNames.betweenRules
23+
);
24+
25+
return (
26+
<div class={className()} data-testid={TestID.inlineCombinator}>
27+
{/* `omit` returns the rest only; the local half is read straight off `props`. */}
28+
<Dynamic
29+
component={props.component}
30+
{...omit(props, 'component')}
31+
testID={TestID.combinators}
32+
/>
33+
</div>
34+
);
35+
};
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { render } from '@solidjs/testing-library';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import type { MatchModeEditorProps } from '../types/props.js';
4+
import { MatchModeEditor } from './MatchModeEditor.jsx';
5+
import { ValueEditor } from './ValueEditor.jsx';
6+
import { ValueSelector } from './ValueSelector.jsx';
7+
8+
const options = [
9+
{ name: 'all', value: 'all', label: 'all' },
10+
{ name: 'atLeast', value: 'atLeast', label: 'at least' },
11+
];
12+
13+
const schema = {
14+
controls: { valueSelector: ValueSelector, valueEditor: ValueEditor },
15+
classNames: {},
16+
suppressStandardClassnames: false,
17+
};
18+
19+
const baseProps = (overrides: Partial<MatchModeEditorProps> = {}): MatchModeEditorProps =>
20+
({
21+
testID: 'match-mode-editor',
22+
className: 'rule-matchMode',
23+
title: 'Match mode',
24+
match: { mode: 'all' },
25+
options,
26+
field: 'sub',
27+
fieldData: { name: 'sub', value: 'sub', label: 'Sub' },
28+
classNames: { matchMode: 'rule-matchMode', matchThreshold: 'rule-matchThreshold' },
29+
handleOnChange: () => {},
30+
path: [0],
31+
level: 1,
32+
schema,
33+
...overrides,
34+
}) as unknown as MatchModeEditorProps;
35+
36+
/** Both controls share one `testID`, as upstream does. */
37+
const controls = (getAllByTestId: (id: string) => HTMLElement[]): HTMLElement[] =>
38+
getAllByTestId('match-mode-editor');
39+
40+
describe('MatchModeEditor', () => {
41+
it('renders only the mode selector for a mode with no threshold', () => {
42+
const { getAllByTestId } = render(() => <MatchModeEditor {...baseProps()} />);
43+
const rendered = controls(getAllByTestId);
44+
expect(rendered).toHaveLength(1);
45+
expect(rendered[0].tagName).toBe('SELECT');
46+
expect((rendered[0] as HTMLSelectElement).value).toBe('all');
47+
expect(rendered[0]).toHaveClass('rule-matchMode');
48+
expect(rendered[0]).toHaveAttribute('title', 'Match mode');
49+
});
50+
51+
it('adds a numeric threshold editor for a mode that takes one', () => {
52+
const { getAllByTestId } = render(() => (
53+
<MatchModeEditor {...baseProps({ match: { mode: 'atLeast', threshold: 3 } })} />
54+
));
55+
const rendered = controls(getAllByTestId);
56+
expect(rendered).toHaveLength(2);
57+
expect(rendered[1].tagName).toBe('INPUT');
58+
expect(rendered[1]).toHaveAttribute('type', 'number');
59+
expect((rendered[1] as HTMLInputElement).value).toBe('3');
60+
});
61+
62+
it('defaults the threshold to 1 when switching to a mode that requires one', () => {
63+
const handleOnChange = vi.fn();
64+
const { getAllByTestId } = render(() => <MatchModeEditor {...baseProps({ handleOnChange })} />);
65+
const select = controls(getAllByTestId)[0] as HTMLSelectElement;
66+
select.value = 'atLeast';
67+
select.dispatchEvent(new Event('change', { bubbles: true }));
68+
expect(handleOnChange).toHaveBeenCalledWith({ mode: 'atLeast', threshold: 1 });
69+
});
70+
71+
it('keeps an existing threshold when switching modes', () => {
72+
const handleOnChange = vi.fn();
73+
const { getAllByTestId } = render(() => (
74+
<MatchModeEditor
75+
{...baseProps({ match: { mode: 'atLeast', threshold: 4 }, handleOnChange })}
76+
/>
77+
));
78+
const select = controls(getAllByTestId)[0] as HTMLSelectElement;
79+
select.value = 'all';
80+
select.dispatchEvent(new Event('change', { bubbles: true }));
81+
expect(handleOnChange).toHaveBeenCalledWith({ mode: 'all', threshold: 4 });
82+
});
83+
84+
it('reports a parsed number when the threshold changes', () => {
85+
const handleOnChange = vi.fn();
86+
const { getAllByTestId } = render(() => (
87+
<MatchModeEditor
88+
{...baseProps({ match: { mode: 'exactly', threshold: 2 }, handleOnChange })}
89+
/>
90+
));
91+
const input = controls(getAllByTestId)[1] as HTMLInputElement;
92+
input.value = '7';
93+
input.dispatchEvent(new Event('input', { bubbles: true }));
94+
expect(handleOnChange).toHaveBeenCalledWith({ mode: 'exactly', threshold: 7 });
95+
});
96+
97+
it('clamps a negative threshold and defaults a missing one', () => {
98+
const { getAllByTestId, unmount } = render(() => (
99+
<MatchModeEditor {...baseProps({ match: { mode: 'atMost', threshold: -5 } })} />
100+
));
101+
expect((controls(getAllByTestId)[1] as HTMLInputElement).value).toBe('0');
102+
unmount();
103+
104+
const second = render(() => <MatchModeEditor {...baseProps({ match: { mode: 'atMost' } })} />);
105+
expect((second.getAllByTestId('match-mode-editor')[1] as HTMLInputElement).value).toBe('1');
106+
});
107+
108+
it('passes the threshold placeholder through as field data', () => {
109+
const { getAllByTestId } = render(() => (
110+
<MatchModeEditor
111+
{...baseProps({
112+
match: { mode: 'atLeast', threshold: 1 },
113+
thresholdPlaceholder: 'How many',
114+
})}
115+
/>
116+
));
117+
expect(controls(getAllByTestId)[1]).toHaveAttribute('placeholder', 'How many');
118+
});
119+
120+
it('accepts replacement selector and editor components', () => {
121+
const { getByTestId } = render(() => (
122+
<MatchModeEditor
123+
{...baseProps({
124+
match: { mode: 'atLeast', threshold: 1 },
125+
selectorComponent: (() => <span data-testid="custom-selector" />) as never,
126+
numericEditorComponent: (() => <span data-testid="custom-editor" />) as never,
127+
})}
128+
/>
129+
));
130+
expect(getByTestId('custom-selector')).toBeInTheDocument();
131+
expect(getByTestId('custom-editor')).toBeInTheDocument();
132+
});
133+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { FullField, MatchMode } from '@react-querybuilder/core';
2+
import { lc, parseNumber } from '@react-querybuilder/core';
3+
import type { JSX } from '@solidjs/web';
4+
import { Dynamic } from '@solidjs/web';
5+
import { Show, createMemo, merge } from 'solid-js';
6+
import type { MatchModeEditorProps } from '../types/props.js';
7+
8+
const dummyFieldData: FullField = { name: '', value: '', label: '' };
9+
const dummyPath: never[] = [];
10+
11+
/** Whether a match mode carries a numeric threshold. */
12+
const requiresThreshold = (mm?: string | null): boolean =>
13+
['atleast', 'atmost', 'exactly'].includes(lc(mm) ?? '');
14+
15+
/**
16+
* Default `matchModeEditor` component: a mode selector, plus a numeric threshold editor for the
17+
* modes that take one.
18+
*
19+
* Port of React Query Builder's `MatchModeEditor`. Both controls carry the *same* `testID`, as
20+
* upstream does — tests reach the threshold editor with `getAllByTestId(...)[1]`.
21+
*
22+
* React's `useMatchModeEditor` hook is not reproduced as a composable: it is four memos and two
23+
* callbacks, all of which are one-liners here.
24+
*/
25+
export const MatchModeEditor = (props: MatchModeEditorProps): JSX.Element => {
26+
const selectorComponent = () => props.selectorComponent ?? props.schema.controls.valueSelector;
27+
const numericEditorComponent = () =>
28+
props.numericEditorComponent ?? props.schema.controls.valueEditor;
29+
30+
const thresholdNum = createMemo(() =>
31+
typeof props.match.threshold === 'number' ? Math.max(0, props.match.threshold) : 1
32+
);
33+
const thresholdRule = createMemo(() => ({ field: '', operator: '=', value: thresholdNum() }));
34+
const thresholdFieldData = createMemo(() =>
35+
props.thresholdPlaceholder
36+
? { ...dummyFieldData, placeholder: props.thresholdPlaceholder }
37+
: dummyFieldData
38+
);
39+
40+
// `merge`, not a spread: `schema` is a getter object, and spreading it would snapshot every
41+
// getter into a value and sever reactivity. `merge` is lazy.
42+
const thresholdSchema = merge(
43+
() => props.schema,
44+
() => ({ parseNumbers: true }) as const
45+
);
46+
47+
const handleChangeMode = (mode: MatchMode): void => {
48+
props.handleOnChange(
49+
requiresThreshold(mode) && typeof props.match.threshold !== 'number'
50+
? { ...props.match, mode, threshold: 1 }
51+
: { ...props.match, mode }
52+
);
53+
};
54+
55+
const handleChangeThreshold = (threshold: number): void => {
56+
props.handleOnChange({
57+
...props.match,
58+
threshold: parseNumber(threshold, { parseNumbers: true }),
59+
});
60+
};
61+
62+
return (
63+
<>
64+
<Dynamic
65+
component={selectorComponent()}
66+
schema={props.schema}
67+
testID={props.testID}
68+
className={props.className}
69+
title={props.title}
70+
handleOnChange={handleChangeMode}
71+
disabled={props.disabled}
72+
value={props.match.mode}
73+
options={props.options}
74+
multiple={false}
75+
listsAsArrays={false}
76+
path={dummyPath}
77+
level={0}
78+
/>
79+
<Show when={requiresThreshold(props.match.mode)}>
80+
<Dynamic
81+
component={numericEditorComponent()}
82+
skipHook
83+
testID={props.testID}
84+
inputType="number"
85+
title={props.title}
86+
className={props.className}
87+
disabled={props.disabled}
88+
handleOnChange={handleChangeThreshold}
89+
field=""
90+
operator=""
91+
value={thresholdNum()}
92+
valueSource="value"
93+
fieldData={thresholdFieldData()}
94+
schema={thresholdSchema}
95+
path={dummyPath}
96+
level={0}
97+
rule={thresholdRule()}
98+
/>
99+
</Show>
100+
</>
101+
);
102+
};

0 commit comments

Comments
 (0)