fix(editor): Update filter and feedback for source control (#12504)

This commit is contained in:
Raúl Gómez Morales 2025-01-13 10:24:51 +01:00 committed by GitHub
parent 3ec5b2850c
commit 865fc21276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 474 additions and 78 deletions

View file

@ -32,6 +32,7 @@ export async function getAllCredentials(
): Promise<ICredentialsResponse[]> {
return await makeRestApiRequest(context, 'GET', '/credentials', {
...(includeScopes ? { includeScopes } : {}),
includeData: true,
...(filter ? { filter } : {}),
});
}

View file

@ -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);
};

View file

@ -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 {

View file

@ -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',
}),
);
});
});
});
});

View file

@ -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;

View file

@ -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;

View file

@ -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) => {

View file

@ -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",

View file

@ -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));
});
});
});

View file

@ -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