Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strong-hotels-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blinkk/root-cms': patch
---

feat: add `variant: list` to `schema.multiselect`
4 changes: 4 additions & 0 deletions docs/root-cms.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,10 @@ export interface TemplateSandboxFields {
datetimeWithTimezone?: number;
/** DateField */
date?: string;
/** MultiSelect */
multiselect?: string[];
/** MultiSelect (String List) */
multiselectStringList?: string[];
/** StringField (Textarea) */
string?: string;
/** StringField (JSON) */
Expand Down
13 changes: 13 additions & 0 deletions docs/templates/TemplateSandbox/TemplateSandbox.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export default schema.define({
id: 'date',
label: 'DateField',
}),
schema.multiselect({
id: 'multiselect',
label: 'MultiSelect',
translate: true,
creatable: true,
}),
schema.multiselect({
id: 'multiselectStringList',
label: 'MultiSelect (String List)',
translate: true,
variant: 'list',
creatable: true,
}),
schema.string({
id: 'string',
label: 'StringField (Textarea)',
Expand Down
6 changes: 6 additions & 0 deletions packages/root-cms/core/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ export type MultiSelectField = Omit<SelectField, 'type'> & {
/** Set to `true` to allow users to create arbitrary values. */
creatable?: boolean;
translate?: boolean;
/**
* The UI variant to use. `multiselect` renders as a dropdown with search and
* selection capabilities. `list` renders as a list of text inputs with
* drag-and-drop reordering.
*/
variant?: 'multiselect' | 'list';
};

export function multiselect(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {render} from '@testing-library/preact';
import {describe, it, vi, expect} from 'vitest';
import {MultiSelectField} from './MultiSelectField.js';

const setValueMock = vi.fn();
const useDraftDocValueMock = vi.fn();

vi.mock('../../../hooks/useDraftDoc.js', () => ({
useDraftDocValue: (key: string, defaultValue: unknown) =>
useDraftDocValueMock(key, defaultValue),
}));

// Stub drag-and-drop so StringListField renders without errors.
vi.mock('@hello-pangea/dnd', () => ({
DragDropContext: ({children}: any) => children,
Droppable: ({children}: any) =>
children({innerRef: () => {}, droppableProps: {}}, {}),
Draggable: ({children}: any) =>
children(
{innerRef: () => {}, draggableProps: {}, dragHandleProps: {}},
{isDragging: false}
),
}));

// Stub Mantine components that require MantineProvider context.
vi.mock('@mantine/core', () => ({
MultiSelect: (props: any) => (
<select data-testid="mantine-multiselect" multiple>
{(props.value || []).map((v: string) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
),
ActionIcon: ({children, ...props}: any) => (
<button {...props}>{children}</button>
),
Button: ({children, ...props}: any) => <button {...props}>{children}</button>,
Tooltip: ({children}: any) => children,
}));

describe('MultiSelectField serialization', () => {
const sampleData: string[] = ['alpha', 'beta', 'gamma'];

it('multiselect variant reads string[] from draft doc', () => {
useDraftDocValueMock.mockReturnValue([sampleData, setValueMock]);

render(
<MultiSelectField
field={{type: 'multiselect', label: 'Tags'}}
deepKey="fields.tags"
/>
);

expect(useDraftDocValueMock).toHaveBeenCalledWith('fields.tags', []);
});

it('list variant reads string[] from draft doc', () => {
useDraftDocValueMock.mockReturnValue([sampleData, setValueMock]);

render(
<MultiSelectField
field={{type: 'multiselect', label: 'Tags', variant: 'list'}}
deepKey="fields.tags"
/>
);

expect(useDraftDocValueMock).toHaveBeenCalledWith('fields.tags', []);
});

it('both variants use the same default value', () => {
useDraftDocValueMock.mockReturnValue([[], setValueMock]);

const calls: unknown[][] = [];

// Render multiselect variant.
render(
<MultiSelectField
field={{type: 'multiselect', label: 'Tags'}}
deepKey="fields.tags"
/>
);
calls.push(useDraftDocValueMock.mock.calls.at(-1)!);

// Render list variant.
render(
<MultiSelectField
field={{type: 'multiselect', label: 'Tags', variant: 'list'}}
deepKey="fields.tags"
/>
);
calls.push(useDraftDocValueMock.mock.calls.at(-1)!);

// Both should pass the same (key, default) to useDraftDocValue.
expect(calls[0]).toEqual(calls[1]);
});

it('list variant writes string[] via setValue', () => {
useDraftDocValueMock.mockReturnValue([sampleData, setValueMock]);
setValueMock.mockClear();

const {container} = render(
<MultiSelectField
field={{type: 'multiselect', label: 'Tags', variant: 'list'}}
deepKey="fields.tags"
/>
);

// Simulate typing in the first input.
const input = container.querySelector('input') as HTMLInputElement;
expect(input).not.toBeNull();
input.value = 'alpha-edited';
input.dispatchEvent(new Event('input', {bubbles: true}));

// setValue should have been called with a string[].
expect(setValueMock).toHaveBeenCalled();
const written = setValueMock.mock.calls[0][0];
expect(Array.isArray(written)).toBe(true);
written.forEach((v: unknown) => expect(typeof v).toBe('string'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@ import {useMemo} from 'preact/hooks';
import * as schema from '../../../../core/schema.js';
import {useDraftDocValue} from '../../../hooks/useDraftDoc.js';
import {FieldProps} from './FieldProps.js';
import {StringListField} from './StringListField.js';

export function MultiSelectField(props: FieldProps) {
const field = props.field as schema.MultiSelectField;

if (field.variant === 'list') {
return <StringListField {...props} />;
}

return <DefaultMultiSelectField {...props} />;
}

/** Default multiselect variant using the Mantine MultiSelect dropdown. */
function DefaultMultiSelectField(props: FieldProps) {
const field = props.field as schema.MultiSelectField;
const [value, setValue] = useDraftDocValue<string[]>(props.deepKey, []);

const options = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
.StringListField__items {
display: flex;
flex-direction: column;
gap: 0;
}

.StringListField__item {
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--color-border);
}

.StringListField__item:first-child {
border-top: 1px solid var(--color-border);
}

.StringListField__item--dragging {
background-color: #f8f9fa;
border: 1px solid #dedede;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05) 0 10px 15px -5px,
rgba(0, 0, 0, 0.04) 0 7px 7px -5px;
}

.StringListField__item__handle {
display: flex;
align-items: center;
padding: 0 4px;
color: var(--color-text-subtle);
cursor: grab;
}

.StringListField__item__handle:active {
cursor: grabbing;
}

.StringListField__item__input {
flex: 1;
}

.StringListField__item__input input {
width: 100%;
border: none;
background: transparent;
padding: 6px 4px;
font-size: 13px;
font-family: inherit;
outline: none;
}

.StringListField__item__input input:focus {
background: var(--color-bg-subtle);
}

.StringListField__item__remove {
padding: 0 4px;
}

.StringListField__add {
margin-top: 8px;
}
Loading
Loading