mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-21 02:56:40 -08:00
feat: Add SelectableList component (no-changelog) (#12621)
This commit is contained in:
parent
b098b19c7f
commit
fbc8ca6571
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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>"
|
||||||
|
`;
|
|
@ -0,0 +1,3 @@
|
||||||
|
import N8nSelectableList from './SelectableList.vue';
|
||||||
|
|
||||||
|
export default N8nSelectableList;
|
|
@ -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';
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue