mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Implement filter by project and ownership pills for source control push modal (#16551)
This commit is contained in:
committed by
GitHub
parent
f6a66c70d9
commit
254c9d7fb4
@@ -10,6 +10,8 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { ProjectListItem } from '@/types/projects.types';
|
||||
|
||||
const eventBus = createEventBus();
|
||||
|
||||
@@ -51,6 +53,19 @@ const DynamicScrollerItemStub = {
|
||||
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, {
|
||||
global: {
|
||||
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 () => {
|
||||
const status: SourceControlledFile[] = [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<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 { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.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 {
|
||||
type SourceControlledFile,
|
||||
@@ -19,6 +24,7 @@ import {
|
||||
N8nIcon,
|
||||
N8nInput,
|
||||
N8nInputLabel,
|
||||
N8nLink,
|
||||
N8nNotice,
|
||||
N8nOption,
|
||||
N8nPopover,
|
||||
@@ -30,7 +36,7 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { refDebounced } from '@vueuse/core';
|
||||
import dateformat from 'dateformat';
|
||||
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 { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
@@ -45,26 +51,37 @@ const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const route = useRoute();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
onBeforeMount(() => {
|
||||
void projectsStore.getAvailableProjects();
|
||||
});
|
||||
|
||||
const concatenateWithAnd = (messages: string[]) =>
|
||||
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages);
|
||||
|
||||
type SourceControlledFileStatus = SourceControlledFile['status'];
|
||||
|
||||
type SourceControlledFileWithProject = SourceControlledFile & { project?: ProjectListItem };
|
||||
|
||||
type Changes = {
|
||||
tags: SourceControlledFile[];
|
||||
variables: SourceControlledFile[];
|
||||
credential: SourceControlledFile[];
|
||||
workflow: SourceControlledFile[];
|
||||
currentWorkflow?: SourceControlledFile;
|
||||
folders: SourceControlledFile[];
|
||||
tags: SourceControlledFileWithProject[];
|
||||
variables: SourceControlledFileWithProject[];
|
||||
credential: SourceControlledFileWithProject[];
|
||||
workflow: SourceControlledFileWithProject[];
|
||||
currentWorkflow?: SourceControlledFileWithProject;
|
||||
folders: SourceControlledFileWithProject[];
|
||||
};
|
||||
|
||||
const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?: string): Changes =>
|
||||
files.reduce<Changes>(
|
||||
(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
|
||||
if (
|
||||
file.location === SOURCE_CONTROL_FILE_LOCATION.remote &&
|
||||
@@ -75,31 +92,31 @@ const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?:
|
||||
}
|
||||
|
||||
if (file.type === SOURCE_CONTROL_FILE_TYPE.variables) {
|
||||
acc.variables.push(file);
|
||||
acc.variables.push({ ...file, project });
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (file.type === SOURCE_CONTROL_FILE_TYPE.tags) {
|
||||
acc.tags.push(file);
|
||||
acc.tags.push({ ...file, project });
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (file.type === SOURCE_CONTROL_FILE_TYPE.folders) {
|
||||
acc.folders.push(file);
|
||||
acc.folders.push({ ...file, project });
|
||||
return acc;
|
||||
}
|
||||
|
||||
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) {
|
||||
acc.workflow.push(file);
|
||||
acc.workflow.push({ ...file, project });
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (file.type === SOURCE_CONTROL_FILE_TYPE.credential) {
|
||||
acc.credential.push(file);
|
||||
acc.credential.push({ ...file, project });
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -150,17 +167,19 @@ const changes = computed(() => classifyFilesByType(props.data.status, workflowId
|
||||
|
||||
const selectedWorkflows = reactive<Set<string>>(new Set());
|
||||
|
||||
const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFile) =>
|
||||
const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFileWithProject) =>
|
||||
workflow && selectedWorkflows.add(workflow.id);
|
||||
|
||||
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
|
||||
|
||||
const filters = ref<{ status?: SourceControlledFileStatus }>({});
|
||||
const filters = ref<{ status?: SourceControlledFileStatus; project: ProjectSharingData | null }>({
|
||||
project: null,
|
||||
});
|
||||
const filtersApplied = computed(
|
||||
() => Boolean(search.value) || Boolean(Object.keys(filters.value).length),
|
||||
);
|
||||
const resetFilters = () => {
|
||||
filters.value = {};
|
||||
filters.value = { project: null };
|
||||
search.value = '';
|
||||
};
|
||||
|
||||
@@ -194,6 +213,10 @@ const filteredWorkflows = computed(() => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -470,6 +493,17 @@ const filtersActiveText = computed(() => {
|
||||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -532,6 +566,25 @@ const filtersActiveText = computed(() => {
|
||||
>
|
||||
</N8nOption>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -643,6 +696,24 @@ const filtersActiveText = computed(() => {
|
||||
>
|
||||
Current workflow
|
||||
</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)">
|
||||
{{ getStatusText(file.status) }}
|
||||
</N8nBadge>
|
||||
@@ -760,6 +831,7 @@ const filtersActiveText = computed(() => {
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
Reference in New Issue
Block a user