Skip to content

Commit 2666dd1

Browse files
committed
Add basic components
1 parent f51c401 commit 2666dd1

21 files changed

Lines changed: 2442 additions & 36 deletions

packages/solid-querybuilder/scripts/ssr-smoke-entry.jsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,23 @@
66
* call. The library is imported by BARE SPECIFIER on purpose: that exercises the `solid` export
77
* condition the same way a real SSR consumer does.
88
*
9+
* `QueryBuilder` (not a placeholder) since step 4: it uses `createContext`, `createStore`, and
10+
* `createEffect`, so it is also the thing that would break first if the two module graphs ever
11+
* stopped sharing one Solid instance.
12+
*
913
* Plain `.jsx`, not `.tsx`, so it stays out of the typecheck project — `bun run check` must not
1014
* depend on `dist/` existing.
1115
*/
1216
import { renderToString } from '@solidjs/web';
13-
import { Placeholder } from 'solid-querybuilder';
17+
import { QueryBuilder } from 'solid-querybuilder';
18+
19+
const fields = [{ name: 'f1', label: 'F1' }];
20+
21+
const query = {
22+
id: 'root',
23+
combinator: 'and',
24+
rules: [{ id: 'r1', field: 'f1', operator: '=', value: 'v1' }],
25+
};
1426

15-
export const render = () => renderToString(() => <Placeholder label="ssr-smoke" />);
27+
export const render = () =>
28+
renderToString(() => <QueryBuilder fields={fields} query={query} onQueryChange={() => {}} />);

packages/solid-querybuilder/scripts/ssr-smoke.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
* `renderToString` from `@solidjs/web` (synchronous in Solid 2), and assert the full
1414
* markup.
1515
*
16-
* At step 1 the component under test is `Placeholder`; step 4 repoints this at `QueryBuilder`.
16+
* The component under test is `QueryBuilder` (step 4 repointed this from the step-1
17+
* `Placeholder`) — it exercises `createContext`, `createStore`, and `createEffect`, which is
18+
* what makes the single-Solid-instance requirement below load-bearing rather than theoretical.
1719
* Step 8 adds a SolidStart SSR gate but keeps this script, because it is the only thing that
1820
* checks the export condition in isolation.
1921
*/
@@ -153,11 +155,44 @@ const html: string = mod.render();
153155

154156
await vite.close();
155157

156-
const expectedHtml = '<div data-testid="solid-querybuilder-placeholder">ssr-smoke</div>';
158+
// The markup assertion. Not a full-string comparison: the rendered tree is ~2KB and dominated
159+
// by the default operator list, which would make this a snapshot in all but name. Instead it
160+
// asserts every structural claim the SSR path is here to make — that the wrapper, the group, the
161+
// rule, and each of the rule's controls all rendered, with the *value* of the controlled query
162+
// present — plus the exact number of `data-testid` elements, so a dropped or added control turns
163+
// this red.
164+
const requiredFragments = [
165+
'<div role="form" class="queryBuilder" data-dnd="disabled" data-inlinecombinators="disabled">',
166+
'data-testid="rule-group"',
167+
'class="ruleGroup-header"',
168+
'data-testid="combinators"',
169+
'data-testid="add-rule"',
170+
'data-testid="add-group"',
171+
'class="ruleGroup-body"',
172+
'data-testid="rule"',
173+
'data-path="[0]"',
174+
'data-testid="fields"',
175+
'data-testid="operators"',
176+
'data-testid="value-editor"',
177+
'value="v1"',
178+
'data-testid="remove-rule"',
179+
];
180+
181+
const expectedTestIdCount = 9;
182+
183+
for (const fragment of requiredFragments) {
184+
if (!html.includes(fragment)) {
185+
fail(`SSR markup is missing ${fragment}.\n actual: ${html}`);
186+
}
187+
}
157188

