mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-30 22:02:03 -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[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/credentials', {
|
||||
...(includeScopes ? { includeScopes } : {}),
|
||||
includeData: true,
|
||||
...(filter ? { filter } : {}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ export const pushWorkfolder = async (
|
|||
export const pullWorkfolder = async (
|
||||
context: IRestApiContext,
|
||||
data: PullWorkFolderRequestDto,
|
||||
): Promise<void> => {
|
||||
): Promise<SourceControlledFile[]> => {
|
||||
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
|
||||
};
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ const props = withDefaults(
|
|||
defineProps<{
|
||||
data: ICredentialsResponse;
|
||||
readOnly?: boolean;
|
||||
needsSetup?: boolean;
|
||||
}>(),
|
||||
{
|
||||
data: () => ({
|
||||
|
@ -146,6 +147,9 @@ function moveResource() {
|
|||
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
|
||||
{{ locale.baseText('credentials.item.readonly') }}
|
||||
</N8nBadge>
|
||||
<N8nBadge v-if="needsSetup" class="ml-3xs" theme="warning">
|
||||
{{ locale.baseText('credentials.item.needsSetup') }}
|
||||
</N8nBadge>
|
||||
</n8n-heading>
|
||||
</template>
|
||||
<div :class="$style.cardDescription">
|
||||
|
@ -195,10 +199,6 @@ function moveResource() {
|
|||
.cardHeading {
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--spacing-s) 0 0;
|
||||
|
||||
span {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/vue';
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
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 MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
|
@ -18,8 +18,9 @@ let rbacStore: ReturnType<typeof useRBACStore>;
|
|||
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const showToast = vi.fn();
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage, showError }),
|
||||
useToast: () => ({ showMessage, showError, showToast }),
|
||||
}));
|
||||
|
||||
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>
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { computed, h, nextTick, ref } from 'vue';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
|
@ -9,6 +9,9 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||
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';
|
||||
|
||||
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() {
|
||||
loadingService.startLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
|
||||
|
||||
try {
|
||||
const status: SourceControlledFile[] =
|
||||
((await sourceControlStore.pullWorkfolder(false)) as unknown as SourceControlledFile[]) || [];
|
||||
const status = await sourceControlStore.pullWorkfolder(false);
|
||||
|
||||
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
|
||||
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
|
||||
});
|
||||
|
||||
if (statusWithoutLocallyCreatedWorkflows.length === 0) {
|
||||
if (!status.length) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
|
||||
message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
|
||||
type: 'success',
|
||||
});
|
||||
} else {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.sourceControl.pull.success.title'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
const incompleteFileTypes = ['variables', 'credential'];
|
||||
const hasVariablesOrCredentials = (status || []).some((file) => {
|
||||
return incompleteFileTypes.includes(file.type);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { credential, tags, variables, workflow } = groupBy(status, 'type');
|
||||
|
||||
const toastMessages = [
|
||||
...(variables?.length ? [variablesToast] : []),
|
||||
...(credential?.length ? [credentialsToast] : []),
|
||||
{
|
||||
title: i18n.baseText('settings.sourceControl.pull.success.title'),
|
||||
message: pullMessage({ credential, tags, variables, workflow }),
|
||||
type: 'success' as const,
|
||||
},
|
||||
];
|
||||
|
||||
for (const message of toastMessages) {
|
||||
/**
|
||||
* the toasts stack in a reversed way, resulting in
|
||||
* Success
|
||||
* Credentials
|
||||
* Variables
|
||||
*/
|
||||
//
|
||||
toast.showToast(message);
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
sourceControlEventBus.emit('pull');
|
||||
} catch (error) {
|
||||
const errorResponse = error.response;
|
||||
|
|
|
@ -44,7 +44,18 @@ const filtersLength = computed(() => {
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
|
@ -168,13 +168,19 @@ const focusSearchInput = () => {
|
|||
};
|
||||
|
||||
const hasAppliedFilters = (): boolean => {
|
||||
return !!filterKeys.value.find(
|
||||
(key) =>
|
||||
key !== 'search' &&
|
||||
(Array.isArray(props.filters[key])
|
||||
? props.filters[key].length > 0
|
||||
: props.filters[key] !== ''),
|
||||
);
|
||||
return !!filterKeys.value.find((key) => {
|
||||
if (key === 'search') return false;
|
||||
|
||||
if (typeof props.filters[key] === 'boolean') {
|
||||
return props.filters[key];
|
||||
}
|
||||
|
||||
if (Array.isArray(props.filters[key])) {
|
||||
return props.filters[key].length > 0;
|
||||
}
|
||||
|
||||
return props.filters[key] !== '';
|
||||
});
|
||||
};
|
||||
|
||||
const setRowsPerPage = (numberOfRowsPerPage: number) => {
|
||||
|
|
|
@ -624,8 +624,11 @@
|
|||
"credentials.item.created": "Created",
|
||||
"credentials.item.owner": "Owner",
|
||||
"credentials.item.readonly": "Read only",
|
||||
"credentials.item.needsSetup": "Needs first setup",
|
||||
"credentials.search.placeholder": "Search credentials...",
|
||||
"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.reset": "Remove filters",
|
||||
"credentials.sort.lastUpdated": "Sort by last updated",
|
||||
|
@ -1967,6 +1970,10 @@
|
|||
"settings.sourceControl.pull.success.title": "Pulled successfully",
|
||||
"settings.sourceControl.pull.upToDate.title": "Up to date",
|
||||
"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.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",
|
||||
|
|
|
@ -5,28 +5,27 @@ import CredentialsView from '@/views/CredentialsView.vue';
|
|||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
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 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', () => ({
|
||||
useGlobalEntityCreation: () => ({
|
||||
menu: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
const push = vi.fn();
|
||||
const replace = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
useRouter: () => ({
|
||||
push,
|
||||
replace,
|
||||
}),
|
||||
};
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/:credentialId?',
|
||||
name: VIEWS.CREDENTIALS,
|
||||
component: { template: '<div></div>' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
|
@ -36,14 +35,14 @@ const initialState = {
|
|||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(CredentialsView, {
|
||||
global: { stubs: { ProjectHeader: true } },
|
||||
global: { stubs: { ProjectHeader: true }, plugins: [router] },
|
||||
});
|
||||
let router: ReturnType<typeof useRouter>;
|
||||
|
||||
describe('CredentialsView', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
createTestingPinia({ initialState });
|
||||
router = useRouter();
|
||||
await router.push('/');
|
||||
await router.isReady();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -115,6 +114,7 @@ describe('CredentialsView', () => {
|
|||
});
|
||||
|
||||
it('should update credentialId route param when opened', async () => {
|
||||
const replaceSpy = vi.spyOn(router, 'replace');
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.isProjectHome = false;
|
||||
projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
|
||||
|
@ -137,8 +137,147 @@ describe('CredentialsView', () => {
|
|||
*/
|
||||
await fireEvent.click(getByTestId('resources-list-item'));
|
||||
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">
|
||||
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 { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
|
||||
import ResourcesListLayout, {
|
||||
type IResource,
|
||||
type IFilters,
|
||||
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||
import CredentialCard from '@/components/CredentialCard.vue';
|
||||
import type { ICredentialType } from 'n8n-workflow';
|
||||
import {
|
||||
CREDENTIAL_SELECT_MODAL_KEY,
|
||||
CREDENTIAL_EDIT_MODAL_KEY,
|
||||
|
@ -27,6 +27,9 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
|||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
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<{
|
||||
credentialId?: string;
|
||||
|
@ -46,14 +49,26 @@ const router = useRouter();
|
|||
const telemetry = useTelemetry();
|
||||
const i18n = useI18n();
|
||||
|
||||
const filters = ref<IFilters>({
|
||||
search: '',
|
||||
homeProject: '',
|
||||
type: [],
|
||||
});
|
||||
type Filters = IFilters & { type?: string[]; setupNeeded?: boolean };
|
||||
const updateFilter = (state: Filters) => {
|
||||
void router.replace({ query: pickBy(state) as LocationQueryRaw });
|
||||
};
|
||||
|
||||
const filters = computed<Filters>(
|
||||
() =>
|
||||
({ ...route.query, setupNeeded: route.query.setupNeeded?.toString() === 'true' }) as Filters,
|
||||
);
|
||||
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[]>(() =>
|
||||
credentialsStore.allCredentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
|
@ -66,6 +81,7 @@ const allCredentials = computed<IResource[]>(() =>
|
|||
type: credential.type,
|
||||
sharedWithProjects: credential.sharedWithProjects,
|
||||
readOnly: !getResourcePermissions(credential.scopes).credential.update,
|
||||
needsSetup: needsSetup(credential.data),
|
||||
})),
|
||||
);
|
||||
|
||||
|
@ -84,7 +100,7 @@ const projectPermissions = computed(() =>
|
|||
);
|
||||
|
||||
const setRouteCredentialId = (credentialId?: string) => {
|
||||
void router.replace({ params: { credentialId } });
|
||||
void router.replace({ params: { credentialId }, query: route.query });
|
||||
};
|
||||
|
||||
const addCredential = () => {
|
||||
|
@ -98,7 +114,7 @@ listenForModalChanges({
|
|||
store: uiStore,
|
||||
onModalClosed(modalName) {
|
||||
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 iResource = resource as ICredentialsResponse;
|
||||
const filtersToApply = newFilters as IFilters & { type: string[] };
|
||||
if (filtersToApply.type.length > 0) {
|
||||
const iResource = resource as ICredentialsResponse & { needsSetup: boolean };
|
||||
const filtersToApply = newFilters as Filters;
|
||||
if (filtersToApply.type && filtersToApply.type.length > 0) {
|
||||
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));
|
||||
}
|
||||
|
||||
if (filtersToApply.setupNeeded) {
|
||||
matches = matches && iResource.needsSetup;
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
|
@ -156,6 +176,14 @@ const initialize = async () => {
|
|||
loading.value = false;
|
||||
};
|
||||
|
||||
credentialsStore.$onAction(({ name, after }) => {
|
||||
if (name === 'createNewCredential') {
|
||||
after(() => {
|
||||
void credentialsStore.fetchAllCredentials(route?.params?.projectId as string | undefined);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sourceControlStore.$onAction(({ name, after }) => {
|
||||
if (name !== 'pullWorkfolder') return;
|
||||
after(() => {
|
||||
|
@ -181,7 +209,7 @@ onMounted(() => {
|
|||
:type-props="{ itemSize: 77 }"
|
||||
:loading="loading"
|
||||
:disabled="readOnlyEnv || !projectPermissions.credential.create"
|
||||
@update:filters="filters = $event"
|
||||
@update:filters="updateFilter"
|
||||
>
|
||||
<template #header>
|
||||
<ProjectHeader />
|
||||
|
@ -192,6 +220,7 @@ onMounted(() => {
|
|||
class="mb-2xs"
|
||||
:data="data"
|
||||
:read-only="data.readOnly"
|
||||
:needs-setup="data.needsSetup"
|
||||
@click="setRouteCredentialId"
|
||||
/>
|
||||
</template>
|
||||
|
@ -221,6 +250,23 @@ onMounted(() => {
|
|||
/>
|
||||
</N8nSelect>
|
||||
</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 #empty>
|
||||
<n8n-action-box
|
||||
|
|
Loading…
Reference in a new issue