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

24 changes: 24 additions & 0 deletions packages/editor-ui/src/components/ResourceMapper/MappingFields.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,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 +312,24 @@ defineExpose({
:value="props.paramValue"
@update:model-value="onParameterActionSelected"
/>
<N8nTooltip v-if="props.isDataStale && !props.refreshInProgress">
<template #content>
<span>{{
locale.baseText('resourceMapper.staleDataWarning.tooltip', {
interpolate: { fieldWord: pluralFieldWordCapitalized },
})
}}</span>
</template>
<N8nIconButton
icon="refresh"
type="tertiary"
size="small"
:text="true"
:class="$style.staleDataRefreshButton"
:disabled="props.refreshInProgress"
@click="onParameterActionSelected('refreshFieldList')"
/>
</N8nTooltip>
</template>
</N8nInputLabel>
<div v-if="orderedFields.length === 0" class="mt-3xs mb-xs">
Expand Down Expand Up @@ -442,4 +462,8 @@ defineExpose({
margin-top: var(--spacing-l);
padding: 0 0 0 var(--spacing-s);
}

.staleDataRefreshButton {
padding-bottom: var(--spacing-2xs);
}
</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;

interface DocumentVisibilityResult {
MiloradFilipovic marked this conversation as resolved.
Show resolved Hide resolved
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
99 changes: 98 additions & 1 deletion packages/editor-ui/src/utils/nodeSettingsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, it, expect, afterAll } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { IConnections, NodeParameterValueType } from 'n8n-workflow';
import type { IConnections, NodeParameterValueType, ResourceMapperField } from 'n8n-workflow';
import { updateDynamicConnections } from './nodeSettingsUtils';
import { SWITCH_NODE_TYPE } from '@/constants';
import type { INodeUi, IUpdateInformation } from '@/Interface';
import { isResourceMapperFieldListStale } from './nodeTypesUtils';

describe('updateDynamicConnections', () => {
afterAll(() => {
Expand Down Expand Up @@ -160,3 +161,99 @@ describe('updateDynamicConnections', () => {
expect(result).toBeNull();
});
});

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

test('returns false for identical lists', () => {
MiloradFilipovic marked this conversation as resolved.
Show resolved Hide resolved
const oldFields = [{ ...baseField }];
const newFields = [{ ...baseField }];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(false);
});

test('returns true for different lengths', () => {
const oldFields = [{ ...baseField }];
const newFields = [{ ...baseField }, { ...baseField, id: 'test2' }];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

test('returns true when field is removed', () => {
const oldFields = [
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
];
const newFields = [
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test3' },
];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

test('returns true when displayName changes', () => {
const oldFields = [{ ...baseField }];
const newFields = [{ ...baseField, displayName: 'changed' }];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

test('returns true when required changes', () => {
const oldFields = [{ ...baseField }];
const newFields = [{ ...baseField, required: true }];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

test('returns true when defaultMatch changes', () => {
const oldFields = [{ ...baseField }];
const newFields = [{ ...baseField, defaultMatch: true }];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

test('returns true when display changes', () => {
const oldFields = [{ ...baseField }];
const newFields = [{ ...baseField, display: false }];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

test('returns true when canBeUsedToMatch changes', () => {
const oldFields = [{ ...baseField }];
const newFields = [{ ...baseField, canBeUsedToMatch: false }];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

test('returns true when type changes', () => {
const oldFields = [{ ...baseField }];
const newFields: ResourceMapperField[] = [{ ...baseField, type: 'number' }];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});

test('returns false for multiple identical fields', () => {
const oldFields = [
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
{ ...baseField, id: 'test3' },
];
const newFields = [
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
{ ...baseField, id: 'test3' },
];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(false);
});

test('handles empty arrays correctly', () => {
expect(isResourceMapperFieldListStale([], [])).toBe(false);
});

test('returns true when comparing empty array with non-empty array', () => {
const nonEmptyFields = [{ ...baseField }];
expect(isResourceMapperFieldListStale([], nonEmptyFields)).toBe(true);
expect(isResourceMapperFieldListStale(nonEmptyFields, [])).toBe(true);
});
});
Loading
Loading