diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 349bf5be102..a7f8f9352d8 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -82,6 +82,7 @@ "generic.upgrade": "Upgrade", "generic.upgradeNow": "Upgrade now", "generic.credential": "Credential | {count} Credential | {count} Credentials", + "generic.credentials": "Credentials", "generic.workflow": "Workflow | {count} Workflow | {count} Workflows", "generic.workflowSaved": "Workflow changes saved", "generic.editor": "Editor", @@ -2188,6 +2189,7 @@ "settings.sourceControl.modals.push.description": "The following will be committed: ", "settings.sourceControl.modals.push.description.learnMore": "More info", "settings.sourceControl.modals.push.filesToCommit": "Files to commit", + "settings.sourceControl.modals.push.filter": "Filters are applied. Showing {count} {entity}.", "settings.sourceControl.modals.push.workflowsToCommit": "Select workflows", "settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date", "settings.sourceControl.modals.push.noWorkflowChanges": "There are no workflow changes but the following will be committed: {link}", @@ -2198,6 +2200,7 @@ "settings.sourceControl.modals.push.buttons.save": "Commit and push", "settings.sourceControl.modals.push.success.title": "Pushed successfully", "settings.sourceControl.modals.push.success.description": "were committed and pushed to your remote repository", + "settings.sourceControl.modals.push.projectAdmin.callout": "If you want to push workflows from your personal space, move then to a project first.", "settings.sourceControl.status.modified": "Modified", "settings.sourceControl.status.deleted": "Deleted", "settings.sourceControl.status.created": "New", diff --git a/packages/frontend/editor-ui/src/components/SourceControlPushModal.ee.test.ts b/packages/frontend/editor-ui/src/components/SourceControlPushModal.ee.test.ts index 680817f659d..fb80c91839f 100644 --- a/packages/frontend/editor-ui/src/components/SourceControlPushModal.ee.test.ts +++ b/packages/frontend/editor-ui/src/components/SourceControlPushModal.ee.test.ts @@ -323,7 +323,6 @@ describe('SourceControlPushModal', () => { }); it('should show credentials in a different tab', async () => { - // source-control-push-modal-tab const status: SourceControlledFile[] = [ { id: 'gTbbBkkYTnNyX1jD', @@ -486,15 +485,18 @@ describe('SourceControlPushModal', () => { }); }); - it('should filter by project', async () => { + test.each([ + ['credential', 'Credentials'], + ['workflow', 'Workflows'], + ])('should filter %s by project', async (entity, name) => { const projectsStore = mockedStore(useProjectsStore); projectsStore.availableProjects = projects as unknown as ProjectListItem[]; const status: SourceControlledFile[] = [ { id: 'gTbbBkkYTnNyX1jD', - name: 'My workflow 1', - type: 'workflow', + name: `My ${name} 1`, + type: entity as SourceControlledFile['type'], status: 'created', location: 'local', conflict: false, @@ -508,8 +510,8 @@ describe('SourceControlPushModal', () => { }, { id: 'JIGKevgZagmJAnM6', - name: 'My workflow 2', - type: 'workflow', + name: `My ${name} 1`, + type: entity as SourceControlledFile['type'], status: 'created', location: 'local', conflict: false, @@ -532,6 +534,12 @@ describe('SourceControlPushModal', () => { }, }); + const tab = getAllByTestId('source-control-push-modal-tab').filter(({ textContent }) => + textContent?.includes(name), + ); + + await userEvent.click(tab[0]); + expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2); await userEvent.click(getByTestId('source-control-filter-dropdown')); @@ -545,6 +553,9 @@ describe('SourceControlPushModal', () => { await userEvent.click(getAllByTestId('project-sharing-info')[0]); expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1); + expect(getByTestId('source-control-push-modal-file-checkbox')).toHaveTextContent( + `My ${name} 1`, + ); }); it('should reset', async () => { diff --git a/packages/frontend/editor-ui/src/components/SourceControlPushModal.ee.vue b/packages/frontend/editor-ui/src/components/SourceControlPushModal.ee.vue index 0723cc3252c..56670036825 100644 --- a/packages/frontend/editor-ui/src/components/SourceControlPushModal.ee.vue +++ b/packages/frontend/editor-ui/src/components/SourceControlPushModal.ee.vue @@ -4,14 +4,17 @@ 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 type { WorkflowResource } from '@/Interface'; import { useProjectsStore } from '@/stores/projects.store'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useUIStore } from '@/stores/ui.store'; +import { useUsersStore } from '@/stores/users.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 } from '@n8n/api-types'; import { - type SourceControlledFile, + ROLE, SOURCE_CONTROL_FILE_LOCATION, SOURCE_CONTROL_FILE_STATUS, SOURCE_CONTROL_FILE_TYPE, @@ -19,6 +22,7 @@ import { import { N8nBadge, N8nButton, + N8nCallout, N8nHeading, N8nIcon, N8nInput, @@ -32,7 +36,7 @@ import { } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import type { EventBus } from '@n8n/utils/event-bus'; -import { refDebounced } from '@vueuse/core'; +import { refDebounced, useStorage } from '@vueuse/core'; import dateformat from 'dateformat'; import orderBy from 'lodash/orderBy'; import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch } from 'vue'; @@ -40,7 +44,6 @@ import { useRoute } from 'vue-router'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; import Modal from './Modal.vue'; -import { type WorkflowResource } from '@/Interface'; const props = defineProps<{ data: { eventBus: EventBus; status: SourceControlledFile[] }; @@ -54,11 +57,25 @@ const sourceControlStore = useSourceControlStore(); const projectsStore = useProjectsStore(); const route = useRoute(); const telemetry = useTelemetry(); +const usersStore = useUsersStore(); + +const projectAdminCalloutDismissed = useStorage( + 'SOURCE_CONTROL_PROJECT_ADMIN_CALLOUT_DISMISSED', + false, + localStorage, +); onBeforeMount(() => { void projectsStore.getAvailableProjects(); }); +const projectsForFilters = computed(() => { + return projectsStore.availableProjects.filter( + // global admins role is empty... + (project) => !project.role || project.role === 'project:admin', + ); +}); + const concatenateWithAnd = (messages: string[]) => new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages); @@ -172,11 +189,31 @@ const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFileWithProject) onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow)); +const currentProject = computed(() => { + if (!route.params.projectId) { + return null; + } + + const project = projectsStore.availableProjects.find( + (project) => project.id === route.params.projectId?.toString(), + ); + + if (!project) { + return null; + } + + if (!project.role || project.role === 'project:admin') { + return project; + } + + return null; +}); + const filters = ref<{ status?: SourceControlledFileStatus; project: ProjectSharingData | null }>({ - project: null, + project: currentProject.value, }); const filtersApplied = computed( - () => Boolean(search.value) || Boolean(Object.keys(filters.value).length), + () => Boolean(search.value) || Boolean(Object.values(filters.value).filter(Boolean).length), ); const resetFilters = () => { filters.value = { project: null }; @@ -244,6 +281,10 @@ const filteredCredentials = computed(() => { return false; } + if (credential.project && filters.value.project) { + return credential.project.id === filters.value.project.id; + } + return !(filters.value.status && filters.value.status !== credential.status); }); }); @@ -417,6 +458,10 @@ const allVisibleItemsSelected = computed(() => { if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { const workflowsSet = new Set(sortedWorkflows.value.map(({ id }) => id)); + + if (!workflowsSet.size) { + return false; + } const notSelectedVisibleItems = workflowsSet.difference(toRaw(activeSelection.value)); return !Boolean(notSelectedVisibleItems.size); @@ -424,6 +469,9 @@ const allVisibleItemsSelected = computed(() => { if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) { const credentialsSet = new Set(sortedCredentials.value.map(({ id }) => id)); + if (!credentialsSet.size) { + return false; + } const notSelectedVisibleItems = credentialsSet.difference(toRaw(activeSelection.value)); return !Boolean(notSelectedVisibleItems.size); @@ -460,6 +508,14 @@ const activeDataSourceFiltered = computed(() => { return []; }); +const activeEntityLocale = computed(() => { + if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { + return 'generic.workflows'; + } + + return 'generic.credentials'; +}); + const activeSelection = computed(() => { if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { return selectedWorkflows; @@ -487,11 +543,11 @@ const tabs = computed(() => { ]; }); -const filtersActiveText = computed(() => { +const filtersNoResultText = computed(() => { if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { - return i18n.baseText('workflows.filters.active'); + return i18n.baseText('workflows.noResults'); } - return i18n.baseText('credentials.filters.active'); + return i18n.baseText('credentials.noResults'); }); function castType(type: string): ResourceType { @@ -519,7 +575,11 @@ function castProject(project: ProjectListItem) { {{ i18n.baseText('settings.sourceControl.modals.push.title') }} -
+
@@ -588,6 +648,26 @@ function castProject(project: ProjectListItem) {
+