mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Update filter and feedback for source control (#12504)
This commit is contained in:
parent
3ec5b2850c
commit
865fc21276
|
@ -32,6 +32,7 @@ export async function getAllCredentials(
|
||||||
): Promise<ICredentialsResponse[]> {
|
): Promise<ICredentialsResponse[]> {
|
||||||
return await makeRestApiRequest(context, 'GET', '/credentials', {
|
return await makeRestApiRequest(context, 'GET', '/credentials', {
|
||||||
...(includeScopes ? { includeScopes } : {}),
|
...(includeScopes ? { includeScopes } : {}),
|
||||||
|
includeData: true,
|
||||||
...(filter ? { filter } : {}),
|
...(filter ? { filter } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ export const pushWorkfolder = async (
|
||||||
export const pullWorkfolder = async (
|
export const pullWorkfolder = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
data: PullWorkFolderRequestDto,
|
data: PullWorkFolderRequestDto,
|
||||||
): Promise<void> => {
|
): Promise<SourceControlledFile[]> => {
|
||||||
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
|
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,7 @@ const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
data: ICredentialsResponse;
|
data: ICredentialsResponse;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
needsSetup?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
data: () => ({
|
data: () => ({
|
||||||
|
@ -146,6 +147,9 @@ function moveResource() {
|
||||||
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
|
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
|
||||||
{{ locale.baseText('credentials.item.readonly') }}
|
{{ locale.baseText('credentials.item.readonly') }}
|
||||||
</N8nBadge>
|
</N8nBadge>
|
||||||
|
<N8nBadge v-if="needsSetup" class="ml-3xs" theme="warning">
|
||||||
|
{{ locale.baseText('credentials.item.needsSetup') }}
|
||||||
|
</N8nBadge>
|
||||||
</n8n-heading>
|
</n8n-heading>
|
||||||
</template>
|
</template>
|
||||||
<div :class="$style.cardDescription">
|
<div :class="$style.cardDescription">
|
||||||
|
@ -195,10 +199,6 @@ function moveResource() {
|
||||||
.cardHeading {
|
.cardHeading {
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
padding: var(--spacing-s) 0 0;
|
padding: var(--spacing-s) 0 0;
|
||||||
|
|
||||||
span {
|
|
||||||
color: var(--color-text-light);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardDescription {
|
.cardDescription {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/vue';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { merge } from 'lodash-es';
|
import { merge } from 'lodash-es';
|
||||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, STORES } from '@/constants';
|
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, STORES } from '@/constants';
|
||||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||||
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
|
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
|
@ -18,8 +18,9 @@ let rbacStore: ReturnType<typeof useRBACStore>;
|
||||||
|
|
||||||
const showMessage = vi.fn();
|
const showMessage = vi.fn();
|
||||||
const showError = vi.fn();
|
const showError = vi.fn();
|
||||||
|
const showToast = vi.fn();
|
||||||
vi.mock('@/composables/useToast', () => ({
|
vi.mock('@/composables/useToast', () => ({
|
||||||
useToast: () => ({ showMessage, showError }),
|
useToast: () => ({ showMessage, showError, showToast }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(MainSidebarSourceControl);
|
const renderComponent = createComponentRenderer(MainSidebarSourceControl);
|
||||||
|
@ -131,5 +132,129 @@ describe('MainSidebarSourceControl', () => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should open push modal when there are changes', async () => {
|
||||||
|
const status = [
|
||||||
|
{
|
||||||
|
id: '014da93897f146d2b880-baa374b9d02d',
|
||||||
|
name: 'vuelfow2',
|
||||||
|
type: 'workflow' as const,
|
||||||
|
status: 'created' as const,
|
||||||
|
location: 'local' as const,
|
||||||
|
conflict: false,
|
||||||
|
file: '/014da93897f146d2b880-baa374b9d02d.json',
|
||||||
|
updatedAt: '2025-01-09T13:12:24.580Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce(status);
|
||||||
|
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||||
|
|
||||||
|
const { getAllByRole } = renderComponent({
|
||||||
|
pinia,
|
||||||
|
props: { isCollapsed: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(getAllByRole('button')[1]);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(openModalSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show user's feedback when pulling", async () => {
|
||||||
|
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: '014da93897f146d2b880-baa374b9d02d',
|
||||||
|
name: 'vuelfow2',
|
||||||
|
type: 'workflow',
|
||||||
|
status: 'created',
|
||||||
|
location: 'remote',
|
||||||
|
conflict: false,
|
||||||
|
file: '/014da93897f146d2b880-baa374b9d02d.json',
|
||||||
|
updatedAt: '2025-01-09T13:12:24.580Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a102c0b9-28ac-43cb-950e-195723a56d54',
|
||||||
|
name: 'Gmail account',
|
||||||
|
type: 'credential',
|
||||||
|
status: 'created',
|
||||||
|
location: 'remote',
|
||||||
|
conflict: false,
|
||||||
|
file: '/a102c0b9-28ac-43cb-950e-195723a56d54.json',
|
||||||
|
updatedAt: '2025-01-09T13:12:24.586Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'variables',
|
||||||
|
name: 'variables',
|
||||||
|
type: 'variables',
|
||||||
|
status: 'modified',
|
||||||
|
location: 'remote',
|
||||||
|
conflict: false,
|
||||||
|
file: '/variable_stubs.json',
|
||||||
|
updatedAt: '2025-01-09T13:12:24.588Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mappings',
|
||||||
|
name: 'tags',
|
||||||
|
type: 'tags',
|
||||||
|
status: 'modified',
|
||||||
|
location: 'remote',
|
||||||
|
conflict: false,
|
||||||
|
file: '/tags.json',
|
||||||
|
updatedAt: '2024-12-16T12:53:12.155Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { getAllByRole } = renderComponent({
|
||||||
|
pinia,
|
||||||
|
props: { isCollapsed: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(getAllByRole('button')[0]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(showToast).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Finish setting up your new variables to use in workflows',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(showToast).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Finish setting up your new credentials to use in workflows',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(showToast).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.objectContaining({
|
||||||
|
message: '1 Workflow, 1 Credential, Variables, and Tags were pulled',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show feedback where there are no change to pull', async () => {
|
||||||
|
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const { getAllByRole } = renderComponent({
|
||||||
|
pinia,
|
||||||
|
props: { isCollapsed: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(getAllByRole('button')[0]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(showMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Up to date',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, nextTick, ref } from 'vue';
|
import { computed, h, nextTick, ref } from 'vue';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { hasPermission } from '@/utils/rbac/permissions';
|
import { hasPermission } from '@/utils/rbac/permissions';
|
||||||
|
@ -9,6 +9,9 @@ import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||||
import { sourceControlEventBus } from '@/event-bus/source-control';
|
import { sourceControlEventBus } from '@/event-bus/source-control';
|
||||||
|
import { groupBy } from 'lodash-es';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
import type { SourceControlledFile } from '@n8n/api-types';
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -64,48 +67,106 @@ async function pushWorkfolder() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variablesToast = {
|
||||||
|
title: i18n.baseText('settings.sourceControl.pull.upToDate.variables.title'),
|
||||||
|
message: h(RouterLink, { to: { name: VIEWS.VARIABLES } }, () =>
|
||||||
|
i18n.baseText('settings.sourceControl.pull.upToDate.variables.description'),
|
||||||
|
),
|
||||||
|
type: 'info' as const,
|
||||||
|
closeOnClick: true,
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentialsToast = {
|
||||||
|
title: i18n.baseText('settings.sourceControl.pull.upToDate.credentials.title'),
|
||||||
|
message: h(RouterLink, { to: { name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } } }, () =>
|
||||||
|
i18n.baseText('settings.sourceControl.pull.upToDate.credentials.description'),
|
||||||
|
),
|
||||||
|
type: 'info' as const,
|
||||||
|
closeOnClick: true,
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pullMessage = ({
|
||||||
|
credential,
|
||||||
|
tags,
|
||||||
|
variables,
|
||||||
|
workflow,
|
||||||
|
}: Partial<Record<SourceControlledFile['type'], SourceControlledFile[]>>) => {
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
if (workflow?.length) {
|
||||||
|
messages.push(
|
||||||
|
i18n.baseText('generic.workflow', {
|
||||||
|
adjustToNumber: workflow.length,
|
||||||
|
interpolate: { count: workflow.length },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credential?.length) {
|
||||||
|
messages.push(
|
||||||
|
i18n.baseText('generic.credential', {
|
||||||
|
adjustToNumber: credential.length,
|
||||||
|
interpolate: { count: credential.length },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variables?.length) {
|
||||||
|
messages.push(i18n.baseText('generic.variable_plural'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags?.length) {
|
||||||
|
messages.push(i18n.baseText('generic.tag_plural'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages),
|
||||||
|
'were pulled',
|
||||||
|
].join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
async function pullWorkfolder() {
|
async function pullWorkfolder() {
|
||||||
loadingService.startLoading();
|
loadingService.startLoading();
|
||||||
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
|
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status: SourceControlledFile[] =
|
const status = await sourceControlStore.pullWorkfolder(false);
|
||||||
((await sourceControlStore.pullWorkfolder(false)) as unknown as SourceControlledFile[]) || [];
|
|
||||||
|
|
||||||
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
|
if (!status.length) {
|
||||||
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (statusWithoutLocallyCreatedWorkflows.length === 0) {
|
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
|
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
|
||||||
message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
|
message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
toast.showMessage({
|
}
|
||||||
|
|
||||||
|
const { credential, tags, variables, workflow } = groupBy(status, 'type');
|
||||||
|
|
||||||
|
const toastMessages = [
|
||||||
|
...(variables?.length ? [variablesToast] : []),
|
||||||
|
...(credential?.length ? [credentialsToast] : []),
|
||||||
|
{
|
||||||
title: i18n.baseText('settings.sourceControl.pull.success.title'),
|
title: i18n.baseText('settings.sourceControl.pull.success.title'),
|
||||||
type: 'success',
|
message: pullMessage({ credential, tags, variables, workflow }),
|
||||||
});
|
type: 'success' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const incompleteFileTypes = ['variables', 'credential'];
|
for (const message of toastMessages) {
|
||||||
const hasVariablesOrCredentials = (status || []).some((file) => {
|
/**
|
||||||
return incompleteFileTypes.includes(file.type);
|
* the toasts stack in a reversed way, resulting in
|
||||||
});
|
* Success
|
||||||
|
* Credentials
|
||||||
|
* Variables
|
||||||
|
*/
|
||||||
|
//
|
||||||
|
toast.showToast(message);
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
if (hasVariablesOrCredentials) {
|
|
||||||
void nextTick(() => {
|
|
||||||
toast.showMessage({
|
|
||||||
message: i18n.baseText('settings.sourceControl.pull.oneLastStep.description'),
|
|
||||||
title: i18n.baseText('settings.sourceControl.pull.oneLastStep.title'),
|
|
||||||
type: 'info',
|
|
||||||
duration: 0,
|
|
||||||
showClose: true,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sourceControlEventBus.emit('pull');
|
sourceControlEventBus.emit('pull');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorResponse = error.response;
|
const errorResponse = error.response;
|
||||||
|
|
|
@ -44,7 +44,18 @@ const filtersLength = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = props.modelValue[key];
|
const value = props.modelValue[key];
|
||||||
length += (Array.isArray(value) ? value.length > 0 : value !== '') ? 1 : 0;
|
|
||||||
|
if (value === true) {
|
||||||
|
length += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value) && value.length) {
|
||||||
|
length += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' && value !== '') {
|
||||||
|
length += 1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return length;
|
return length;
|
||||||
|
|
|
@ -168,13 +168,19 @@ const focusSearchInput = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAppliedFilters = (): boolean => {
|
const hasAppliedFilters = (): boolean => {
|
||||||
return !!filterKeys.value.find(
|
return !!filterKeys.value.find((key) => {
|
||||||
(key) =>
|
if (key === 'search') return false;
|
||||||
key !== 'search' &&
|
|
||||||
(Array.isArray(props.filters[key])
|
if (typeof props.filters[key] === 'boolean') {
|
||||||
? props.filters[key].length > 0
|
return props.filters[key];
|
||||||
: props.filters[key] !== ''),
|
}
|
||||||
);
|
|
||||||
|
if (Array.isArray(props.filters[key])) {
|
||||||
|
return props.filters[key].length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.filters[key] !== '';
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setRowsPerPage = (numberOfRowsPerPage: number) => {
|
const setRowsPerPage = (numberOfRowsPerPage: number) => {
|
||||||
|
|
|
@ -624,8 +624,11 @@
|
||||||
"credentials.item.created": "Created",
|
"credentials.item.created": "Created",
|
||||||
"credentials.item.owner": "Owner",
|
"credentials.item.owner": "Owner",
|
||||||
"credentials.item.readonly": "Read only",
|
"credentials.item.readonly": "Read only",
|
||||||
|
"credentials.item.needsSetup": "Needs first setup",
|
||||||
"credentials.search.placeholder": "Search credentials...",
|
"credentials.search.placeholder": "Search credentials...",
|
||||||
"credentials.filters.type": "Type",
|
"credentials.filters.type": "Type",
|
||||||
|
"credentials.filters.setup": "Needs first setup",
|
||||||
|
"credentials.filters.status": "Status",
|
||||||
"credentials.filters.active": "Some credentials may be hidden since filters are applied.",
|
"credentials.filters.active": "Some credentials may be hidden since filters are applied.",
|
||||||
"credentials.filters.active.reset": "Remove filters",
|
"credentials.filters.active.reset": "Remove filters",
|
||||||
"credentials.sort.lastUpdated": "Sort by last updated",
|
"credentials.sort.lastUpdated": "Sort by last updated",
|
||||||
|
@ -1967,6 +1970,10 @@
|
||||||
"settings.sourceControl.pull.success.title": "Pulled successfully",
|
"settings.sourceControl.pull.success.title": "Pulled successfully",
|
||||||
"settings.sourceControl.pull.upToDate.title": "Up to date",
|
"settings.sourceControl.pull.upToDate.title": "Up to date",
|
||||||
"settings.sourceControl.pull.upToDate.description": "No workflow changes to pull from Git",
|
"settings.sourceControl.pull.upToDate.description": "No workflow changes to pull from Git",
|
||||||
|
"settings.sourceControl.pull.upToDate.variables.title": "Finish setting up your new variables to use in workflows",
|
||||||
|
"settings.sourceControl.pull.upToDate.variables.description": "Review Variables",
|
||||||
|
"settings.sourceControl.pull.upToDate.credentials.title": "Finish setting up your new credentials to use in workflows",
|
||||||
|
"settings.sourceControl.pull.upToDate.credentials.description": "Review Credentials",
|
||||||
"settings.sourceControl.modals.pull.title": "Pull changes",
|
"settings.sourceControl.modals.pull.title": "Pull changes",
|
||||||
"settings.sourceControl.modals.pull.description": "These workflows will be updated, and any local changes to them will be overridden. To keep the local version, push it before pulling.",
|
"settings.sourceControl.modals.pull.description": "These workflows will be updated, and any local changes to them will be overridden. To keep the local version, push it before pulling.",
|
||||||
"settings.sourceControl.modals.pull.description.learnMore": "More info",
|
"settings.sourceControl.modals.pull.description.learnMore": "More info",
|
||||||
|
|
|
@ -5,28 +5,27 @@ import CredentialsView from '@/views/CredentialsView.vue';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { waitFor, within, fireEvent } from '@testing-library/vue';
|
import { waitFor, within, fireEvent } from '@testing-library/vue';
|
||||||
import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants';
|
import { CREDENTIAL_SELECT_MODAL_KEY, STORES, VIEWS } from '@/constants';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { Project } from '@/types/projects.types';
|
import type { Project } from '@/types/projects.types';
|
||||||
import { useRouter } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { flushPromises } from '@vue/test-utils';
|
||||||
|
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
||||||
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
||||||
useGlobalEntityCreation: () => ({
|
useGlobalEntityCreation: () => ({
|
||||||
menu: [],
|
menu: [],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('vue-router', async () => {
|
const router = createRouter({
|
||||||
const actual = await vi.importActual('vue-router');
|
history: createWebHistory(),
|
||||||
const push = vi.fn();
|
routes: [
|
||||||
const replace = vi.fn();
|
{
|
||||||
return {
|
path: '/:credentialId?',
|
||||||
...actual,
|
name: VIEWS.CREDENTIALS,
|
||||||
// your mocked methods
|
component: { template: '<div></div>' },
|
||||||
useRouter: () => ({
|
},
|
||||||
push,
|
],
|
||||||
replace,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
|
@ -36,14 +35,14 @@ const initialState = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CredentialsView, {
|
const renderComponent = createComponentRenderer(CredentialsView, {
|
||||||
global: { stubs: { ProjectHeader: true } },
|
global: { stubs: { ProjectHeader: true }, plugins: [router] },
|
||||||
});
|
});
|
||||||
let router: ReturnType<typeof useRouter>;
|
|
||||||
|
|
||||||
describe('CredentialsView', () => {
|
describe('CredentialsView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
createTestingPinia({ initialState });
|
createTestingPinia({ initialState });
|
||||||
router = useRouter();
|
await router.push('/');
|
||||||
|
await router.isReady();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -115,6 +114,7 @@ describe('CredentialsView', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update credentialId route param when opened', async () => {
|
it('should update credentialId route param when opened', async () => {
|
||||||
|
const replaceSpy = vi.spyOn(router, 'replace');
|
||||||
const projectsStore = mockedStore(useProjectsStore);
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
projectsStore.isProjectHome = false;
|
projectsStore.isProjectHome = false;
|
||||||
projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
|
projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
|
||||||
|
@ -137,8 +137,147 @@ describe('CredentialsView', () => {
|
||||||
*/
|
*/
|
||||||
await fireEvent.click(getByTestId('resources-list-item'));
|
await fireEvent.click(getByTestId('resources-list-item'));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: '1' } }),
|
expect(replaceSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ params: { credentialId: '1' } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('filters', () => {
|
||||||
|
it('should filter by type', async () => {
|
||||||
|
await router.push({ name: VIEWS.CREDENTIALS, query: { type: ['test'] } });
|
||||||
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
|
credentialsStore.allCredentialTypes = [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
displayName: 'test',
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
credentialsStore.allCredentials = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'test',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
isManaged: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'test',
|
||||||
|
type: 'another',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
isManaged: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { getAllByTestId } = renderComponent();
|
||||||
|
expect(getAllByTestId('resources-list-item').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by setupNeeded', async () => {
|
||||||
|
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
|
||||||
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
|
credentialsStore.allCredentials = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'test',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
isManaged: false,
|
||||||
|
data: {} as unknown as string,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'test',
|
||||||
|
type: 'another',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
isManaged: false,
|
||||||
|
data: { anyKey: 'any' } as unknown as string,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { getAllByTestId, getByTestId } = renderComponent();
|
||||||
|
await flushPromises();
|
||||||
|
expect(getAllByTestId('resources-list-item').length).toBe(1);
|
||||||
|
|
||||||
|
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
|
||||||
|
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by setupNeeded when object keys are empty', async () => {
|
||||||
|
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
|
||||||
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
|
credentialsStore.allCredentials = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'credential needs setup',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
isManaged: false,
|
||||||
|
data: { anyKey: '' } as unknown as string,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'random',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
isManaged: false,
|
||||||
|
data: { anyKey: 'any value' } as unknown as string,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { getAllByTestId, getByTestId } = renderComponent();
|
||||||
|
await flushPromises();
|
||||||
|
expect(getAllByTestId('resources-list-item').length).toBe(1);
|
||||||
|
expect(getByTestId('resources-list-item').textContent).toContain('credential needs setup');
|
||||||
|
|
||||||
|
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
|
||||||
|
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by setupNeeded when object keys are "CREDENTIAL_EMPTY_VALUE"', async () => {
|
||||||
|
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
|
||||||
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
|
credentialsStore.allCredentials = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'credential needs setup',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
isManaged: false,
|
||||||
|
data: { anyKey: CREDENTIAL_EMPTY_VALUE } as unknown as string,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'random',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
isManaged: false,
|
||||||
|
data: { anyKey: 'any value' } as unknown as string,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { getAllByTestId, getByTestId } = renderComponent();
|
||||||
|
await flushPromises();
|
||||||
|
expect(getAllByTestId('resources-list-item').length).toBe(1);
|
||||||
|
expect(getByTestId('resources-list-item').textContent).toContain('credential needs setup');
|
||||||
|
|
||||||
|
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
|
||||||
|
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
|
||||||
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
|
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
|
||||||
|
import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
|
||||||
import ResourcesListLayout, {
|
import ResourcesListLayout, {
|
||||||
type IResource,
|
type IResource,
|
||||||
type IFilters,
|
type IFilters,
|
||||||
} from '@/components/layouts/ResourcesListLayout.vue';
|
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import CredentialCard from '@/components/CredentialCard.vue';
|
import CredentialCard from '@/components/CredentialCard.vue';
|
||||||
import type { ICredentialType } from 'n8n-workflow';
|
|
||||||
import {
|
import {
|
||||||
CREDENTIAL_SELECT_MODAL_KEY,
|
CREDENTIAL_SELECT_MODAL_KEY,
|
||||||
CREDENTIAL_EDIT_MODAL_KEY,
|
CREDENTIAL_EDIT_MODAL_KEY,
|
||||||
|
@ -27,6 +27,9 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
|
import { N8nCheckbox } from 'n8n-design-system';
|
||||||
|
import { pickBy } from 'lodash-es';
|
||||||
|
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
credentialId?: string;
|
credentialId?: string;
|
||||||
|
@ -46,14 +49,26 @@ const router = useRouter();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const filters = ref<IFilters>({
|
type Filters = IFilters & { type?: string[]; setupNeeded?: boolean };
|
||||||
search: '',
|
const updateFilter = (state: Filters) => {
|
||||||
homeProject: '',
|
void router.replace({ query: pickBy(state) as LocationQueryRaw });
|
||||||
type: [],
|
};
|
||||||
});
|
|
||||||
|
|
||||||
|
const filters = computed<Filters>(
|
||||||
|
() =>
|
||||||
|
({ ...route.query, setupNeeded: route.query.setupNeeded?.toString() === 'true' }) as Filters,
|
||||||
|
);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const needsSetup = (data: string | undefined): boolean => {
|
||||||
|
const dataObject = data as unknown as ICredentialsDecrypted['data'];
|
||||||
|
if (!dataObject) return false;
|
||||||
|
|
||||||
|
if (Object.keys(dataObject).length === 0) return true;
|
||||||
|
|
||||||
|
return Object.values(dataObject).every((value) => !value || value === CREDENTIAL_EMPTY_VALUE);
|
||||||
|
};
|
||||||
|
|
||||||
const allCredentials = computed<IResource[]>(() =>
|
const allCredentials = computed<IResource[]>(() =>
|
||||||
credentialsStore.allCredentials.map((credential) => ({
|
credentialsStore.allCredentials.map((credential) => ({
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
|
@ -66,6 +81,7 @@ const allCredentials = computed<IResource[]>(() =>
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
sharedWithProjects: credential.sharedWithProjects,
|
sharedWithProjects: credential.sharedWithProjects,
|
||||||
readOnly: !getResourcePermissions(credential.scopes).credential.update,
|
readOnly: !getResourcePermissions(credential.scopes).credential.update,
|
||||||
|
needsSetup: needsSetup(credential.data),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -84,7 +100,7 @@ const projectPermissions = computed(() =>
|
||||||
);
|
);
|
||||||
|
|
||||||
const setRouteCredentialId = (credentialId?: string) => {
|
const setRouteCredentialId = (credentialId?: string) => {
|
||||||
void router.replace({ params: { credentialId } });
|
void router.replace({ params: { credentialId }, query: route.query });
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCredential = () => {
|
const addCredential = () => {
|
||||||
|
@ -98,7 +114,7 @@ listenForModalChanges({
|
||||||
store: uiStore,
|
store: uiStore,
|
||||||
onModalClosed(modalName) {
|
onModalClosed(modalName) {
|
||||||
if ([CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY].includes(modalName as string)) {
|
if ([CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY].includes(modalName as string)) {
|
||||||
void router.replace({ params: { credentialId: '' } });
|
void router.replace({ params: { credentialId: '' }, query: route.query });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -121,9 +137,9 @@ watch(
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => {
|
const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => {
|
||||||
const iResource = resource as ICredentialsResponse;
|
const iResource = resource as ICredentialsResponse & { needsSetup: boolean };
|
||||||
const filtersToApply = newFilters as IFilters & { type: string[] };
|
const filtersToApply = newFilters as Filters;
|
||||||
if (filtersToApply.type.length > 0) {
|
if (filtersToApply.type && filtersToApply.type.length > 0) {
|
||||||
matches = matches && filtersToApply.type.includes(iResource.type);
|
matches = matches && filtersToApply.type.includes(iResource.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,6 +152,10 @@ const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean):
|
||||||
credentialTypesById.value[iResource.type].displayName.toLowerCase().includes(searchString));
|
credentialTypesById.value[iResource.type].displayName.toLowerCase().includes(searchString));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filtersToApply.setupNeeded) {
|
||||||
|
matches = matches && iResource.needsSetup;
|
||||||
|
}
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -156,6 +176,14 @@ const initialize = async () => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
credentialsStore.$onAction(({ name, after }) => {
|
||||||
|
if (name === 'createNewCredential') {
|
||||||
|
after(() => {
|
||||||
|
void credentialsStore.fetchAllCredentials(route?.params?.projectId as string | undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
sourceControlStore.$onAction(({ name, after }) => {
|
sourceControlStore.$onAction(({ name, after }) => {
|
||||||
if (name !== 'pullWorkfolder') return;
|
if (name !== 'pullWorkfolder') return;
|
||||||
after(() => {
|
after(() => {
|
||||||
|
@ -181,7 +209,7 @@ onMounted(() => {
|
||||||
:type-props="{ itemSize: 77 }"
|
:type-props="{ itemSize: 77 }"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disabled="readOnlyEnv || !projectPermissions.credential.create"
|
:disabled="readOnlyEnv || !projectPermissions.credential.create"
|
||||||
@update:filters="filters = $event"
|
@update:filters="updateFilter"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<ProjectHeader />
|
<ProjectHeader />
|
||||||
|
@ -192,6 +220,7 @@ onMounted(() => {
|
||||||
class="mb-2xs"
|
class="mb-2xs"
|
||||||
:data="data"
|
:data="data"
|
||||||
:read-only="data.readOnly"
|
:read-only="data.readOnly"
|
||||||
|
:needs-setup="data.needsSetup"
|
||||||
@click="setRouteCredentialId"
|
@click="setRouteCredentialId"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -221,6 +250,23 @@ onMounted(() => {
|
||||||
/>
|
/>
|
||||||
</N8nSelect>
|
</N8nSelect>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-s">
|
||||||
|
<N8nInputLabel
|
||||||
|
:label="i18n.baseText('credentials.filters.status')"
|
||||||
|
:bold="false"
|
||||||
|
size="small"
|
||||||
|
color="text-base"
|
||||||
|
class="mb-3xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<N8nCheckbox
|
||||||
|
:label="i18n.baseText('credentials.filters.setup')"
|
||||||
|
data-test-id="credential-filter-setup-needed"
|
||||||
|
:model-value="filters.setupNeeded"
|
||||||
|
@update:model-value="setKeyValue('setupNeeded', $event)"
|
||||||
|
>
|
||||||
|
</N8nCheckbox>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
|
|
Loading…
Reference in a new issue