mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
fix(editor): Reconcile Data Table column schema on node open (#29935)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
getMainAuthField,
|
||||
getThemedValue,
|
||||
isResourceMapperFieldListStale,
|
||||
isResourceMapperSchemaIncomplete,
|
||||
parseResourceMapperFieldName,
|
||||
} from './nodeTypesUtils';
|
||||
import { mockNodeTypeDescription } from '@/__tests__/mocks';
|
||||
@@ -126,6 +127,45 @@ describe('getThemedValue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isResourceMapperSchemaIncomplete', () => {
|
||||
// A field as produced by a real loader, which always populates readOnly/removed.
|
||||
const loadedField: ResourceMapperField = {
|
||||
id: 'test',
|
||||
displayName: 'test',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
readOnly: false,
|
||||
removed: false,
|
||||
};
|
||||
|
||||
it('returns false for a fully loaded schema', () => {
|
||||
expect(isResourceMapperSchemaIncomplete([loadedField])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an empty schema', () => {
|
||||
expect(isResourceMapperSchemaIncomplete([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a field is missing readOnly', () => {
|
||||
const { readOnly: _omit, ...authoredField } = loadedField;
|
||||
expect(isResourceMapperSchemaIncomplete([authoredField])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when a field is missing removed', () => {
|
||||
const { removed: _omit, ...authoredField } = loadedField;
|
||||
expect(isResourceMapperSchemaIncomplete([authoredField])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when any field in the schema is incomplete', () => {
|
||||
const { readOnly: _omit, ...authoredField } = loadedField;
|
||||
expect(isResourceMapperSchemaIncomplete([loadedField, { ...authoredField, id: 'test2' }])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMainAuthField', () => {
|
||||
const booleanToggle: INodeProperties = {
|
||||
displayName: 'Use Schema Registry',
|
||||
|
||||
@@ -552,6 +552,17 @@ export const isResourceMapperFieldListStale = (
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects a resource mapper schema that was authored (e.g. by an AI builder, or
|
||||
* hand-edited) rather than loaded from its source. Loaders always populate
|
||||
* `readOnly` and `removed`; an authored schema omits them. Such schemas render
|
||||
* with broken/outdated inputs, so callers can use this to decide whether to
|
||||
* reconcile against the live source on open instead of just flagging it stale.
|
||||
*/
|
||||
export const isResourceMapperSchemaIncomplete = (fields: ResourceMapperField[]): boolean => {
|
||||
return fields.some((field) => field.readOnly === undefined || field.removed === undefined);
|
||||
};
|
||||
|
||||
export const isMatchingField = (
|
||||
field: string,
|
||||
matchingFields: string[],
|
||||
|
||||
+155
@@ -2,6 +2,7 @@ import {
|
||||
DEFAULT_SETUP,
|
||||
MAPPING_COLUMNS_RESPONSE,
|
||||
UPDATED_SCHEMA,
|
||||
getLatestValueChangeEvent,
|
||||
} from './ResourceMapper.test.utils';
|
||||
import type { MockedStore } from '@/__tests__/utils';
|
||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||
@@ -353,6 +354,160 @@ describe('ResourceMapper.vue', () => {
|
||||
expect(fetchFieldsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconciles an incomplete cached schema with the live source when refreshIncompleteSchemaOnOpen is set', async () => {
|
||||
// A cached schema that is structurally incomplete (missing the
|
||||
// loader-populated `readOnly`), as produced by an AI builder rather than
|
||||
// a real load. After reconciling, the rendered fields should match the
|
||||
// live response (MAPPING_COLUMNS_RESPONSE), not the cached schema below.
|
||||
const incompleteCachedSchema = [
|
||||
{
|
||||
id: 'cached_only_field',
|
||||
displayName: 'cached_only_field',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
canBeUsedToMatch: true,
|
||||
removed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = renderComponent(
|
||||
{
|
||||
props: {
|
||||
node: createTestNode({
|
||||
parameters: {
|
||||
columns: {
|
||||
schema: incompleteCachedSchema,
|
||||
},
|
||||
},
|
||||
}),
|
||||
parameter: createTestNodeProperties({
|
||||
name: 'columns',
|
||||
typeOptions: {
|
||||
resourceMapper: {
|
||||
mode: 'add',
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
refreshIncompleteSchemaOnOpen: true,
|
||||
} as ResourceMapperTypeOptions,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
await waitAllPromises();
|
||||
expect(fetchFieldsSpy).toHaveBeenCalledTimes(1);
|
||||
// Schema was reconciled with the live source — the cached-only field
|
||||
// is gone and a field from MAPPING_COLUMNS_RESPONSE is rendered.
|
||||
const mappingContainer = getByTestId('mapping-fields-container');
|
||||
expect(mappingContainer.textContent).not.toContain('cached_only_field');
|
||||
expect(mappingContainer.textContent).toContain('First name');
|
||||
});
|
||||
|
||||
it('does not reintroduce an incomplete field when reconciling a matching-id schema', async () => {
|
||||
// A cached field that shares its id with the live schema but is missing the
|
||||
// loader-populated readOnly/removed, as an AI-authored schema would be.
|
||||
// The reconcile must not copy the cached field's missing `removed` onto the
|
||||
// freshly loaded field, which would leave it incomplete and re-trigger the
|
||||
// refresh on every open.
|
||||
const incompleteMatchingSchema = [
|
||||
{
|
||||
id: 'First name',
|
||||
displayName: 'First name',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
canBeUsedToMatch: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Rendered without `{ merge: true }` to keep the cached schema deterministic
|
||||
// (see the test below for why).
|
||||
const { emitted } = renderComponent({
|
||||
props: {
|
||||
node: createTestNode({
|
||||
parameters: {
|
||||
columns: {
|
||||
schema: incompleteMatchingSchema,
|
||||
},
|
||||
},
|
||||
}),
|
||||
parameter: createTestNodeProperties({
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
typeOptions: {
|
||||
resourceMapper: {
|
||||
mode: 'add',
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
refreshIncompleteSchemaOnOpen: true,
|
||||
} as ResourceMapperTypeOptions,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
||||
const reconciledSchema = getLatestValueChangeEvent(emitted())[0].value.schema;
|
||||
const firstName = reconciledSchema.find((field) => field.id === 'First name');
|
||||
expect(firstName?.removed).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the stale warning for a complete-but-drifted schema when refreshIncompleteSchemaOnOpen is set', async () => {
|
||||
// A cached schema that is structurally complete (the loader-populated
|
||||
// `readOnly`/`removed` are present) but has drifted from the live source,
|
||||
// as happens when the user edits the table after configuring the node.
|
||||
// The user's drift must NOT be auto-clobbered — it keeps the cached
|
||||
// fields and surfaces the stale warning instead of reconciling.
|
||||
const completeDriftedSchema = [
|
||||
{
|
||||
id: 'user_added_field',
|
||||
displayName: 'user_added_field',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
canBeUsedToMatch: true,
|
||||
readOnly: false,
|
||||
removed: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Rendered without `{ merge: true }` on purpose: the shared DEFAULT_SETUP
|
||||
// is mutated by lodash merge across tests, and arrays merge by index, so a
|
||||
// prior test's longer schema would leak residual fields into this single
|
||||
// field one. A full-props render keeps the cached schema deterministic.
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
node: createTestNode({
|
||||
parameters: {
|
||||
columns: {
|
||||
schema: completeDriftedSchema,
|
||||
},
|
||||
},
|
||||
}),
|
||||
parameter: createTestNodeProperties({
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
typeOptions: {
|
||||
resourceMapper: {
|
||||
mode: 'add',
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
refreshIncompleteSchemaOnOpen: true,
|
||||
} as ResourceMapperTypeOptions,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await waitAllPromises();
|
||||
// The schema was checked against the live source but NOT replaced — the
|
||||
// cached field is still rendered and the live-only field is absent.
|
||||
const mappingContainer = getByTestId('mapping-fields-container');
|
||||
expect(mappingContainer.textContent).toContain('user_added_field');
|
||||
expect(mappingContainer.textContent).not.toContain('First name');
|
||||
});
|
||||
|
||||
it('renders initially selected matching column properly', async () => {
|
||||
const { getByTestId } = renderComponent(
|
||||
{
|
||||
|
||||
+18
-1
@@ -22,6 +22,7 @@ import MappingFields from './MappingFields.vue';
|
||||
import {
|
||||
fieldCannotBeDeleted,
|
||||
isResourceMapperFieldListStale,
|
||||
isResourceMapperSchemaIncomplete,
|
||||
parseResourceMapperFieldName,
|
||||
} from '@/app/utils/nodeTypesUtils';
|
||||
import { isFullExecutionResponse, isResourceMapperValue } from '@/app/utils/typeGuards';
|
||||
@@ -209,6 +210,16 @@ onMounted(async () => {
|
||||
if (!hasSchema) {
|
||||
// Only fetch a schema if it's not already set
|
||||
await initFetching();
|
||||
} else if (
|
||||
props.parameter.typeOptions?.resourceMapper?.refreshIncompleteSchemaOnOpen &&
|
||||
isResourceMapperSchemaIncomplete(state.paramValue.schema)
|
||||
) {
|
||||
// Opt-in: the cached schema is structurally incomplete (e.g. authored by
|
||||
// an AI builder rather than loaded from the source), so it would render
|
||||
// with broken/outdated inputs. Reconcile it against the live source
|
||||
// instead. A complete-but-drifted schema falls through to the stale-data
|
||||
// check below, leaving the refresh up to the user.
|
||||
await initFetching(true);
|
||||
} else {
|
||||
await checkStaleFields();
|
||||
}
|
||||
@@ -388,7 +399,13 @@ async function loadAndSetFieldsToMap(): Promise<void> {
|
||||
const newSchema = fetchedFields.fields.map((field) => {
|
||||
const existingField = state.paramValue.schema.find((f) => f.id === field.id);
|
||||
if (existingField) {
|
||||
field.removed = existingField.removed;
|
||||
// Keep the user's removed state, but don't let an incomplete cached
|
||||
// field (e.g. an AI-authored schema missing `removed`) overwrite the
|
||||
// loader-populated value with `undefined`.
|
||||
field.removed =
|
||||
typeof existingField.removed === 'boolean'
|
||||
? existingField.removed
|
||||
: (field.removed ?? false);
|
||||
} else if (state.paramValue.value !== null && !(field.id in state.paramValue.value)) {
|
||||
// New fields are shown by default
|
||||
field.removed = false;
|
||||
|
||||
@@ -33,6 +33,7 @@ export function makeAddRow(operation: string, displayOptions: IDisplayOptions) {
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
hideNoDataError: true,
|
||||
refreshIncompleteSchemaOnOpen: true,
|
||||
},
|
||||
},
|
||||
displayOptions,
|
||||
|
||||
@@ -1759,6 +1759,11 @@ export interface ResourceMapperTypeOptionsBase {
|
||||
};
|
||||
showTypeConversionOptions?: boolean;
|
||||
allowEmptyValues?: boolean;
|
||||
// When true, a cached schema that is detected to be structurally incomplete
|
||||
// (e.g. authored by an AI builder rather than loaded from the source) is
|
||||
// reconciled against the source on node open. A complete-but-drifted schema
|
||||
// still shows the stale-data warning, leaving the refresh up to the user.
|
||||
refreshIncompleteSchemaOnOpen?: boolean;
|
||||
}
|
||||
|
||||
// Enforce at least one of resourceMapperMethod or localResourceMapperMethod
|
||||
|
||||
Reference in New Issue
Block a user