fix(editor): Make expression edit modal read-only in executions view (#10806)

This commit is contained in:
Elias Meire 2024-09-13 15:27:55 +02:00 committed by GitHub
parent 2f8c8448d3
commit 394ef88843
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 157 additions and 54 deletions

View file

@ -0,0 +1,77 @@
import { createComponentRenderer } from '@/__tests__/render';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
import ExpressionEditModal from '@/components/ExpressionEditModal.vue';
import { createTestingPinia } from '@pinia/testing';
import { waitFor, within } from '@testing-library/vue';
vi.mock('vue-router', () => {
const push = vi.fn();
return {
useRouter: () => ({
push,
}),
useRoute: () => ({}),
RouterLink: vi.fn(),
};
});
const renderModal = createComponentRenderer(ExpressionEditModal);
describe('ExpressionEditModal', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
it('renders correctly', async () => {
const pinia = createTestingPinia();
const { getByTestId } = renderModal({
pinia,
props: {
parameter: { name: 'foo', type: 'string' },
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(() => {
expect(getByTestId('expression-modal-input')).toBeInTheDocument();
expect(getByTestId('expression-modal-output')).toBeInTheDocument();
const editor = within(getByTestId('expression-modal-input')).getByRole('textbox');
expect(editor).toBeInTheDocument();
expect(editor).toHaveAttribute('contenteditable', 'true');
expect(editor).not.toHaveAttribute('aria-readonly');
});
});
it('is read only', async () => {
const pinia = createTestingPinia();
const { getByTestId } = renderModal({
pinia,
props: {
parameter: { name: 'foo', type: 'string' },
path: '',
modelValue: 'test',
dialogVisible: true,
isReadOnly: true,
},
});
await waitFor(() => {
expect(getByTestId('expression-modal-input')).toBeInTheDocument();
expect(getByTestId('expression-modal-output')).toBeInTheDocument();
const editor = within(getByTestId('expression-modal-input')).getByRole('textbox');
expect(editor).toBeInTheDocument();
expect(editor).toHaveAttribute('aria-readonly', 'true');
});
});
});

View file

@ -151,9 +151,9 @@ async function onDrop(expression: string, event: MouseEvent) {
:class="$style.schema"
:search="appliedSearch"
:nodes="parentNodes"
mapping-enabled
pane-type="input"
:mapping-enabled="!isReadOnly"
:connection-type="NodeConnectionType.Main"
pane-type="input"
/>
</div>

View file

@ -472,6 +472,7 @@ function getParameterValue<T extends NodeParameterValueType = NodeParameterValue
:node="node"
:path="getPath(parameter.name)"
:dependent-parameters-values="getDependentParametersValues(parameter)"
:is-read-only="isReadOnly"
input-size="small"
label-size="small"
@value-changed="valueChanged"

View file

@ -21,6 +21,7 @@ import {
parseResourceMapperFieldName,
} from '@/utils/nodeTypesUtils';
import { useNodeSpecificationValues } from '@/composables/useNodeSpecificationValues';
import { N8nIconButton, N8nInputLabel, N8nOption, N8nSelect, N8nTooltip } from 'n8n-design-system';
interface Props {
parameter: INodeProperties;
@ -28,16 +29,18 @@ interface Props {
nodeValues: INodeParameters | undefined;
fieldsToMap: ResourceMapperField[];
paramValue: ResourceMapperValue;
labelSize: string;
labelSize: 'small' | 'medium';
showMatchingColumnsSelector: boolean;
showMappingModeSelect: boolean;
loading: boolean;
refreshInProgress: boolean;
teleported?: boolean;
isReadOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
teleported: true,
isReadOnly: false,
});
const FORCE_TEXT_INPUT_FOR_TYPES: FieldType[] = ['time', 'object', 'array'];
@ -285,7 +288,7 @@ defineExpose({
<template>
<div class="mt-xs" data-test-id="mapping-fields-container">
<n8n-input-label
<N8nInputLabel
:label="valuesLabel"
:underline="true"
:size="labelSize"
@ -300,14 +303,15 @@ defineExpose({
:custom-actions="parameterActions"
:loading="props.refreshInProgress"
:loading-message="fetchingFieldsLabel"
:is-read-only="isReadOnly"
@update:model-value="onParameterActionSelected"
/>
</template>
</n8n-input-label>
</N8nInputLabel>
<div v-if="orderedFields.length === 0" class="mt-3xs mb-xs">
<n8n-text size="small">{{
<N8nText size="small">{{
$locale.baseText('fixedCollectionParameter.currentlyNoItemsExist')
}}</n8n-text>
}}</N8nText>
</div>
<div
v-for="field in orderedFields"
@ -322,7 +326,7 @@ defineExpose({
v-if="resourceMapperMode === 'add' && field.required"
:class="['delete-option', 'mt-5xs', $style.parameterTooltipIcon]"
>
<n8n-tooltip placement="top">
<N8nTooltip placement="top">
<template #content>
<span>{{
locale.baseText('resourceMapper.mandatoryField.title', {
@ -331,7 +335,7 @@ defineExpose({
}}</span>
</template>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</N8nTooltip>
</div>
<div
v-else-if="
@ -343,7 +347,7 @@ defineExpose({
"
:class="['delete-option', 'mt-5xs']"
>
<n8n-icon-button
<N8nIconButton
type="tertiary"
text
size="mini"
@ -356,8 +360,9 @@ defineExpose({
},
})
"
:disabled="isReadOnly"
@click="removeField(field.name)"
></n8n-icon-button>
></N8nIconButton>
</div>
<div :class="$style.parameterInput">
<ParameterInputFull
@ -365,7 +370,7 @@ defineExpose({
:value="getParameterValue(field.name)"
:display-options="true"
:path="`${props.path}.${field.name}`"
:is-read-only="refreshInProgress || field.readOnly"
:is-read-only="refreshInProgress || field.readOnly || isReadOnly"
:hide-issues="true"
:node-values="nodeValues"
:class="$style.parameterInputFull"
@ -379,7 +384,7 @@ defineExpose({
/>
</div>
<div :class="['add-option', $style.addOption]" data-test-id="add-fields-select">
<n8n-select
<N8nSelect
:placeholder="
locale.baseText('resourceMapper.addFieldToSend', {
interpolate: { fieldWord: singularFieldWordCapitalized },
@ -387,18 +392,18 @@ defineExpose({
"
size="small"
:teleported="teleported"
:disabled="addFieldOptions.length == 0"
:disabled="addFieldOptions.length == 0 || isReadOnly"
@update:model-value="addField"
>
<n8n-option
<N8nOption
v-for="item in addFieldOptions"
:key="item.value"
:label="item.name"
:value="item.value"
:disabled="item.disabled"
>
</n8n-option>
</n8n-select>
</N8nOption>
</N8nSelect>
</div>
</div>
</template>

View file

@ -3,20 +3,22 @@ import type { INodePropertyTypeOptions, ResourceMapperFields } from 'n8n-workflo
import { computed, ref, watch } from 'vue';
import { i18n as locale } from '@/plugins/i18n';
import { useNodeSpecificationValues } from '@/composables/useNodeSpecificationValues';
import { N8nInputLabel, N8nSelect, N8nText } from 'n8n-design-system';
interface Props {
initialValue: string;
fieldsToMap: ResourceMapperFields['fields'];
inputSize: string;
labelSize: string;
inputSize: 'small' | 'medium';
labelSize: 'small' | 'medium';
typeOptions: INodePropertyTypeOptions | undefined;
serviceName: string;
loading: boolean;
loadingError: boolean;
teleported?: boolean;
isReadOnly?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), { isReadOnly: false });
const { resourceMapperTypeOptions, pluralFieldWord, singularFieldWord } =
useNodeSpecificationValues(props.typeOptions);
@ -103,7 +105,7 @@ defineExpose({
<template>
<div data-test-id="mapping-mode-select">
<n8n-input-label
<N8nInputLabel
:label="locale.baseText('resourceMapper.mappingMode.label')"
:bold="false"
:required="false"
@ -111,13 +113,14 @@ defineExpose({
color="text-dark"
>
<div class="mt-5xs">
<n8n-select
<N8nSelect
:model-value="selected"
:teleported="teleported"
:size="props.inputSize"
:disabled="isReadOnly"
@update:model-value="onModeChanged"
>
<n8n-option
<N8nOption
v-for="option in mappingModeOptions"
:key="option.value"
:value="option.value"
@ -130,12 +133,12 @@ defineExpose({
</div>
<div class="option-description" v-html="option.description" />
</div>
</n8n-option>
</n8n-select>
</N8nOption>
</N8nSelect>
</div>
<div class="mt-5xs">
<n8n-text v-if="loading" size="small">
<n8n-icon icon="sync-alt" size="xsmall" :spin="true" />
<N8nText v-if="loading" size="small">
<N8nIcon icon="sync-alt" size="xsmall" :spin="true" />
{{
locale.baseText('resourceMapper.fetchingFields.message', {
interpolate: {
@ -143,15 +146,15 @@ defineExpose({
},
})
}}
</n8n-text>
<n8n-text v-else-if="errorMessage !== ''" size="small" color="danger">
<n8n-icon icon="exclamation-triangle" size="xsmall" />
</N8nText>
<N8nText v-else-if="errorMessage !== ''" size="small" color="danger">
<N8nIcon icon="exclamation-triangle" size="xsmall" />
{{ errorMessage }}
<n8n-link size="small" theme="danger" :underline="true" @click="onRetryClick">
<N8nLink size="small" theme="danger" :underline="true" @click="onRetryClick">
{{ locale.baseText('generic.retry') }}
</n8n-link>
</n8n-text>
</N8nLink>
</N8nText>
</div>
</n8n-input-label>
</N8nInputLabel>
</div>
</template>

View file

@ -9,22 +9,25 @@ import { computed, reactive, watch } from 'vue';
import { i18n as locale } from '@/plugins/i18n';
import { useNodeSpecificationValues } from '@/composables/useNodeSpecificationValues';
import ParameterOptions from '@/components/ParameterOptions.vue';
import { N8nInputLabel, N8nNotice, N8nSelect } from 'n8n-design-system';
interface Props {
parameter: INodeProperties;
initialValue: string[];
fieldsToMap: ResourceMapperFields['fields'];
typeOptions: INodePropertyTypeOptions | undefined;
labelSize: string;
inputSize: string;
labelSize: 'small' | 'medium';
inputSize: 'small' | 'medium';
loading: boolean;
serviceName: string;
teleported?: boolean;
refreshInProgress: boolean;
teleported?: boolean;
isReadOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
teleported: true,
isReadOnly: false,
});
const {
resourceMapperTypeOptions,
@ -168,7 +171,7 @@ defineExpose({
<template>
<div class="mt-2xs" data-test-id="matching-column-select">
<n8n-input-label
<N8nInputLabel
v-if="availableMatchingFields.length > 0"
:label="fieldLabel"
:tooltip-text="fieldTooltip"
@ -183,14 +186,15 @@ defineExpose({
:custom-actions="parameterActions"
:loading="props.refreshInProgress"
:loading-message="fetchingFieldsLabel"
:is-read-only="isReadOnly"
@update:model-value="onParameterActionSelected"
/>
</template>
<n8n-select
<N8nSelect
:multiple="resourceMapperTypeOptions?.multiKeyMatch === true"
:model-value="state.selected"
:size="props.inputSize"
:disabled="loading"
:disabled="loading || isReadOnly"
:teleported="teleported"
@update:model-value="onSelectionChange"
>
@ -202,17 +206,17 @@ defineExpose({
>
{{ field.displayName }}
</n8n-option>
</n8n-select>
<n8n-text size="small">
</N8nSelect>
<N8nText size="small">
{{ fieldDescription }}
</n8n-text>
</n8n-input-label>
<n8n-notice v-else>
</N8nText>
</N8nInputLabel>
<N8nNotice v-else>
{{
locale.baseText('resourceMapper.columnsToMatchOn.noFieldsFound', {
interpolate: { fieldWord: singularFieldWord, serviceName: props.serviceName },
})
}}
</n8n-notice>
</N8nNotice>
</div>
</template>

View file

@ -25,10 +25,11 @@ type Props = {
parameter: INodeProperties;
node: INode | null;
path: string;
inputSize: string;
labelSize: string;
dependentParametersValues?: string | null;
inputSize: 'small' | 'medium';
labelSize: 'small' | 'medium';
teleported: boolean;
dependentParametersValues?: string | null;
isReadOnly?: boolean;
};
const nodeTypesStore = useNodeTypesStore();
@ -38,6 +39,7 @@ const workflowsStore = useWorkflowsStore();
const props = withDefaults(defineProps<Props>(), {
teleported: true,
dependentParametersValues: null,
isReadOnly: false,
});
const emit = defineEmits<{
@ -485,6 +487,7 @@ defineExpose({
:loading-error="state.loadingError"
:fields-to-map="state.paramValue.schema"
:teleported="teleported"
:is-read-only="isReadOnly"
@mode-changed="onModeChanged"
@retry-fetch="initFetching"
/>
@ -500,11 +503,12 @@ defineExpose({
:service-name="nodeType?.displayName || locale.baseText('generic.service')"
:teleported="teleported"
:refresh-in-progress="state.refreshInProgress"
:is-read-only="isReadOnly"
@matching-columns-changed="onMatchingColumnsChanged"
@refresh-field-list="initFetching(true)"
/>
<n8n-text v-if="!showMappingModeSelect && state.loading" size="small">
<n8n-icon icon="sync-alt" size="xsmall" :spin="true" />
<N8nText v-if="!showMappingModeSelect && state.loading" size="small">
<N8nIcon icon="sync-alt" size="xsmall" :spin="true" />
{{
locale.baseText('resourceMapper.fetchingFields.message', {
interpolate: {
@ -512,7 +516,7 @@ defineExpose({
},
})
}}
</n8n-text>
</N8nText>
<MappingFields
v-if="showMappingFields"
:parameter="props.parameter"
@ -526,12 +530,13 @@ defineExpose({
:loading="state.loading"
:teleported="teleported"
:refresh-in-progress="state.refreshInProgress"
:is-read-only="isReadOnly"
@field-value-changed="fieldValueChanged"
@remove-field="removeField"
@add-field="addField"
@refresh-field-list="initFetching(true)"
/>
<n8n-notice
<N8nNotice
v-if="state.paramValue.mappingMode === 'autoMapInputData' && hasAvailableMatchingColumns"
>
{{
@ -542,6 +547,6 @@ defineExpose({
},
})
}}
</n8n-notice>
</N8nNotice>
</div>
</template>

View file

@ -45,6 +45,14 @@ describe('ResourceMapper.vue', () => {
).toBe(MAPPING_COLUMNS_RESPONSE.fields.length);
});
it('renders correctly in read only mode', async () => {
const { getByTestId } = renderComponent({ props: { isReadOnly: true } });
await waitAllPromises();
expect(getByTestId('mapping-mode-select').querySelector('input')).toBeDisabled();
expect(getByTestId('matching-column-select').querySelector('input')).toBeDisabled();
expect(getByTestId('mapping-fields-container').querySelector('input')).toBeDisabled();
});
it('renders add mode properly', async () => {
const { getByTestId, queryByTestId } = renderComponent(
{