feat(editor): Implement filter by project and ownership pills for source control push modal (#16551)

This commit is contained in:
Raúl Gómez Morales
2025-06-23 16:06:07 +02:00
committed by GitHub
parent f6a66c70d9
commit 254c9d7fb4
2 changed files with 164 additions and 16 deletions

View File

@@ -10,6 +10,8 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectListItem } from '@/types/projects.types';
const eventBus = createEventBus(); const eventBus = createEventBus();
@@ -51,6 +53,19 @@ const DynamicScrollerItemStub = {
template: '<slot></slot>', template: '<slot></slot>',
}; };
const projects = [
{
id: '1',
name: 'Nathan member',
type: 'personal',
},
{
id: '2',
name: 'Other project',
type: 'team',
},
] as const;
const renderModal = createComponentRenderer(SourceControlPushModal, { const renderModal = createComponentRenderer(SourceControlPushModal, {
global: { global: {
stubs: { stubs: {
@@ -471,6 +486,67 @@ describe('SourceControlPushModal', () => {
}); });
}); });
it('should filter by project', async () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.availableProjects = projects as unknown as ProjectListItem[];
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
updatedAt: '2024-09-20T10:31:40.000Z',
owner: {
type: projects[0].type,
projectId: projects[0].id,
projectName: projects[0].name as string,
},
},
{
id: 'JIGKevgZagmJAnM6',
name: 'My workflow 2',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
owner: {
type: projects[1].type,
projectId: projects[1].id,
projectName: projects[1].name as string,
},
},
];
const { getByTestId, getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2);
await userEvent.click(getByTestId('source-control-filter-dropdown'));
expect(getByTestId('source-control-push-modal-project-search')).toBeVisible();
await userEvent.click(getByTestId('source-control-push-modal-project-search'));
expect(getAllByTestId('project-sharing-info')).toHaveLength(2);
await userEvent.click(getAllByTestId('project-sharing-info')[0]);
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1);
});
it('should reset', async () => { it('should reset', async () => {
const status: SourceControlledFile[] = [ const status: SourceControlledFile[] = [
{ {

View File

@@ -1,10 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { WorkflowResource } from '@/components/layouts/ResourcesListLayout.vue';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants'; import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import { useProjectsStore } from '@/stores/projects.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import { ResourceType } from '@/utils/projects.utils';
import { getPushPriorityByStatus, getStatusText, getStatusTheme } from '@/utils/sourceControlUtils'; import { getPushPriorityByStatus, getStatusText, getStatusTheme } from '@/utils/sourceControlUtils';
import { import {
type SourceControlledFile, type SourceControlledFile,
@@ -19,6 +24,7 @@ import {
N8nIcon, N8nIcon,
N8nInput, N8nInput,
N8nInputLabel, N8nInputLabel,
N8nLink,
N8nNotice, N8nNotice,
N8nOption, N8nOption,
N8nPopover, N8nPopover,
@@ -30,7 +36,7 @@ import type { EventBus } from '@n8n/utils/event-bus';
import { refDebounced } from '@vueuse/core'; import { refDebounced } from '@vueuse/core';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import { computed, onMounted, reactive, ref, toRaw, watch } from 'vue'; import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
@@ -45,26 +51,37 @@ const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
const i18n = useI18n(); const i18n = useI18n();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const projectsStore = useProjectsStore();
const route = useRoute(); const route = useRoute();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
onBeforeMount(() => {
void projectsStore.getAvailableProjects();
});
const concatenateWithAnd = (messages: string[]) => const concatenateWithAnd = (messages: string[]) =>
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages); new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages);
type SourceControlledFileStatus = SourceControlledFile['status']; type SourceControlledFileStatus = SourceControlledFile['status'];
type SourceControlledFileWithProject = SourceControlledFile & { project?: ProjectListItem };
type Changes = { type Changes = {
tags: SourceControlledFile[]; tags: SourceControlledFileWithProject[];
variables: SourceControlledFile[]; variables: SourceControlledFileWithProject[];
credential: SourceControlledFile[]; credential: SourceControlledFileWithProject[];
workflow: SourceControlledFile[]; workflow: SourceControlledFileWithProject[];
currentWorkflow?: SourceControlledFile; currentWorkflow?: SourceControlledFileWithProject;
folders: SourceControlledFile[]; folders: SourceControlledFileWithProject[];
}; };
const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?: string): Changes => const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?: string): Changes =>
files.reduce<Changes>( files.reduce<Changes>(
(acc, file) => { (acc, file) => {
const project = projectsStore.availableProjects.find(
({ id }) => id === file.owner?.projectId,
);
// do not show remote workflows that are not yet created locally during push // do not show remote workflows that are not yet created locally during push
if ( if (
file.location === SOURCE_CONTROL_FILE_LOCATION.remote && file.location === SOURCE_CONTROL_FILE_LOCATION.remote &&
@@ -75,31 +92,31 @@ const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?:
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.variables) { if (file.type === SOURCE_CONTROL_FILE_TYPE.variables) {
acc.variables.push(file); acc.variables.push({ ...file, project });
return acc; return acc;
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.tags) { if (file.type === SOURCE_CONTROL_FILE_TYPE.tags) {
acc.tags.push(file); acc.tags.push({ ...file, project });
return acc; return acc;
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.folders) { if (file.type === SOURCE_CONTROL_FILE_TYPE.folders) {
acc.folders.push(file); acc.folders.push({ ...file, project });
return acc; return acc;
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow && currentWorkflowId === file.id) { if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow && currentWorkflowId === file.id) {
acc.currentWorkflow = file; acc.currentWorkflow = { ...file, project };
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow) { if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow) {
acc.workflow.push(file); acc.workflow.push({ ...file, project });
return acc; return acc;
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.credential) { if (file.type === SOURCE_CONTROL_FILE_TYPE.credential) {
acc.credential.push(file); acc.credential.push({ ...file, project });
return acc; return acc;
} }
@@ -150,17 +167,19 @@ const changes = computed(() => classifyFilesByType(props.data.status, workflowId
const selectedWorkflows = reactive<Set<string>>(new Set()); const selectedWorkflows = reactive<Set<string>>(new Set());
const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFile) => const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFileWithProject) =>
workflow && selectedWorkflows.add(workflow.id); workflow && selectedWorkflows.add(workflow.id);
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow)); onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
const filters = ref<{ status?: SourceControlledFileStatus }>({}); const filters = ref<{ status?: SourceControlledFileStatus; project: ProjectSharingData | null }>({
project: null,
});
const filtersApplied = computed( const filtersApplied = computed(
() => Boolean(search.value) || Boolean(Object.keys(filters.value).length), () => Boolean(search.value) || Boolean(Object.keys(filters.value).length),
); );
const resetFilters = () => { const resetFilters = () => {
filters.value = {}; filters.value = { project: null };
search.value = ''; search.value = '';
}; };
@@ -194,6 +213,10 @@ const filteredWorkflows = computed(() => {
return false; return false;
} }
if (workflow.project && filters.value.project) {
return workflow.project.id === filters.value.project.id;
}
return !(filters.value.status && filters.value.status !== workflow.status); return !(filters.value.status && filters.value.status !== workflow.status);
}); });
}); });
@@ -470,6 +493,17 @@ const filtersActiveText = computed(() => {
} }
return i18n.baseText('credentials.filters.active'); return i18n.baseText('credentials.filters.active');
}); });
function castType(type: string): ResourceType {
if (type === SOURCE_CONTROL_FILE_TYPE.workflow) {
return ResourceType.Workflow;
}
return ResourceType.Credential;
}
function castProject(project: ProjectListItem) {
return { homeProject: project } as unknown as WorkflowResource;
}
</script> </script>
<template> <template>
@@ -532,6 +566,25 @@ const filtersActiveText = computed(() => {
> >
</N8nOption> </N8nOption>
</N8nSelect> </N8nSelect>
<N8nInputLabel
:label="i18n.baseText('forms.resourceFiltersDropdown.owner')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs mt-3xs"
/>
<ProjectSharing
v-model="filters.project"
data-test-id="source-control-push-modal-project-search"
:projects="projectsStore.availableProjects"
:placeholder="i18n.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
:empty-options-text="i18n.baseText('projects.sharing.noMatchingProjects')"
/>
<div v-if="filterCount" class="mt-s">
<N8nLink @click="resetFilters">
{{ i18n.baseText('forms.resourceFiltersDropdown.reset') }}
</N8nLink>
</div>
</N8nPopover> </N8nPopover>
</div> </div>
</div> </div>
@@ -643,6 +696,24 @@ const filtersActiveText = computed(() => {
> >
Current workflow Current workflow
</N8nBadge> </N8nBadge>
<template
v-if="
file.type === SOURCE_CONTROL_FILE_TYPE.workflow ||
file.type === SOURCE_CONTROL_FILE_TYPE.credential
"
>
<ProjectCardBadge
v-if="file.project"
data-test-id="source-control-push-modal-project-badge"
:resource="castProject(file.project)"
:resource-type="castType(file.type)"
:resource-type-label="
i18n.baseText(`generic.${file.type}`).toLowerCase()
"
:personal-project="projectsStore.personalProject"
:show-badge-border="false"
/>
</template>
<N8nBadge :theme="getStatusTheme(file.status)"> <N8nBadge :theme="getStatusTheme(file.status)">
{{ getStatusText(file.status) }} {{ getStatusText(file.status) }}
</N8nBadge> </N8nBadge>
@@ -760,6 +831,7 @@ const filtersActiveText = computed(() => {
.badges { .badges {
display: flex; display: flex;
gap: 10px;
} }
.footer { .footer {