Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): Add stale data warning to Resource Mapper #12305

Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ import {
parseResourceMapperFieldName,
} from '@/utils/nodeTypesUtils';
import { useNodeSpecificationValues } from '@/composables/useNodeSpecificationValues';
import { N8nIconButton, N8nInputLabel, N8nOption, N8nSelect, N8nTooltip } from 'n8n-design-system';
import {
N8nIcon,
N8nIconButton,
N8nInputLabel,
N8nOption,
N8nSelect,
N8nTooltip,
} from 'n8n-design-system';
import { useI18n } from '@/composables/useI18n';

interface Props {
Expand All @@ -37,11 +44,13 @@ interface Props {
refreshInProgress: boolean;
teleported?: boolean;
isReadOnly?: boolean;
isDataStale?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
teleported: true,
isReadOnly: false,
isDataStale: false,
});
const FORCE_TEXT_INPUT_FOR_TYPES: FieldType[] = ['time', 'object', 'array'];

Expand Down Expand Up @@ -310,6 +319,27 @@ defineExpose({
:value="props.paramValue"
@update:model-value="onParameterActionSelected"
/>
<div v-if="props.isDataStale && !props.refreshInProgress" :class="$style.staleDataWarning">
<N8nTooltip>
<template #content>
<span>{{
locale.baseText('resourceMapper.staleDataWarning.tooltip', {
interpolate: { fieldWord: pluralFieldWordCapitalized },
})
}}</span>
</template>
<N8nIcon icon="exclamation-triangle" size="small" color="warning" />
</N8nTooltip>
<N8nIconButton
icon="refresh"
type="tertiary"
size="small"
:text="true"
:title="locale.baseText('generic.refresh')"
:disabled="props.refreshInProgress"
@click="onParameterActionSelected('refreshFieldList')"
/>
</div>
</template>
</N8nInputLabel>
<div v-if="orderedFields.length === 0" class="mt-3xs mb-xs">
Expand Down Expand Up @@ -442,4 +472,11 @@ defineExpose({
margin-top: var(--spacing-l);
padding: 0 0 0 var(--spacing-s);
}

.staleDataWarning {
display: flex;
height: var(--spacing-m);
align-items: baseline;
gap: var(--spacing-5xs);
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,25 @@ import type {
INodeTypeDescription,
NodeParameterValueType,
ResourceMapperField,
ResourceMapperFields,
ResourceMapperValue,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import { computed, onMounted, reactive, watch } from 'vue';
import MappingModeSelect from './MappingModeSelect.vue';
import MatchingColumnsSelect from './MatchingColumnsSelect.vue';
import MappingFields from './MappingFields.vue';
import { fieldCannotBeDeleted, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils';
import {
fieldCannotBeDeleted,
isResourceMapperFieldListStale,
parseResourceMapperFieldName,
} from '@/utils/nodeTypesUtils';
import { isFullExecutionResponse, isResourceMapperValue } from '@/utils/typeGuards';
import { i18n as locale } from '@/plugins/i18n';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useDocumentVisibility } from '@/composables/useDocumentVisibility';
import { N8nButton, N8nCallout } from 'n8n-design-system';

type Props = {
parameter: INodeProperties;
Expand All @@ -43,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
isReadOnly: false,
});

const { onDocumentVisible } = useDocumentVisibility();

const emit = defineEmits<{
valueChanged: [value: IUpdateInformation];
}>();
Expand All @@ -64,6 +73,7 @@ const state = reactive({
loading: false,
refreshInProgress: false, // Shows inline loader when refreshing fields
loadingError: false,
hasStaleFields: false,
});

// Reload fields to map when dependent parameters change
Expand All @@ -83,6 +93,21 @@ watch(
},
);

onDocumentVisible(async () => {
await checkStaleFields();
});

async function checkStaleFields(): Promise<void> {
const fetchedFields = await fetchFields();
if (fetchedFields) {
const isSchemaStale = isResourceMapperFieldListStale(
state.paramValue.schema,
fetchedFields.fields,
);
state.hasStaleFields = isSchemaStale;
}
}

// Reload fields to map when node is executed
watch(
() => workflowsStore.getWorkflowExecution,
Expand Down Expand Up @@ -149,6 +174,8 @@ onMounted(async () => {
if (!hasSchema) {
// Only fetch a schema if it's not already set
await initFetching();
} else {
await checkStaleFields();
}
// Set default values if this is the first time the parameter is being set
if (!state.paramValue.value) {
Expand Down Expand Up @@ -246,10 +273,11 @@ async function initFetching(inlineLoading = false): Promise<void> {
state.loading = true;
}
try {
await loadFieldsToMap();
await loadAndSetFieldsToMap();
if (!state.paramValue.matchingColumns || state.paramValue.matchingColumns.length === 0) {
onMatchingColumnsChanged(defaultSelectedMatchingColumns.value);
}
state.hasStaleFields = false;
} catch (error) {
state.loadingError = true;
} finally {
Expand Down Expand Up @@ -279,11 +307,7 @@ const createRequestParams = (methodName: string) => {
return requestParams;
};

async function loadFieldsToMap(): Promise<void> {
if (!props.node) {
return;
}

async function fetchFields(): Promise<ResourceMapperFields | null> {
const { resourceMapperMethod, localResourceMapperMethod } =
props.parameter.typeOptions?.resourceMapper ?? {};

Expand All @@ -301,6 +325,15 @@ async function loadFieldsToMap(): Promise<void> {

fetchedFields = await nodeTypesStore.getLocalResourceMapperFields(requestParams);
}
return fetchedFields;
}

async function loadAndSetFieldsToMap(): Promise<void> {
if (!props.node) {
return;
}

const fetchedFields = await fetchFields();

if (fetchedFields !== null) {
const newSchema = fetchedFields.fields.map((field) => {
Expand Down Expand Up @@ -570,11 +603,26 @@ defineExpose({
:teleported="teleported"
:refresh-in-progress="state.refreshInProgress"
:is-read-only="isReadOnly"
:is-data-stale="state.hasStaleFields"
@field-value-changed="fieldValueChanged"
@remove-field="removeField"
@add-field="addField"
@refresh-field-list="initFetching(true)"
/>
<N8nCallout v-else-if="state.hasStaleFields" theme="info" :iconless="true">
{{ locale.baseText('resourceMapper.staleDataWarning.notice') }}
<template #trailingContent>
<N8nButton
size="mini"
icon="refresh"
type="secondary"
:loading="state.refreshInProgress"
@click="initFetching(true)"
>
{{ locale.baseText('generic.refresh') }}
</N8nButton>
</template>
</N8nCallout>
<N8nNotice
v-if="state.paramValue.mappingMode === 'autoMapInputData' && hasAvailableMatchingColumns"
>
Expand Down
52 changes: 52 additions & 0 deletions packages/editor-ui/src/composables/useDocumentVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Ref } from 'vue';
import { ref, onMounted, onUnmounted } from 'vue';

type VisibilityHandler = () => void;

type DocumentVisibilityResult = {
isVisible: Ref<boolean>;
onDocumentVisible: (handler: VisibilityHandler) => void;
onDocumentHidden: (handler: VisibilityHandler) => void;
};

export function useDocumentVisibility(): DocumentVisibilityResult {
const isVisible = ref<boolean>(!document.hidden);
const visibleHandlers = ref<VisibilityHandler[]>([]);
const hiddenHandlers = ref<VisibilityHandler[]>([]);

const onVisibilityChange = (): void => {
const newVisibilityState = !document.hidden;
isVisible.value = newVisibilityState;

if (newVisibilityState) {
visibleHandlers.value.forEach((handler) => handler());
} else {
hiddenHandlers.value.forEach((handler) => handler());
}
};

const onDocumentVisible = (handler: VisibilityHandler): void => {
visibleHandlers.value.push(handler);
};

const onDocumentHidden = (handler: VisibilityHandler): void => {
hiddenHandlers.value.push(handler);
};

onMounted((): void => {
document.addEventListener('visibilitychange', onVisibilityChange);
});

onUnmounted((): void => {
document.removeEventListener('visibilitychange', onVisibilityChange);
// Clear handlers on unmount
visibleHandlers.value = [];
hiddenHandlers.value = [];
});

return {
isVisible,
onDocumentVisible,
onDocumentHidden,
};
}
3 changes: 3 additions & 0 deletions packages/editor-ui/src/plugins/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"generic.yes": "Yes",
"generic.no": "No",
"generic.rating": "Rating",
"generic.refresh": "Refresh",
"generic.retry": "Retry",
"generic.error": "Something went wrong",
"generic.settings": "Settings",
Expand Down Expand Up @@ -1588,6 +1589,8 @@
"resourceMapper.addAllFields": "Add All {fieldWord}",
"resourceMapper.removeAllFields": "Remove All {fieldWord}",
"resourceMapper.refreshFieldList": "Refresh {fieldWord} List",
"resourceMapper.staleDataWarning.tooltip": "{fieldWord} are outdated. Refresh to see the changes.",
"resourceMapper.staleDataWarning.notice": "Refresh to see the updated fields",
"resourceMapper.attemptToConvertTypes.displayName": "Attempt to convert types",
"resourceMapper.attemptToConvertTypes.description": "Attempt to convert types when mapping fields",
"resourceMapper.ignoreTypeMismatchErrors.displayName": "Ignore type mismatch errors",
Expand Down
75 changes: 75 additions & 0 deletions packages/editor-ui/src/utils/nodeTypeUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { ResourceMapperField } from 'n8n-workflow';
import { isResourceMapperFieldListStale } from './nodeTypesUtils';

describe('isResourceMapperFieldListStale', () => {
const baseField: ResourceMapperField = {
id: 'test',
displayName: 'test',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
};

// Test property changes
test.each([
[
'displayName',
{ ...baseField },
{ ...baseField, displayName: 'changed' } as ResourceMapperField,
],
['required', { ...baseField }, { ...baseField, required: true } as ResourceMapperField],
['defaultMatch', { ...baseField }, { ...baseField, defaultMatch: true } as ResourceMapperField],
['display', { ...baseField }, { ...baseField, display: false }],
[
'canBeUsedToMatch',
{ ...baseField },
{ ...baseField, canBeUsedToMatch: false } as ResourceMapperField,
],
['type', { ...baseField }, { ...baseField, type: 'number' } as ResourceMapperField],
])('returns true when %s changes', (_property, oldField, newField) => {
expect(isResourceMapperFieldListStale([oldField], [newField])).toBe(true);
});

// Test different array lengths
test.each([
['empty vs non-empty', [], [baseField]],
['non-empty vs empty', [baseField], []],
['one vs two fields', [baseField], [baseField, { ...baseField, id: 'test2' }]],
])('returns true for different lengths: %s', (_scenario, oldFields, newFields) => {
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

// Test identical cases
test.each([
['empty arrays', [], []],
['single field', [baseField], [{ ...baseField }]],
[
'multiple fields',
[
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
],
[
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
],
],
])('returns false for identical lists: %s', (_scenario, oldFields, newFields) => {
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(false);
});

// This test case is complex enough to keep separate
test('returns true when field is removed/replaced', () => {
const oldFields = [
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
];
const newFields = [
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test3' }, // different id
];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});
});
Loading
Loading