mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 21:37:32 -08:00
refactor(editor): Migrate ParameterOptions
to composition API (no-changelog) (#11501)
This commit is contained in:
parent
78e7d8dc8c
commit
d0dce57c14
|
@ -98,10 +98,10 @@ function getIssues(index: number): string[] {
|
|||
return issues.value[`${props.parameter.name}.${index}`] ?? [];
|
||||
}
|
||||
|
||||
function optionSelected(action: 'clearAll' | 'addAll') {
|
||||
function optionSelected(action: string) {
|
||||
if (action === 'clearAll') {
|
||||
state.paramValue.assignments = [];
|
||||
} else {
|
||||
} else if (action === 'addAll' && inputData.value) {
|
||||
const newAssignments = inputDataToAssignments(inputData.value);
|
||||
state.paramValue.assignments = state.paramValue.assignments.concat(newAssignments);
|
||||
}
|
||||
|
|
123
packages/editor-ui/src/components/ParameterOptions.test.ts
Normal file
123
packages/editor-ui/src/components/ParameterOptions.test.ts
Normal file
|
@ -0,0 +1,123 @@
|
|||
import { renderComponent } from '@/__tests__/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { within } from '@testing-library/vue';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import ParameterOptions from './ParameterOptions.vue';
|
||||
|
||||
const DEFAULT_PARAMETER = {
|
||||
displayName: 'Fields to Set',
|
||||
name: 'assignments',
|
||||
type: 'assignmentCollection',
|
||||
default: {},
|
||||
};
|
||||
|
||||
describe('ParameterOptions', () => {
|
||||
it('renders default options properly', () => {
|
||||
const { getByTestId } = renderComponent(ParameterOptions, {
|
||||
props: {
|
||||
parameter: DEFAULT_PARAMETER,
|
||||
isReadOnly: false,
|
||||
},
|
||||
});
|
||||
expect(getByTestId('parameter-options-container')).toBeInTheDocument();
|
||||
expect(getByTestId('action-toggle')).toBeInTheDocument();
|
||||
expect(getByTestId('radio-button-fixed')).toBeInTheDocument();
|
||||
expect(getByTestId('radio-button-expression')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("doesn't render expression with showExpression set to false", () => {
|
||||
const { getByTestId, queryByTestId, container } = renderComponent(ParameterOptions, {
|
||||
props: {
|
||||
parameter: DEFAULT_PARAMETER,
|
||||
isReadOnly: false,
|
||||
showExpressionSelector: false,
|
||||
value: 'manual',
|
||||
},
|
||||
});
|
||||
expect(getByTestId('parameter-options-container')).toBeInTheDocument();
|
||||
expect(getByTestId('action-toggle')).toBeInTheDocument();
|
||||
expect(queryByTestId('radio-button-fixed')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('radio-button-expression')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('.noExpressionSelector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state', () => {
|
||||
const CUSTOM_LOADING_MESSAGE = 'Loading...';
|
||||
const { getByTestId, getByText } = renderComponent(ParameterOptions, {
|
||||
props: {
|
||||
parameter: DEFAULT_PARAMETER,
|
||||
isReadOnly: false,
|
||||
showExpressionSelector: false,
|
||||
value: 'manual',
|
||||
loading: true,
|
||||
loadingMessage: CUSTOM_LOADING_MESSAGE,
|
||||
},
|
||||
});
|
||||
expect(getByTestId('parameter-options-loader')).toBeInTheDocument();
|
||||
expect(getByText(CUSTOM_LOADING_MESSAGE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render horizontal icon', () => {
|
||||
const { container } = renderComponent(ParameterOptions, {
|
||||
props: {
|
||||
parameter: DEFAULT_PARAMETER,
|
||||
value: 'manual',
|
||||
isReadOnly: false,
|
||||
iconOrientation: 'horizontal',
|
||||
},
|
||||
});
|
||||
expect(container.querySelector('[data-icon="ellipsis-h"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom actions', async () => {
|
||||
const CUSTOM_ACTIONS = [
|
||||
{ label: 'Action 1', value: 'action1' },
|
||||
{ label: 'Action 2', value: 'action2' },
|
||||
];
|
||||
const { getByTestId } = renderComponent(ParameterOptions, {
|
||||
props: {
|
||||
parameter: DEFAULT_PARAMETER,
|
||||
value: 'manual',
|
||||
isReadOnly: false,
|
||||
customActions: CUSTOM_ACTIONS,
|
||||
},
|
||||
});
|
||||
const actionToggle = getByTestId('action-toggle');
|
||||
const actionToggleButton = within(actionToggle).getByRole('button');
|
||||
expect(actionToggleButton).toBeVisible();
|
||||
await userEvent.click(actionToggle);
|
||||
const actionToggleId = actionToggleButton.getAttribute('aria-controls');
|
||||
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
|
||||
expect(actionDropdown).toBeInTheDocument();
|
||||
// All custom actions should be rendered
|
||||
CUSTOM_ACTIONS.forEach((action) => {
|
||||
expect(within(actionDropdown).getByText(action.label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit update:modelValue when changing to expression', async () => {
|
||||
const { emitted, getByTestId } = renderComponent(ParameterOptions, {
|
||||
props: {
|
||||
parameter: DEFAULT_PARAMETER,
|
||||
value: 'manual',
|
||||
isReadOnly: false,
|
||||
},
|
||||
});
|
||||
expect(getByTestId('radio-button-expression')).toBeInTheDocument();
|
||||
await userEvent.click(getByTestId('radio-button-expression'));
|
||||
await waitFor(() => expect(emitted('update:modelValue')).toEqual([['addExpression']]));
|
||||
});
|
||||
|
||||
it('should emit update:modelValue when changing to fixed', async () => {
|
||||
const { emitted, getByTestId } = renderComponent(ParameterOptions, {
|
||||
props: {
|
||||
parameter: DEFAULT_PARAMETER,
|
||||
value: '=manual',
|
||||
isReadOnly: false,
|
||||
},
|
||||
});
|
||||
expect(getByTestId('radio-button-fixed')).toBeInTheDocument();
|
||||
await userEvent.click(getByTestId('radio-button-fixed'));
|
||||
await waitFor(() => expect(emitted('update:modelValue')).toEqual([['removeExpression']]));
|
||||
});
|
||||
});
|
|
@ -1,169 +1,134 @@
|
|||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow';
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { isResourceLocatorValue } from '@/utils/typeGuards';
|
||||
import { isValueExpression } from '@/utils/nodeTypesUtils';
|
||||
import { i18n } from '@/plugins/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ParameterOptions',
|
||||
props: {
|
||||
parameter: {
|
||||
type: Object as PropType<INodeProperties>,
|
||||
required: true,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
value: {
|
||||
type: [Object, String, Number, Boolean, Array] as PropType<NodeParameterValueType>,
|
||||
},
|
||||
showOptions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showExpressionSelector: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
customActions: {
|
||||
type: Array as PropType<Array<{ label: string; value: string; disabled?: boolean }>>,
|
||||
default: () => [],
|
||||
},
|
||||
iconOrientation: {
|
||||
type: String,
|
||||
default: 'vertical',
|
||||
validator: (value: string): boolean => ['horizontal', 'vertical'].includes(value),
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingMessage: {
|
||||
type: String,
|
||||
default() {
|
||||
return i18n.baseText('genericHelpers.loading');
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'menu-expanded'],
|
||||
computed: {
|
||||
isDefault(): boolean {
|
||||
return this.parameter.default === this.value;
|
||||
},
|
||||
isValueExpression(): boolean {
|
||||
return isValueExpression(this.parameter, this.value);
|
||||
},
|
||||
isHtmlEditor(): boolean {
|
||||
return this.getArgument('editor') === 'htmlEditor';
|
||||
},
|
||||
shouldShowExpressionSelector(): boolean {
|
||||
return this.parameter.noDataExpression !== true && this.showExpressionSelector;
|
||||
},
|
||||
shouldShowOptions(): boolean {
|
||||
if (this.isReadOnly) {
|
||||
return false;
|
||||
}
|
||||
interface Props {
|
||||
parameter: INodeProperties;
|
||||
isReadOnly: boolean;
|
||||
value: NodeParameterValueType;
|
||||
showOptions?: boolean;
|
||||
showExpressionSelector?: boolean;
|
||||
customActions?: Array<{ label: string; value: string; disabled?: boolean }>;
|
||||
iconOrientation?: 'horizontal' | 'vertical';
|
||||
loading?: boolean;
|
||||
loadingMessage?: string;
|
||||
}
|
||||
|
||||
if (this.parameter.type === 'collection' || this.parameter.type === 'credentialsSelect') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['codeNodeEditor', 'sqlEditor'].includes(this.parameter.typeOptions?.editor ?? '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.showOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
selectedView() {
|
||||
if (this.isValueExpression) {
|
||||
return 'expression';
|
||||
}
|
||||
|
||||
return 'fixed';
|
||||
},
|
||||
hasRemoteMethod(): boolean {
|
||||
return !!this.getArgument('loadOptionsMethod') || !!this.getArgument('loadOptions');
|
||||
},
|
||||
actions(): Array<{ label: string; value: string; disabled?: boolean }> {
|
||||
if (Array.isArray(this.customActions) && this.customActions.length > 0) {
|
||||
return this.customActions;
|
||||
}
|
||||
|
||||
if (this.isHtmlEditor && !this.isValueExpression) {
|
||||
return [
|
||||
{
|
||||
label: this.$locale.baseText('parameterInput.formatHtml'),
|
||||
value: 'formatHtml',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: this.$locale.baseText('parameterInput.resetValue'),
|
||||
value: 'resetValue',
|
||||
disabled: this.isDefault,
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
this.hasRemoteMethod ||
|
||||
(this.parameter.type === 'resourceLocator' &&
|
||||
isResourceLocatorValue(this.value) &&
|
||||
this.value.mode === 'list')
|
||||
) {
|
||||
return [
|
||||
{
|
||||
label: this.$locale.baseText('parameterInput.refreshList'),
|
||||
value: 'refreshOptions',
|
||||
},
|
||||
...actions,
|
||||
];
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onMenuToggle(visible: boolean) {
|
||||
this.$emit('menu-expanded', visible);
|
||||
},
|
||||
onViewSelected(selected: string) {
|
||||
if (selected === 'expression') {
|
||||
this.$emit(
|
||||
'update:modelValue',
|
||||
this.isValueExpression ? 'openExpression' : 'addExpression',
|
||||
);
|
||||
}
|
||||
|
||||
if (selected === 'fixed' && this.isValueExpression) {
|
||||
this.$emit('update:modelValue', 'removeExpression');
|
||||
}
|
||||
},
|
||||
getArgument(argumentName: string): string | number | boolean | undefined {
|
||||
if (this.parameter.typeOptions === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.parameter.typeOptions[argumentName] === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.parameter.typeOptions[argumentName];
|
||||
},
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showOptions: true,
|
||||
showExpressionSelector: true,
|
||||
customActions: () => [],
|
||||
iconOrientation: 'vertical',
|
||||
loading: false,
|
||||
loadingMessage: () => useI18n().baseText('genericHelpers.loading'),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
'menu-expanded': [visible: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const isDefault = computed(() => props.parameter.default === props.value);
|
||||
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
|
||||
const isHtmlEditor = computed(() => getArgument('editor') === 'htmlEditor');
|
||||
const shouldShowExpressionSelector = computed(
|
||||
() => !props.parameter.noDataExpression && props.showExpressionSelector,
|
||||
);
|
||||
const shouldShowOptions = computed(() => {
|
||||
if (props.isReadOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (props.parameter.type === 'collection' || props.parameter.type === 'credentialsSelect') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['codeNodeEditor', 'sqlEditor'].includes(props.parameter.typeOptions?.editor ?? '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (props.showOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
const selectedView = computed(() => (isValueAnExpression.value ? 'expression' : 'fixed'));
|
||||
const hasRemoteMethod = computed(
|
||||
() =>
|
||||
!!props.parameter.typeOptions?.loadOptionsMethod || !!props.parameter.typeOptions?.loadOptions,
|
||||
);
|
||||
const actions = computed(() => {
|
||||
if (Array.isArray(props.customActions) && props.customActions.length > 0) {
|
||||
return props.customActions;
|
||||
}
|
||||
|
||||
if (isHtmlEditor.value && !isValueAnExpression.value) {
|
||||
return [
|
||||
{
|
||||
label: i18n.baseText('parameterInput.formatHtml'),
|
||||
value: 'formatHtml',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const parameterActions = [
|
||||
{
|
||||
label: i18n.baseText('parameterInput.resetValue'),
|
||||
value: 'resetValue',
|
||||
disabled: isDefault.value,
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
hasRemoteMethod.value ||
|
||||
(props.parameter.type === 'resourceLocator' &&
|
||||
isResourceLocatorValue(props.value) &&
|
||||
props.value.mode === 'list')
|
||||
) {
|
||||
return [
|
||||
{
|
||||
label: i18n.baseText('parameterInput.refreshList'),
|
||||
value: 'refreshOptions',
|
||||
},
|
||||
...parameterActions,
|
||||
];
|
||||
}
|
||||
|
||||
return parameterActions;
|
||||
});
|
||||
|
||||
const onMenuToggle = (visible: boolean) => emit('menu-expanded', visible);
|
||||
const onViewSelected = (selected: string) => {
|
||||
if (selected === 'expression') {
|
||||
emit('update:modelValue', isValueAnExpression.value ? 'openExpression' : 'addExpression');
|
||||
}
|
||||
|
||||
if (selected === 'fixed' && isValueAnExpression.value) {
|
||||
emit('update:modelValue', 'removeExpression');
|
||||
}
|
||||
};
|
||||
const getArgument = (argumentName: string) => {
|
||||
if (props.parameter.typeOptions === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (props.parameter.typeOptions[argumentName] === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return props.parameter.typeOptions[argumentName];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div v-if="loading" :class="$style.loader">
|
||||
<div :class="$style.container" data-test-id="parameter-options-container">
|
||||
<div v-if="loading" :class="$style.loader" data-test-id="parameter-options-loader">
|
||||
<n8n-text v-if="loading" size="small">
|
||||
<n8n-icon icon="sync-alt" size="xsmall" :spin="true" />
|
||||
{{ loadingMessage }}
|
||||
|
@ -193,8 +158,8 @@ export default defineComponent({
|
|||
:model-value="selectedView"
|
||||
:disabled="isReadOnly"
|
||||
:options="[
|
||||
{ label: $locale.baseText('parameterInput.fixed'), value: 'fixed' },
|
||||
{ label: $locale.baseText('parameterInput.expression'), value: 'expression' },
|
||||
{ label: i18n.baseText('parameterInput.fixed'), value: 'fixed' },
|
||||
{ label: i18n.baseText('parameterInput.expression'), value: 'expression' },
|
||||
]"
|
||||
@update:model-value="onViewSelected"
|
||||
/>
|
||||
|
|
|
@ -304,6 +304,7 @@ defineExpose({
|
|||
:loading="props.refreshInProgress"
|
||||
:loading-message="fetchingFieldsLabel"
|
||||
:is-read-only="isReadOnly"
|
||||
:value="props.paramValue"
|
||||
@update:model-value="onParameterActionSelected"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -187,6 +187,7 @@ defineExpose({
|
|||
:loading="props.refreshInProgress"
|
||||
:loading-message="fetchingFieldsLabel"
|
||||
:is-read-only="isReadOnly"
|
||||
:value="state.selected"
|
||||
@update:model-value="onParameterActionSelected"
|
||||
/>
|
||||
</template>
|
||||
|
|
Loading…
Reference in a new issue