refactor(editor): Migrate ParameterOptions to composition API (no-changelog) (#11501)

This commit is contained in:
Milorad FIlipović 2024-11-01 16:11:54 +01:00 committed by GitHub
parent 78e7d8dc8c
commit d0dce57c14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 251 additions and 161 deletions

View file

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

View 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']]));
});
});

View file

@ -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"
/>

View file

@ -304,6 +304,7 @@ defineExpose({
:loading="props.refreshInProgress"
:loading-message="fetchingFieldsLabel"
:is-read-only="isReadOnly"
:value="props.paramValue"
@update:model-value="onParameterActionSelected"
/>
</template>

View file

@ -187,6 +187,7 @@ defineExpose({
:loading="props.refreshInProgress"
:loading-message="fetchingFieldsLabel"
:is-read-only="isReadOnly"
:value="state.selected"
@update:model-value="onParameterActionSelected"
/>
</template>