158-
if (html !== expectedHtml) {
159-
fail(`SSR markup mismatch.\n expected: ${expectedHtml}\n actual: ${html}`);
189+
const testIdCount = html.match(/data-testid=/g)?.length ?? 0;
190+
if (testIdCount !== expectedTestIdCount) {
191+
fail(
192+
`SSR markup has ${testIdCount} \`data-testid\` elements, expected ${expectedTestIdCount}.` +
193+
`\n actual: ${html}`
194+
);
160195
}
161196

162-
console.log(`SSR render: ${html} (ok)`);
197+
console.log(`SSR render: ${testIdCount} controls, all structural fragments present (ok)`);
163198
console.log('test:ssr passed.');
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { render } from '@solidjs/testing-library';
2+
import { createSignal, flush } from 'solid-js';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import type { ActionProps } from '../types/props.js';
5+
import { ActionElement } from './ActionElement.jsx';
6+
7+
const baseProps = (overrides: Partial<ActionProps> = {}): ActionProps =>
8+
({
9+
label: 'Add rule',
10+
title: 'Add rule title',
11+
className: 'custom',
12+
testID: 'add-rule',
13+
path: [],
14+
level: 0,
15+
handleOnClick: () => {},
16+
ruleOrGroup: { combinator: 'and', rules: [] },
17+
schema: {},
18+
...overrides,
19+
}) as ActionProps;
20+
21+
describe('ActionElement', () => {
22+
it('renders a button carrying the label, title, class, and test ID', () => {
23+
const { getByTestId } = render(() => <ActionElement {...baseProps()} />);
24+
const button = getByTestId('add-rule');
25+
expect(button.tagName).toBe('BUTTON');
26+
expect(button).toHaveAttribute('type', 'button');
27+
expect(button).toHaveAttribute('title', 'Add rule title');
28+
expect(button).toHaveClass('custom');
29+
expect(button.textContent).toBe('Add rule');
30+
expect(button).not.toBeDisabled();
31+
});
32+
33+
it('calls handleOnClick with the event', () => {
34+
const handleOnClick = vi.fn();
35+
const { getByTestId } = render(() => <ActionElement {...baseProps({ handleOnClick })} />);
36+
getByTestId('add-rule').click();
37+
expect(handleOnClick).toHaveBeenCalledTimes(1);
38+
expect(handleOnClick.mock.calls[0][0]).toBeInstanceOf(MouseEvent);
39+
});
40+
41+
it('is disabled when `disabled` is set and there is no disabled translation', () => {
42+
const { getByTestId } = render(() => <ActionElement {...baseProps({ disabled: true })} />);
43+
expect(getByTestId('add-rule')).toBeDisabled();
44+
});
45+
46+
it('stays enabled and swaps label/title when a disabled translation is supplied', () => {
47+
const { getByTestId } = render(() => (
48+
<ActionElement
49+
{...baseProps({
50+
disabled: true,
51+
disabledTranslation: { label: 'Unlock', title: 'Unlock title' },
52+
})}
53+
/>
54+
));
55+
const button = getByTestId('add-rule');
56+
expect(button).not.toBeDisabled();
57+
expect(button.textContent).toBe('Unlock');
58+
expect(button).toHaveAttribute('title', 'Unlock title');
59+
});
60+
61+
it('ignores the disabled translation while enabled', () => {
62+
const { getByTestId } = render(() => (
63+
<ActionElement
64+
{...baseProps({ disabledTranslation: { label: 'Unlock', title: 'Unlock title' } })}
65+
/>
66+
));
67+
expect(getByTestId('add-rule').textContent).toBe('Add rule');
68+
});
69+
70+
/**
71+
* The props-reactivity gate for this component. A destructure at the top of `ActionElement`
72+
* severs every one of these and fails no type check.
73+
*/
74+
it('updates when its props change', () => {
75+
const [label, setLabel] = createSignal('one');
76+
const [disabled, setDisabled] = createSignal(false);
77+
const { getByTestId } = render(() => (
78+
<ActionElement
79+
{...baseProps({
80+
get label() {
81+
return label();
82+
},
83+
get disabled() {
84+
return disabled();
85+
},
86+
})}
87+
/>
88+
));
89+
const button = getByTestId('add-rule');
90+
expect(button.textContent).toBe('one');
91+
expect(button).not.toBeDisabled();
92+
93+
setLabel('two');
94+
setDisabled(true);
95+
flush();
96+
97+
expect(button.textContent).toBe('two');
98+
expect(button).toBeDisabled();
99+
});
100+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { JSX } from '@solidjs/web';
2+
import { Label } from '../internal/Label.jsx';
3+
import type { ActionProps } from '../types/props.js';
4+
5+
/**
6+
* Default `<button>` component for every action control.
7+
*
8+
* Port of React Query Builder's `ActionElement`. When the control is disabled *and* a
9+
* `disabledTranslation` is supplied, the button stays enabled (so the tooltip is reachable) and
10+
* renders that translation's label and title instead of its own.
11+
*/
12+
export const ActionElement = (props: ActionProps): JSX.Element => {
13+
const useDisabledTranslation = () => !!props.disabledTranslation && !!props.disabled;
14+
15+
return (
16+
<button
17+
type="button"
18+
data-testid={props.testID}
19+
disabled={props.disabled && !props.disabledTranslation}
20+
class={props.className}
21+
title={useDisabledTranslation() ? props.disabledTranslation?.title : props.title}
22+
onClick={e => props.handleOnClick(e)}>
23+
<Label label={useDisabledTranslation() ? props.disabledTranslation?.label : props.label} />
24+
</button>
25+
);
26+
};

packages/solid-querybuilder/src/components/Placeholder.test.tsx

Lines changed: 0 additions & 19 deletions
This file was deleted.

packages/solid-querybuilder/src/components/Placeholder.tsx

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)