feat: Add SelectableList component (no-changelog) (#12621)

This commit is contained in:
Charlie Kolb 2025-01-17 16:47:34 +01:00 committed by GitHub
parent b098b19c7f
commit fbc8ca6571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 324 additions and 0 deletions

View file

@ -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:
'<n8n-selectable-list v-bind="args" v-model="model"><template #displayItem="{ name }">Slot content for {{name}}</template></n8n-selectable-list>',
});
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,
},
],
};

View file

@ -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();
});
});

View file

@ -0,0 +1,139 @@
<script setup lang="ts" generic="Value, Item extends { name: string; initialValue: Value }">
import { computed } from 'vue';
import { useI18n } from '../../composables/useI18n';
const { t } = useI18n();
defineSlots<{
// This slot is used to display a selectable item
addItem: (props: Item) => unknown;
// This slot is used to display a selected item
displayItem: (props: Item) => unknown;
}>();
type SelectableListProps = {
inputs: Item[];
disabled?: boolean;
};
const props = withDefaults(defineProps<SelectableListProps>(), {
inputs: () => [],
disabled: false,
});
// Record<inputs[k].name, initialValue>
// Note that only the keys will stay up to date to reflect selected keys
// Whereas the values will not automatically update if the related slot value is updated
const selectedItems = defineModel<Record<string, Value>>({ required: true });
const inputMap = computed(() => Object.fromEntries(props.inputs.map((x) => [x.name, x] as const)));
const visibleSelectables = computed(() => {
return props.inputs
.filter((selectable) => !selectedItems.value.hasOwnProperty(selectable.name))
.sort(itemComparator);
});
const sortedSelectedItems = computed(() => {
return Object.entries(selectedItems.value)
.map(([name, initialValue]) => ({
...inputMap.value[name],
initialValue,
}))
.sort(itemComparator);
});
function addToSelectedItems(name: string) {
selectedItems.value[name] = inputMap.value[name].initialValue;
}
function removeFromSelectedItems(name: string) {
delete selectedItems.value[name];
}
function itemComparator(a: Item, b: Item) {
return a.name.localeCompare(b.name);
}
</script>
<template>
<div>
<div :class="$style.selectableContainer">
<span
v-for="item in visibleSelectables"
:key="item.name"
:class="$style.selectableCell"
:data-test-id="`selectable-list-selectable-${item.name}`"
@click="!props.disabled && addToSelectedItems(item.name)"
>
<slot name="addItem" v-bind="item"
>{{ t('selectableList.addDefault') }} {{ item.name }}</slot
>
</span>
</div>
<div
v-for="item in sortedSelectedItems"
:key="item.name"
:class="$style.slotComboContainer"
:data-test-id="`selectable-list-slot-${item.name}`"
>
<N8nIcon
:class="$style.slotRemoveIcon"
size="xsmall"
:icon="disabled ? 'none' : 'trash'"
:data-test-id="`selectable-list-remove-slot-${item.name}`"
@click="!disabled && removeFromSelectedItems(item.name)"
/>
<div :class="$style.slotContainer">
<slot name="displayItem" v-bind="item" />
</div>
</div>
</div>
</template>
<style lang="scss" module>
.slotComboContainer {
display: flex;
flex-direction: row;
flex-grow: 1;
gap: var(--spacing-2xs);
}
.slotContainer {
flex-grow: 1;
}
.selectableContainer {
width: 100%;
flex-wrap: wrap;
display: flex;
}
.selectableCell {
display: flex;
margin-right: var(--spacing-3xs);
min-width: max-content;
border-radius: var(--border-radius-base);
font-size: small;
background-color: var(--color-ndv-background);
color: var(--text-color-dark);
cursor: pointer;
:hover {
color: var(--color-primary);
}
}
.slotRemoveIcon {
color: var(--color-text-light);
height: 10px;
width: 10px;
margin-top: 3px;
:hover {
cursor: pointer;
}
}
</style>

View file

@ -0,0 +1,28 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`N8nSelectableList > renders disabled collection and clicks do not modify 1`] = `
"<div>
<div class="selectableContainer"><span class="selectableCell" data-test-id="selectable-list-selectable-propA">+ Add a propA</span><span class="selectableCell" data-test-id="selectable-list-selectable-propC">+ Add a propC</span></div>
<div class="slotComboContainer" data-test-id="selectable-list-slot-propB"><span class="n8n-text compact size-xsmall regular n8n-icon slotRemoveIcon slotRemoveIcon n8n-icon slotRemoveIcon slotRemoveIcon" data-test-id="selectable-list-remove-slot-propB"><!----></span>
<div class="slotContainer"></div>
</div>
</div>"
`;
exports[`N8nSelectableList > renders multiple elements with some pre-selected 1`] = `
"<div>
<div class="selectableContainer"><span class="selectableCell" data-test-id="selectable-list-selectable-propB">+ Add a propB</span><span class="selectableCell" data-test-id="selectable-list-selectable-propD">+ Add a propD</span></div>
<div class="slotComboContainer" data-test-id="selectable-list-slot-propA"><span class="n8n-text compact size-xsmall regular n8n-icon slotRemoveIcon slotRemoveIcon n8n-icon slotRemoveIcon slotRemoveIcon" data-test-id="selectable-list-remove-slot-propA"><!----></span>
<div class="slotContainer"></div>
</div>
<div class="slotComboContainer" data-test-id="selectable-list-slot-propC"><span class="n8n-text compact size-xsmall regular n8n-icon slotRemoveIcon slotRemoveIcon n8n-icon slotRemoveIcon slotRemoveIcon" data-test-id="selectable-list-remove-slot-propC"><!----></span>
<div class="slotContainer"></div>
</div>
</div>"
`;
exports[`N8nSelectableList > renders when empty 1`] = `
"<div>
<div class="selectableContainer"></div>
</div>"
`;

View file

@ -0,0 +1,3 @@
import N8nSelectableList from './SelectableList.vue';
export default N8nSelectableList;

View file

@ -33,6 +33,7 @@ export { default as N8nNodeCreatorNode } from './N8nNodeCreatorNode';
export { default as N8nNodeIcon } from './N8nNodeIcon'; export { default as N8nNodeIcon } from './N8nNodeIcon';
export { default as N8nNotice } from './N8nNotice'; export { default as N8nNotice } from './N8nNotice';
export { default as N8nOption } from './N8nOption'; export { default as N8nOption } from './N8nOption';
export { default as N8nSelectableList } from './N8nSelectableList';
export { default as N8nPopover } from './N8nPopover'; export { default as N8nPopover } from './N8nPopover';
export { default as N8nPulse } from './N8nPulse'; export { default as N8nPulse } from './N8nPulse';
export { default as N8nRadioButtons } from './N8nRadioButtons'; export { default as N8nRadioButtons } from './N8nRadioButtons';

View file

@ -51,4 +51,5 @@ export default {
'iconPicker.button.defaultToolTip': 'Choose icon', 'iconPicker.button.defaultToolTip': 'Choose icon',
'iconPicker.tabs.icons': 'Icons', 'iconPicker.tabs.icons': 'Icons',
'iconPicker.tabs.emojis': 'Emojis', 'iconPicker.tabs.emojis': 'Emojis',
'selectableList.addDefault': '+ Add a',
} as N8nLocale; } as N8nLocale;