diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.stories.ts b/packages/design-system/src/components/N8nSelectableList/SelectableList.stories.ts new file mode 100644 index 0000000000..a4f5244143 --- /dev/null +++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.stories.ts @@ -0,0 +1,45 @@ +import type { StoryFn } from '@storybook/vue3'; + +import N8nSelectableList from './SelectableList.vue'; + +export default { + title: 'Modules/SelectableList', + component: N8nSelectableList, + argTypes: {}, + parameters: { + backgrounds: { default: '--color-background-light' }, + }, +}; + +const Template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ + args: { ...args, modelValue: undefined }, + model: args.modelValue, + }), + props: Object.keys(argTypes), + // Generics make this difficult to type + components: N8nSelectableList as never, + template: + '', +}); + +export const SelectableList = Template.bind({}); +SelectableList.args = { + modelValue: { + propC: 'propC pre-existing initial value', + }, + inputs: [ + { + name: 'propC', + initialValue: 'propC default', + }, + { + name: 'propB', + initialValue: 0, + }, + { + name: 'propA', + initialValue: false, + }, + ], +}; diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts new file mode 100644 index 0000000000..e7ba9d4206 --- /dev/null +++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts @@ -0,0 +1,107 @@ +import { fireEvent, render } from '@testing-library/vue'; + +import N8nSelectableList from './SelectableList.vue'; + +describe('N8nSelectableList', () => { + it('renders when empty', () => { + const wrapper = render(N8nSelectableList, { + props: { + modelValue: {}, + inputs: [], + }, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('renders one clickable element that can be added and removed', async () => { + const wrapper = render(N8nSelectableList, { + props: { + modelValue: {}, + inputs: [{ name: 'propA', initialValue: '' }], + }, + }); + + expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument(); + + await fireEvent.click(wrapper.getByTestId('selectable-list-selectable-propA')); + + expect(wrapper.queryByTestId('selectable-list-selectable-propA')).not.toBeInTheDocument(); + expect(wrapper.getByTestId('selectable-list-slot-propA')).toBeInTheDocument(); + + await fireEvent.click(wrapper.getByTestId('selectable-list-remove-slot-propA')); + + expect(wrapper.queryByTestId('selectable-list-slot-propA')).not.toBeInTheDocument(); + }); + + it('renders multiple elements with some pre-selected', () => { + const wrapper = render(N8nSelectableList, { + props: { + modelValue: { + propC: false, + propA: 'propA value', + }, + inputs: [ + { name: 'propD', initialValue: true }, + { name: 'propC', initialValue: true }, + { name: 'propB', initialValue: 3 }, + { name: 'propA', initialValue: '' }, + ], + }, + }); + + expect(wrapper.queryByTestId('selectable-list-selectable-propA')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('selectable-list-selectable-propC')).not.toBeInTheDocument(); + expect(wrapper.getByTestId('selectable-list-slot-propA')).toBeInTheDocument(); + expect(wrapper.getByTestId('selectable-list-selectable-propB')).toBeInTheDocument(); + expect(wrapper.getByTestId('selectable-list-slot-propC')).toBeInTheDocument(); + expect(wrapper.getByTestId('selectable-list-selectable-propD')).toBeInTheDocument(); + + // This asserts order - specifically that propA appears before propC + expect( + wrapper + .getByTestId('selectable-list-slot-propA') + .compareDocumentPosition(wrapper.getByTestId('selectable-list-slot-propC')), + ).toEqual(4); + + expect( + wrapper + .getByTestId('selectable-list-selectable-propB') + .compareDocumentPosition(wrapper.getByTestId('selectable-list-selectable-propD')), + ).toEqual(4); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('renders disabled collection and clicks do not modify', async () => { + const wrapper = render(N8nSelectableList, { + props: { + modelValue: { + propB: 'propB value', + }, + disabled: true, + inputs: [ + { name: 'propA', initialValue: '' }, + { name: 'propB', initialValue: '' }, + { name: 'propC', initialValue: '' }, + ], + }, + }); + + expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument(); + expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument(); + expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument(); + expect(wrapper.getByTestId('selectable-list-selectable-propC')).toBeInTheDocument(); + + await fireEvent.click(wrapper.getByTestId('selectable-list-selectable-propA')); + + expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument(); + expect(wrapper.queryByTestId('selectable-list-slot-propA')).not.toBeInTheDocument(); + + await fireEvent.click(wrapper.getByTestId('selectable-list-remove-slot-propB')); + + expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument(); + expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument(); + + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.vue b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue new file mode 100644 index 0000000000..e5ba985edf --- /dev/null +++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap b/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap new file mode 100644 index 0000000000..747dbeb778 --- /dev/null +++ b/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap @@ -0,0 +1,28 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`N8nSelectableList > renders disabled collection and clicks do not modify 1`] = ` +"
+
+ Add a propA+ Add a propC
+
+
+
+
" +`; + +exports[`N8nSelectableList > renders multiple elements with some pre-selected 1`] = ` +"
+
+ Add a propB+ Add a propD
+
+
+
+
+
+
+
" +`; + +exports[`N8nSelectableList > renders when empty 1`] = ` +"
+
+
" +`; diff --git a/packages/design-system/src/components/N8nSelectableList/index.ts b/packages/design-system/src/components/N8nSelectableList/index.ts new file mode 100644 index 0000000000..6c2d0e1127 --- /dev/null +++ b/packages/design-system/src/components/N8nSelectableList/index.ts @@ -0,0 +1,3 @@ +import N8nSelectableList from './SelectableList.vue'; + +export default N8nSelectableList; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index ce4360fe46..6b584b8e8e 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -33,6 +33,7 @@ export { default as N8nNodeCreatorNode } from './N8nNodeCreatorNode'; export { default as N8nNodeIcon } from './N8nNodeIcon'; export { default as N8nNotice } from './N8nNotice'; export { default as N8nOption } from './N8nOption'; +export { default as N8nSelectableList } from './N8nSelectableList'; export { default as N8nPopover } from './N8nPopover'; export { default as N8nPulse } from './N8nPulse'; export { default as N8nRadioButtons } from './N8nRadioButtons'; diff --git a/packages/design-system/src/locale/lang/en.ts b/packages/design-system/src/locale/lang/en.ts index 35f2fd9f0e..3743bf92c6 100644 --- a/packages/design-system/src/locale/lang/en.ts +++ b/packages/design-system/src/locale/lang/en.ts @@ -51,4 +51,5 @@ export default { 'iconPicker.button.defaultToolTip': 'Choose icon', 'iconPicker.tabs.icons': 'Icons', 'iconPicker.tabs.emojis': 'Emojis', + 'selectableList.addDefault': '+ Add a', } as N8nLocale;