mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
fix(editor): Make expression edit modal read-only in executions view (#10806)
This commit is contained in:
parent
2f8c8448d3
commit
394ef88843
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
Loading…
Reference in a new issue