diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 951f94144f3..5da4326ec2e 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -9885,6 +9885,75 @@ "promotions.bindings.destinationDenied": "You can't create this item in the destination project. Ask a project admin for access.", "promotions.bindings.editorBusy": "Another editor is open. Close it and try again.", "promotions.bindings.editorClosed": "The editor closed. Open it again to continue.", + "promotions.preview.title.promote": "Changes to promote", + "promotions.preview.title.apply": "Changes to apply", + "promotions.preview.back": "Back to {project}", + "promotions.preview.tab.changes": "Changes", + "promotions.preview.branch.promote": "Base branch: {branch}", + "promotions.preview.branch.apply": "Branch: {branch}", + "promotions.preview.lastRefreshed": "Last refreshed {time}", + "promotions.preview.refresh": "Refresh", + "promotions.preview.refreshFailed": "Couldn't refresh the preview. It shows the changes from the last refresh. Refresh to try again.", + "promotions.preview.dropped": "{count} workflow left the preview and is no longer selected. | {count} workflows left the preview and are no longer selected.", + "promotions.preview.state.empty.promote.title": "Nothing to promote", + "promotions.preview.state.empty.apply.title": "Nothing to apply", + "promotions.preview.state.empty.description": "Refresh if the project or the branch has changed.", + "promotions.preview.state.disconnected.promote.title": "Promote isn't set up", + "promotions.preview.state.disconnected.apply.title": "Apply isn't set up", + "promotions.preview.state.disconnected.admin": "Set it up in the promotion settings.", + "promotions.preview.state.disconnected.member": "Ask an admin to set it up.", + "promotions.preview.state.disconnected.action": "Open promotion settings", + "promotions.preview.state.connectionFailed": "Couldn't check the instance connection", + "promotions.preview.state.error": "Couldn't load the changes", + "promotions.preview.state.retry": "Try again", + "promotions.preview.change.added": "New", + "promotions.preview.change.archived": "Archived", + "promotions.preview.change.deleted": "Deleted", + "promotions.preview.change.moved": "Moved", + "promotions.preview.change.renamed": "Renamed", + "promotions.preview.change.modified": "Modified", + "promotions.preview.tree.selectAll": "Select all workflows", + "promotions.preview.tree.select": "Select {name}", + "promotions.preview.tree.selectedOfTotal": "{selected} of {total} change | {selected} of {total} changes", + "promotions.preview.tree.section.workflows": "Workflows", + "promotions.preview.tree.group.credential": "Credentials", + "promotions.preview.tree.group.dataTable": "Data tables", + "promotions.preview.tree.group.variable": "Variables", + "promotions.preview.tree.group.tag": "Tags", + "promotions.preview.tree.group.subWorkflow": "Sub-workflows", + "promotions.preview.card.noChanges": "No changes. The operation keeps this resource as it is.", + "promotions.preview.card.loadFailed": "Couldn't load the changes to this resource.", + "promotions.preview.card.stale": "This resource changed after the preview loaded. Refresh the preview to review the current changes.", + "promotions.preview.card.missing": "This resource no longer exists. Refresh the preview.", + "promotions.preview.card.unreadable": "The file in the Git branch has an unexpected format. Ask an admin to check it, then refresh the preview.", + "promotions.preview.card.included": "Included", + "promotions.preview.card.notIncluded": "Not included", + "promotions.preview.card.usedBy": "Used by {names}", + "promotions.preview.card.calledBy": "Called by {names}", + "promotions.preview.card.globalScope": "Global", + "promotions.preview.field.before": "Before", + "promotions.preview.field.after": "After", + "promotions.preview.field.empty": "None", + "promotions.preview.field.name": "Name", + "promotions.preview.field.description": "Description", + "promotions.preview.field.icon": "Icon", + "promotions.preview.field.location": "Location", + "promotions.preview.field.type": "Type", + "promotions.preview.field.data": "Exported expressions", + "promotions.preview.field.value": "Value", + "promotions.preview.variable.valueHidden": "You don't have permission to see variable values.", + "promotions.preview.folder.moveConflict": "Apply can't move a folder that already exists on this instance. Move the folder on this instance to match the branch, then refresh the preview.", + "promotions.preview.dataTable.columns": "Columns", + "promotions.preview.dataTable.addColumn": "Adds column {column} ({type})", + "promotions.preview.dataTable.removeColumn": "Removes column {column}", + "promotions.preview.dataTable.removeColumnWithValues": "Removes column {column} and its values", + "promotions.preview.dataTable.changeColumnType": "Changes the type of column {column} from {from} to {to}", + "promotions.preview.dataTable.renameColumn": "Renames column {from} to {to}", + "promotions.preview.dataTable.reorderColumns": "Changes the column order", + "promotions.preview.dataTable.renameTable": "Renames the table from {from} to {to}", + "promotions.preview.dataTable.rowsNotCopied": "The operation copies the table structure only. It doesn't copy rows.", + "promotions.preview.side.notOnBranch": "Not on the branch", + "promotions.preview.side.notOnInstance": "Not on this instance", "credentialEdit.typeUnavailable": "This credential type isn't available. Install it and try again.", "credentialEdit.savedLoadFailed": "The credential was saved, but its details couldn't be loaded. Retry to continue editing.", "agents.channels.teams.setup.install.checking": "Checking the credential can reach Microsoft…", diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/__tests__/module.descriptor.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/__tests__/module.descriptor.test.ts index a8ce3938c8a..83093f1ba65 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/__tests__/module.descriptor.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/__tests__/module.descriptor.test.ts @@ -1,10 +1,12 @@ +import type { Scope } from '@n8n/permissions'; +import { useRBACStore } from '@n8n/stores/rbac.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { createTestingPinia } from '@pinia/testing'; import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router'; import { VIEWS } from '@/app/constants'; import { PromotionsModule } from '../module.descriptor'; -import { PROMOTIONS_SETTINGS_VIEW } from '../promotions.constants'; +import { PROMOTION_PREVIEW_VIEWS, PROMOTIONS_SETTINGS_VIEW } from '../promotions.constants'; const stub = { render: () => null }; @@ -16,22 +18,30 @@ const moduleRoutes = (PromotionsModule.routes ?? []).map( children: route.children?.map((child) => ({ ...child, component: stub })), }) as RouteRecordRaw, ); +const settingsRoutes = moduleRoutes.filter((route) => !route.meta?.projectRoute); +const projectRoutes = moduleRoutes.filter((route) => route.meta?.projectRoute); const createTestRouter = () => createRouter({ history: createMemoryHistory(), routes: [ { path: '/home', name: VIEWS.HOMEPAGE, component: stub }, - { path: '/settings', component: stub, children: moduleRoutes }, + { path: '/settings', component: stub, children: settingsRoutes }, + { path: '/projects/:projectId', component: stub, children: projectRoutes }, ], }); -const setup = ({ flag = 'true' } = {}) => { +const setup = ({ + flag = 'true', + scopes = ['gitConnection:read', 'gitConnection:push', 'gitConnection:pull'] as Scope[], +} = {}) => { createTestingPinia({ stubActions: false }); const settingsStore = useSettingsStore(); settingsStore.settings.activeModules = ['promotions']; settingsStore.settings.envFeatureFlags = { N8N_ENV_FEAT_PROMOTIONS: flag }; + // Project scopes stay empty, as in the app, where the frontend does not load them. + useRBACStore().setGlobalScopes(scopes); }; describe('PromotionsModule settings route', () => { @@ -53,3 +63,38 @@ describe('PromotionsModule settings route', () => { expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE); }); }); + +describe('PromotionsModule preview routes', () => { + it('opens the apply preview of a project with the direction from the route', async () => { + setup(); + const router = createTestRouter(); + + await router.push('/projects/p1/promotions/apply'); + + const route = router.currentRoute.value; + expect(route.name).toBe(PROMOTION_PREVIEW_VIEWS.apply); + const props = route.matched.at(-1)?.props.default; + expect(typeof props === 'function' ? props(route) : props).toEqual({ + projectId: 'p1', + direction: 'apply', + }); + }); + + it('leads users who cannot apply changes home', async () => { + setup({ scopes: ['gitConnection:read', 'gitConnection:push'] }); + const router = createTestRouter(); + + await router.push('/projects/p1/promotions/apply'); + + expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE); + }); + + it('redirects to home when the feature flag is off', async () => { + setup({ flag: 'false' }); + const router = createTestRouter(); + + await router.push('/projects/p1/promotions/promote'); + + expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/DataTableChangeDetails.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/DataTableChangeDetails.vue new file mode 100644 index 00000000000..15d5777855d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/DataTableChangeDetails.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCard.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCard.test.ts new file mode 100644 index 00000000000..d862c41e40a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCard.test.ts @@ -0,0 +1,244 @@ +import type { + PromotionDirection, + PromotionResource, + PromotionResourceDetail, +} from '@n8n/api-types'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; + +import { createComponentRenderer } from '@/__tests__/render'; + +import { billingPreview, billingResources, resource } from '../../__tests__/preview.fixtures'; +import { buildSelectionGraph } from '../../promotionPreview.selection'; +import PromotionChangeCard from './PromotionChangeCard.vue'; + +const renderComponent = createComponentRenderer(PromotionChangeCard); + +type Case = { + name: string; + direction: PromotionDirection; + resource: PromotionResource; + detail?: PromotionResourceDetail; + /** The files changed after the preview loaded, so the revisions differ. */ + stale?: boolean; + shows: string[]; +}; + +const revision = { before: 'b', after: 'a' }; + +const cases: Case[] = [ + { + name: 'a renamed project with its old and new name', + direction: 'promote', + resource: resource({ key: 'project:p1', name: 'Billing', change: 'modified' }), + detail: { + key: 'project:p1', + type: 'project', + before: { id: 'p1', name: 'Invoicing' }, + after: { id: 'p1', name: 'Billing' }, + revision, + }, + shows: ['Before', 'Invoicing', 'After', 'Billing'], + }, + { + name: 'only the move of a folder, which stops Apply', + direction: 'apply', + resource: resource({ + key: 'folder:f3', + name: 'Archive', + parentKey: 'folder:f2', + previousParentKey: 'folder:f1', + change: 'modified', + }), + detail: { + key: 'folder:f3', + type: 'folder', + before: { id: 'f3', name: 'Archive', parentFolderId: 'f1' }, + after: { id: 'f3', name: 'Old invoices', parentFolderId: 'f2' }, + revision, + }, + shows: [ + 'Location', + 'Payments', + 'Reports', + "Apply can't move a folder that already exists on this instance", + ], + }, + { + name: 'the exported expressions of a new credential', + direction: 'promote', + resource: resource({ key: 'credential:c3', name: 'Slack', change: 'added' }), + detail: { + key: 'credential:c3', + type: 'credential', + before: null, + after: { id: 'c3', name: 'Slack', type: 'slackApi', data: { token: '={{ $env.SLACK }}' } }, + revision: { before: null, after: 'a' }, + }, + shows: ['Not on the branch', 'Exported expressions', '$env.SLACK', 'slackApi'], + }, + { + name: 'that variable values are hidden from the user', + direction: 'promote', + resource: billingResources.apiUrl, + detail: { + key: 'variable:global:API_URL', + type: 'variable', + before: null, + after: { name: 'API_URL', type: 'string' }, + revision: { before: null, after: 'a' }, + valueHidden: true, + }, + shows: ['Global', "You don't have permission to see variable values."], + }, + { + name: 'the column changes of a data table', + direction: 'apply', + resource: billingResources.invoicesTable, + detail: { + key: 'dataTable:t1', + type: 'dataTable', + before: null, + after: null, + revision, + dataTableChanges: [ + { kind: 'remove-column', column: 'notes', type: 'string', destructive: true }, + { kind: 'rename-column', from: 'amount', to: 'total', destructive: false }, + ], + }, + shows: ['Removes column notes and its values', 'Renames column amount to total'], + }, + { + name: 'that a file in an unknown format cannot be shown', + direction: 'apply', + resource: resource({ key: 'tag:g2', name: 'ops', change: 'added' }), + detail: { + key: 'tag:g2', + type: 'tag', + before: null, + after: null, + revision: { before: null, after: 'invalid' }, + }, + shows: ['The file in the Git branch has an unexpected format.'], + }, + { + name: 'that an unchanged dependency stays as it is', + direction: 'apply', + resource: billingResources.stripe, + shows: ['No changes. The operation keeps this resource as it is.'], + }, + { + name: 'that the content changed after the preview loaded', + direction: 'promote', + resource: resource({ key: 'tag:g1', name: 'finance', change: 'modified' }), + detail: { + key: 'tag:g1', + type: 'tag', + before: { id: 'g1', name: 'fin' }, + after: { id: 'g1', name: 'finance' }, + revision, + }, + stale: true, + shows: ['This resource changed after the preview loaded.'], + }, +]; + +describe('PromotionChangeCard', () => { + it.each(cases)('should show $name', ({ direction, resource: card, detail, stale, shows }) => { + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + props: { + resource: detail && !stale ? { ...card, revision: detail.revision } : card, + graph: buildSelectionGraph(billingPreview(), 'promote'), + direction, + entry: detail ? { status: 'loaded', detail } : undefined, + included: false, + inspected: false, + }, + }); + + for (const text of shows) { + expect(getByTestId('promotion-preview-card')).toHaveTextContent(text); + } + }); + + it('should describe a column removal on Promote without a loss of values', () => { + const { getAllByTestId } = renderComponent({ + pinia: createTestingPinia(), + props: { + resource: { ...billingResources.invoicesTable, revision }, + graph: buildSelectionGraph(billingPreview(), 'promote'), + direction: 'promote', + entry: { + status: 'loaded', + detail: { + key: 'dataTable:t1', + type: 'dataTable', + before: null, + after: null, + revision, + dataTableChanges: [ + { kind: 'remove-column', column: 'notes', type: 'string', destructive: true }, + ], + }, + }, + included: false, + inspected: false, + }, + }); + + // Promote changes the branch only. The values go only when the branch is applied. + expect(getAllByTestId('promotion-preview-data-table-change')[0]).toHaveTextContent( + /^Removes column notes$/, + ); + }); + + it('should collapse a card from its header and open it again when the tree inspects it', async () => { + const { getByTestId, findByText, rerender } = renderComponent({ + pinia: createTestingPinia(), + props: { + resource: billingResources.stripe, + graph: buildSelectionGraph(billingPreview(), 'promote'), + direction: 'apply', + included: false, + inspected: false, + }, + }); + const toggle = getByTestId('promotion-preview-card-toggle'); + + await userEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + await rerender({ inspected: true }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(await findByText(/No changes\./)).toBeInTheDocument(); + }); + + it('should show in the header whether the operation includes a dependency, and which workflows use it', () => { + const props = { + resource: billingResources.stripe, + graph: buildSelectionGraph(billingPreview(), 'promote'), + direction: 'promote' as const, + included: false, + inspected: false, + }; + const included = renderComponent({ + pinia: createTestingPinia(), + props: { ...props, included: true }, + }); + expect(included.getByTestId('promotion-preview-card-status')).toHaveAttribute( + 'aria-label', + 'Included', + ); + expect(included.getByTestId('promotion-preview-card-inclusion')).toHaveTextContent( + 'Used by Invoice reconciliation', + ); + + included.unmount(); + const notIncluded = renderComponent({ pinia: createTestingPinia(), props }); + expect(notIncluded.getByTestId('promotion-preview-card-status')).toHaveAttribute( + 'aria-label', + 'Not included', + ); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCard.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCard.vue new file mode 100644 index 00000000000..2107b52b462 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCard.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCards.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCards.vue new file mode 100644 index 00000000000..dc1f3daeb0f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionChangeCards.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionFieldChanges.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionFieldChanges.vue new file mode 100644 index 00000000000..c5edeaa9ac8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionFieldChanges.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionResourceTree.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionResourceTree.test.ts new file mode 100644 index 00000000000..1f9b03b3acd --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionResourceTree.test.ts @@ -0,0 +1,59 @@ +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { within } from '@testing-library/vue'; + +import { createComponentRenderer } from '@/__tests__/render'; + +import { billingPreview } from '../../__tests__/preview.fixtures'; +import { buildSelectionGraph, resolveInclusion } from '../../promotionPreview.selection'; +import { buildPreviewTree } from '../../promotionPreview.tree'; +import PromotionResourceTree from './PromotionResourceTree.vue'; + +const graph = buildSelectionGraph(billingPreview(), 'promote'); +const tree = buildPreviewTree(billingPreview(), { workflows: 'workflows', group: (id) => id }); +const renderComponent = createComponentRenderer(PromotionResourceTree); + +function render(selectedKeys: string[]) { + const selected = new Set(selectedKeys); + return renderComponent({ + pinia: createTestingPinia(), + props: { + tree, + graph, + selected, + inclusion: resolveInclusion(graph, selected), + includedChanges: 0, + totalChanges: 0, + }, + }); +} + +const row = (getAllByTestId: (id: string) => HTMLElement[], key: string) => { + const match = getAllByTestId('promotion-preview-tree-row').find( + (element) => element.dataset.resourceKey === key, + ); + if (!match) throw new Error(`No tree row for ${key}`); + return within(match); +}; + +describe('PromotionResourceTree', () => { + it('should show required dependencies as checked and read-only, and unused ones as unchecked', () => { + const { getAllByTestId } = render(['workflow:w1']); + + const table = row(getAllByTestId, 'dataTable:t1').getByRole('checkbox'); + expect(table).toBeChecked(); + expect(table).toBeDisabled(); + // "Format currency" is a sub-workflow of the selected workflow. Selecting a caller does not include it. + expect(row(getAllByTestId, 'workflow:w4').getByRole('checkbox')).not.toBeChecked(); + }); + + it('should show a folder as partly selected and toggle the workflows inside it', async () => { + const { getAllByTestId, emitted } = render(['workflow:w1']); + + const folder = row(getAllByTestId, 'folder:f1').getByRole('checkbox'); + expect(folder).toHaveAttribute('data-state', 'indeterminate'); + + await userEvent.click(folder); + expect(emitted('toggleContainer')).toEqual([['folder:f1']]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionResourceTree.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionResourceTree.vue new file mode 100644 index 00000000000..69ac9f0564d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/preview/PromotionResourceTree.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionPreviewLabels.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionPreviewLabels.ts new file mode 100644 index 00000000000..0b32f40f1df --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionPreviewLabels.ts @@ -0,0 +1,30 @@ +import type { PromotionResource } from '@n8n/api-types'; +import { useI18n } from '@n8n/i18n'; + +/** The change label that the tree rows and the change cards share, so both say the same. */ +export function usePromotionPreviewLabels() { + const i18n = useI18n(); + + function changeLabel(resource: PromotionResource): string | undefined { + switch (resource.change) { + case 'added': + return i18n.baseText('promotions.preview.change.added'); + case 'archived': + return i18n.baseText('promotions.preview.change.archived'); + case 'deleted': + return i18n.baseText('promotions.preview.change.deleted'); + case 'modified': + if (resource.previousParentKey !== undefined) { + return i18n.baseText('promotions.preview.change.moved'); + } + if (resource.previousName !== undefined) { + return i18n.baseText('promotions.preview.change.renamed'); + } + return i18n.baseText('promotions.preview.change.modified'); + case 'unchanged': + return undefined; + } + } + + return { changeLabel }; +} diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/module.descriptor.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/module.descriptor.ts index e432ebce8b3..16928c24b65 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/module.descriptor.ts @@ -1,25 +1,50 @@ +import type { PromotionDirection } from '@n8n/api-types'; import { defineFrontendModule } from '@n8n/frontend-module-sdk'; -import { RouterView } from 'vue-router'; +import { useRBACStore } from '@n8n/stores/rbac.store'; +import { RouterView, type RouteLocationNormalized, type RouteRecordRaw } from 'vue-router'; import { VIEWS } from '@/app/constants'; import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled'; import { PROMOTIONS_MODALS } from './modals'; import { + PROMOTION_PREVIEW_VIEWS, PROMOTIONS_ENVIRONMENT_ADD_VIEW, PROMOTIONS_ENVIRONMENT_EDIT_VIEW, + PROMOTIONS_SETTINGS_SCOPES, PROMOTIONS_SETTINGS_VIEW, } from './promotions.constants'; const SettingsView = async () => await import('./views/PromotionsSettingsView.vue'); const EnvironmentView = async () => await import('./views/PromotionEnvironmentView.vue'); +const PreviewView = async () => await import('./views/PromotionPreviewView.vue'); -const SETTINGS_SCOPES = [ - 'gitConnection:list', - 'gitConnection:read', - 'gitConnection:create', - 'gitConnection:update', - 'gitConnection:delete', -] as const; +// Check the rollout flag here because module routes skip custom middleware. +const requirePromotionsEnabled = () => + usePromotionsEnabled().isEnabled.value || { name: VIEWS.HOMEPAGE }; + +/** A project page that shows the changes before the user promotes or applies them. */ +const previewRoute = (direction: PromotionDirection): RouteRecordRaw => ({ + path: `promotions/${direction}`, + name: PROMOTION_PREVIEW_VIEWS[direction], + component: PreviewView, + props: (route: RouteLocationNormalized) => ({ + projectId: String(route.params.projectId), + direction, + }), + meta: { + projectRoute: true, + middleware: ['authenticated', 'custom'], + }, + // Users who cannot promote or apply see no promotions UI, so a direct link leads home. + // The rbac middleware needs project scopes, which these routes do not load. + beforeEnter: () => { + const scope = direction === 'promote' ? 'gitConnection:push' : 'gitConnection:pull'; + const allowed = + usePromotionsEnabled().isEnabled.value && + useRBACStore().hasScope(['gitConnection:read', scope], {}, { mode: 'allOf' }); + return allowed || { name: VIEWS.HOMEPAGE }; + }, +}); export const PromotionsModule = defineFrontendModule({ id: 'promotions', @@ -34,15 +59,14 @@ export const PromotionsModule = defineFrontendModule({ layout: 'settings', middleware: ['authenticated', 'rbac', 'custom'], middlewareOptions: { - rbac: { scope: [...SETTINGS_SCOPES], options: { mode: 'allOf' } }, + rbac: { scope: [...PROMOTIONS_SETTINGS_SCOPES], options: { mode: 'allOf' } }, }, telemetry: { pageCategory: 'settings', getProperties: () => ({ feature: 'promotions' }), }, }, - // Check the rollout flag here because module routes skip custom middleware. - beforeEnter: () => usePromotionsEnabled().isEnabled.value || { name: VIEWS.HOMEPAGE }, + beforeEnter: requirePromotionsEnabled, children: [ { path: '', name: PROMOTIONS_SETTINGS_VIEW, component: SettingsView }, { @@ -59,6 +83,8 @@ export const PromotionsModule = defineFrontendModule({ }, ], }, + previewRoute('promote'), + previewRoute('apply'), ], modals: PROMOTIONS_MODALS, }); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionPreview.content.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionPreview.content.test.ts index dcc109257c1..4be170bac2f 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionPreview.content.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionPreview.content.test.ts @@ -4,6 +4,7 @@ import { billingResources, resource } from './__tests__/preview.fixtures'; import { describeDataTable, describeFieldChanges, + describeWorkflowFieldChanges, sideState, type FieldContext, } from './promotionPreview.content'; @@ -105,22 +106,39 @@ describe('promotion preview content', () => { ]); }); - it('should flag a removed column as data loss and list the columns of a new table', () => { - const modified = describeDataTable( - { - key: 'dataTable:t1', - type: 'dataTable', - before: null, - after: null, - revision: { before: 'b', after: 'a' }, - dataTableChanges: [ - { kind: 'rename-column', from: 'amount', to: 'total', destructive: false }, - { kind: 'remove-column', column: 'notes', type: 'string', destructive: true }, - ], - }, - billingResources.invoicesTable, - ); - expect(modified.hasDataLoss).toBe(true); + it('should show the rename and move of a workflow from the preview resource', () => { + // The workflow was renamed and moved on this instance. Apply restores the branch state. + const workflow = resource({ + key: 'workflow:w1', + name: 'Invoice sync', + previousName: 'Invoice sync (local)', + parentKey: 'project:p1', + previousParentKey: 'folder:f1', + change: 'modified', + }); + + expect( + describeWorkflowFieldChanges(context({ direction: 'apply', resource: workflow })), + ).toEqual([ + { field: 'name', before: 'Invoice sync (local)', after: 'Invoice sync' }, + { field: 'location', before: 'Payments', after: 'Billing' }, + ]); + }); + + const changedTable: Extract = { + key: 'dataTable:t1', + type: 'dataTable', + before: null, + after: null, + revision: { before: 'b', after: 'a' }, + dataTableChanges: [ + { kind: 'rename-column', from: 'amount', to: 'total', destructive: false }, + { kind: 'remove-column', column: 'notes', type: 'string', destructive: true }, + ], + }; + + it('should list the column changes of a changed table and the columns of a new table', () => { + const modified = describeDataTable(changedTable, billingResources.invoicesTable, 'apply'); expect(modified.changes).toHaveLength(2); const added = describeDataTable( @@ -139,8 +157,8 @@ describe('promotion preview content', () => { revision: { before: null, after: 'a' }, }, resource({ key: 'dataTable:t2', name: 'Refunds', change: 'added' }), + 'apply', ); expect(added.addedColumns.map((column) => column.name)).toEqual(['amount', 'reason']); - expect(added.hasDataLoss).toBe(false); }); }); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionPreview.content.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionPreview.content.ts index 97053acf181..140da697294 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionPreview.content.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionPreview.content.ts @@ -120,18 +120,37 @@ export function describeFieldChanges( .map((field) => ({ field, before: before[field], after: after[field] })); } +/** The page loads no workflow files, so a workflow card shows the rename and move of the resource. */ +export function describeWorkflowFieldChanges(context: FieldContext): FieldChange[] { + const { resource } = context; + const changes: FieldChange[] = []; + if (resource.previousName !== undefined) { + changes.push({ field: 'name', before: resource.previousName, after: resource.name }); + } + if (resource.previousParentKey !== undefined) { + const before = locationOf(context, 'before').location; + const after = locationOf(context, 'after').location; + changes.push({ field: 'location', before, after }); + } + return changes; +} + export type DataTableDescription = { /** Column changes of a table that exists on both sides. */ changes: PromotionDataTableChange[]; + /** + * True when the card warns about changes that remove data. Promote cannot know the table on the + * instance that applies the branch, so only Apply warns. + */ + warnsAboutDataLoss: boolean; /** The columns of a table that the operation creates. */ addedColumns: SerializedDataTableColumn[]; - /** True when a change removes stored values, such as a removed column or a new column type. */ - hasDataLoss: boolean; }; export function describeDataTable( detail: Extract, resource: PromotionResource, + direction: PromotionDirection, ): DataTableDescription { const changes = resource.change === 'modified' ? (detail.dataTableChanges ?? []) : []; const after = sideState(detail, 'after'); @@ -139,7 +158,7 @@ export function describeDataTable( resource.change === 'added' && after.kind === 'content' ? [...after.content.columns].sort((a, b) => a.index - b.index) : []; - return { changes, addedColumns, hasDataLoss: changes.some((change) => change.destructive) }; + return { changes, warnsAboutDataLoss: direction === 'apply', addedColumns }; } /** True when the files changed after the preview loaded, so the card can be out of date. */ diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.constants.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.constants.ts index 116eb64eec4..9f24a17e3dd 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.constants.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.constants.ts @@ -4,5 +4,19 @@ export const PROMOTIONS_SETTINGS_VIEW = 'PromotionsSettings'; export const PROMOTIONS_ENVIRONMENT_ADD_VIEW = 'PromotionsEnvironmentAdd'; export const PROMOTIONS_ENVIRONMENT_EDIT_VIEW = 'PromotionsEnvironmentEdit'; +/** The scopes that the promotion settings page needs. */ +export const PROMOTIONS_SETTINGS_SCOPES = [ + 'gitConnection:list', + 'gitConnection:read', + 'gitConnection:create', + 'gitConnection:update', + 'gitConnection:delete', +] as const; + +export const PROMOTION_PREVIEW_VIEWS = { + promote: 'PromotionPreviewPromote', + apply: 'PromotionPreviewApply', +} as const; + /** The details endpoint accepts at most this many keys in one request. */ export const PROMOTION_DETAILS_BATCH_SIZE = 50; diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.test.ts new file mode 100644 index 00000000000..07f4fd33980 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.test.ts @@ -0,0 +1,154 @@ +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { waitFor, within } from '@testing-library/vue'; +import { createMemoryHistory, createRouter } from 'vue-router'; + +import { createComponentRenderer } from '@/__tests__/render'; +import { waitAllPromises } from '@/__tests__/utils'; +import { VIEWS } from '@/app/constants'; + +import { billingPreview } from '../__tests__/preview.fixtures'; +import { invalidateInstancePromotionConnection } from '../composables/useInstancePromotionConnection'; +import * as api from '../promotions.api'; +import * as settingsApi from '../promotionsSettings.api'; +import PromotionPreviewView from './PromotionPreviewView.vue'; + +vi.mock('../promotions.api'); +vi.mock('../promotionsSettings.api'); +// The projects store loads the project of the route on its own. +vi.mock('@/features/collaboration/projects/projects.api'); + +const stub = { template: '
' }; +const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/projects/:projectId/workflows', name: VIEWS.PROJECTS_WORKFLOWS, component: stub }, + { path: '/projects/:projectId/promotions/:direction', component: stub }, + ], +}); +const renderComponent = createComponentRenderer(PromotionPreviewView, { + global: { plugins: [router] }, +}); + +const environment = (direction: 'promote' | 'apply') => + ({ + id: `environment-${direction}`, + scope: 'instance', + direction, + remote: { schemaVersion: 1, remoteUrl: 'git@github.com:acme/workflows.git' }, + }) as unknown as settingsApi.PromotionEnvironmentSummary; + +async function render(path = '/projects/p1/promotions/promote', direction = 'promote' as const) { + await router.push(path); + const result = renderComponent({ + pinia: createTestingPinia(), + props: { projectId: 'p1', direction }, + }); + await waitAllPromises(); + return result; +} + +const treeRow = (getAllByTestId: (id: string) => HTMLElement[], key: string) => { + const row = getAllByTestId('promotion-preview-tree-row').find( + (element) => element.dataset.resourceKey === key, + ); + if (!row) throw new Error(`No tree row for ${key}`); + return within(row); +}; + +describe('PromotionPreviewView', () => { + beforeEach(() => { + invalidateInstancePromotionConnection(); + Element.prototype.scrollIntoView = vi.fn(); + vi.mocked(settingsApi.fetchPromotionEnvironments).mockResolvedValue([environment('promote')]); + vi.mocked(api.getPromotionPreview).mockResolvedValue(billingPreview()); + vi.mocked(api.getPromotionResourceDetails).mockReset(); + vi.mocked(api.getPromotionResourceDetails).mockResolvedValue({ details: [], missingKeys: [] }); + }); + + it('should keep the selection while the user inspects cards', async () => { + const { getAllByTestId, getByTestId } = await render(); + expect(getByTestId('promotion-preview-counts')).toHaveTextContent('5 of 5'); + + await userEvent.click(treeRow(getAllByTestId, 'workflow:w1').getByRole('checkbox')); + await userEvent.click(treeRow(getAllByTestId, 'dataTable:t1').getByText('Invoices')); + + expect(getByTestId('promotion-preview-counts')).toHaveTextContent('4 of 5'); + // The tab shows the total, whatever the selection. + expect(getByTestId('promotion-preview-tabs')).toHaveTextContent(/Changes\s*5/); + expect(treeRow(getAllByTestId, 'workflow:w1').getByRole('checkbox')).not.toBeChecked(); + expect(router.currentRoute.value.query.resource).toBe('dataTable:t1'); + }); + + it('should load the content of changed resources other than workflows with the preview', async () => { + await render(); + + expect(api.getPromotionResourceDetails).toHaveBeenCalledTimes(1); + expect(vi.mocked(api.getPromotionResourceDetails).mock.calls[0][3].keys).toEqual([ + 'folder:f1', + 'dataTable:t1', + 'variable:global:API_URL', + ]); + }); + + it('should open the card of a direct link', async () => { + const { getAllByTestId } = await render('/projects/p1/promotions/promote?resource=tag:g1'); + + const card = getAllByTestId('promotion-preview-card').find( + (element) => element.dataset.resourceKey === 'tag:g1', + ); + expect(card?.scrollIntoView).toHaveBeenCalled(); + }); + + it('should scroll to the card again when the same item is inspected twice', async () => { + const { getAllByTestId } = await render(); + const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView); + + await userEvent.click(treeRow(getAllByTestId, 'tag:g1').getByText('finance')); + await userEvent.click(treeRow(getAllByTestId, 'tag:g1').getByText('finance')); + + expect(scrollIntoView).toHaveBeenCalledTimes(2); + }); + + it('should explain that the direction is not set up when the instance has no environment for it', async () => { + vi.mocked(settingsApi.fetchPromotionEnvironments).mockResolvedValue([environment('apply')]); + + const { getByTestId } = await render(); + + expect(getByTestId('promotion-preview-disconnected')).toHaveTextContent("Promote isn't set up"); + }); + + it('should offer a retry when the preview fails to load', async () => { + vi.mocked(api.getPromotionPreview).mockRejectedValueOnce(new Error('offline')); + + const { getByTestId, getByRole } = await render(); + expect(getByTestId('promotion-preview-error')).toHaveTextContent("Couldn't load the changes"); + + await userEvent.click(getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(getByTestId('promotion-preview-tree')).toBeInTheDocument()); + }); + + it('should keep the changes and warn when a refresh fails', async () => { + const { getByTestId, getByText } = await render(); + vi.mocked(api.getPromotionPreview).mockRejectedValueOnce(new Error('offline')); + + await userEvent.click(getByTestId('promotion-preview-refresh')); + await waitAllPromises(); + + expect(getByText(/Couldn't refresh the preview/)).toBeInTheDocument(); + expect(getByTestId('promotion-preview-tree')).toBeInTheDocument(); + }); + + it('should show the error when the refresh of an empty preview fails', async () => { + vi.mocked(api.getPromotionPreview).mockResolvedValueOnce(billingPreview({ resources: [] })); + const { getByTestId } = await render(); + expect(getByTestId('promotion-preview-empty')).toHaveTextContent('Nothing to promote'); + vi.mocked(api.getPromotionPreview).mockRejectedValueOnce(new Error('offline')); + + await userEvent.click(getByTestId('promotion-preview-refresh')); + await waitAllPromises(); + + // "Nothing to promote" could be out of date, so the page asks for a retry. + expect(getByTestId('promotion-preview-error')).toHaveTextContent("Couldn't load the changes"); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.vue new file mode 100644 index 00000000000..1d87e35a9a3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.vue @@ -0,0 +1,371 @@ + + + + +