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 { 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[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user