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:
Ricardo Espinoza
2026-07-02 08:26:59 -04:00
committed by GitHub
parent 5ad2fa86c3
commit 269895fc0f
6 changed files with 230 additions and 1 deletions
@@ -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[],
@@ -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(
{
@@ -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,
+5
View File
@@ -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