mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(editor): Redesign the migration rule detail list (no-changelog) (#40654)
Co-authored-by: Michael Siega <[email protected]> Co-authored-by: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Michael Siega
Claude Fable 5.1
parent
c92141d141
commit
ff7ef60841
@@ -416,4 +416,66 @@ describe('UserSelect', () => {
|
||||
expect(optionIds).not.toContain('user-select-option-id-u1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the name and the email of the selected user', async () => {
|
||||
const { getByRole } = renderComponent({
|
||||
props: { users: sampleUsers, modelValue: 'u1' },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole('combobox')).toHaveValue('Alice Smith ([email protected])'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show only the name of the selected user when hideEmailInLabel is set', async () => {
|
||||
const { getByRole } = renderComponent({
|
||||
props: { users: sampleUsers, modelValue: 'u1', hideEmailInLabel: true },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByRole('combobox')).toHaveValue('Alice Smith'));
|
||||
});
|
||||
|
||||
it('should show the email of a selected user without a name when hideEmailInLabel is set', async () => {
|
||||
const { getByRole } = renderComponent({
|
||||
props: { users: sampleUsers, modelValue: 'u5', hideEmailInLabel: true },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByRole('combobox')).toHaveValue('[email protected]'));
|
||||
});
|
||||
|
||||
it('should show the avatar of the selected user when showAvatar is set', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { users: sampleUsers, modelValue: 'u1', showAvatar: true },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('user-select-avatar')).toHaveTextContent('AS'));
|
||||
});
|
||||
|
||||
it('should take the avatar initials from the full name when the user has no first or last name', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
users: [{ id: 'u9', email: '[email protected]', fullName: 'Nina Park' }],
|
||||
modelValue: 'u9',
|
||||
showAvatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('user-select-avatar')).toHaveTextContent('NP'));
|
||||
});
|
||||
|
||||
it('should not show an avatar by default', () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: { users: sampleUsers, modelValue: 'u1' },
|
||||
});
|
||||
|
||||
expect(queryByTestId('user-select-avatar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should mark the select as borderless when borderless is set', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { users: sampleUsers, borderless: true },
|
||||
});
|
||||
|
||||
expect(getByTestId('user-select-trigger')).toHaveClass('borderless');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import type { IUser, SelectSize } from '../../types';
|
||||
import N8nAvatar from '../N8nAvatar';
|
||||
import N8nOption from '../N8nOption';
|
||||
import N8nSelect from '../N8nSelect';
|
||||
import N8nUserInfo from '../N8nUserInfo';
|
||||
@@ -19,6 +20,12 @@ interface UserSelectProps {
|
||||
loading?: boolean;
|
||||
/** Set to false to keep the dropdown in the local stacking context, e.g. inside N8nDialog */
|
||||
teleported?: boolean;
|
||||
/** Show only the name of the selected user. The options still show the email. */
|
||||
hideEmailInLabel?: boolean;
|
||||
/** Show the avatar of the selected user before the name. */
|
||||
showAvatar?: boolean;
|
||||
/** Show the border only on hover and focus, for a picker inside a table row or a list. */
|
||||
borderless?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<UserSelectProps>(), {
|
||||
@@ -29,6 +36,9 @@ const props = withDefaults(defineProps<UserSelectProps>(), {
|
||||
remote: false,
|
||||
loading: false,
|
||||
teleported: true,
|
||||
hideEmailInLabel: false,
|
||||
showAvatar: false,
|
||||
borderless: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -91,8 +101,22 @@ const setFilter = (value: string = '') => {
|
||||
const onBlur = () => emit('blur');
|
||||
const onFocus = () => emit('focus');
|
||||
|
||||
const getLabel = (user: IUser) =>
|
||||
(!user.fullName ? user.email : `${user.fullName} (${user.email})`) ?? '';
|
||||
const selectedUser = computed(() => props.users.find((user) => user.id === props.modelValue));
|
||||
|
||||
// A user can have only a full name. The avatar then takes its initials from it.
|
||||
const avatarNames = computed(() => {
|
||||
const user = selectedUser.value;
|
||||
if (!user) return {};
|
||||
if (user.firstName || user.lastName) {
|
||||
return { firstName: user.firstName, lastName: user.lastName };
|
||||
}
|
||||
return { firstName: user.fullName };
|
||||
});
|
||||
|
||||
const getLabel = (user: IUser) => {
|
||||
if (!user.fullName) return user.email ?? '';
|
||||
return props.hideEmailInLabel ? user.fullName : `${user.fullName} (${user.email})`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,11 +134,19 @@ const getLabel = (user: IUser) =>
|
||||
:size="size"
|
||||
:remote="remote"
|
||||
:loading="loading"
|
||||
:class="{ [$style.withAvatar]: showAvatar, [$style.borderless]: borderless }"
|
||||
@blur="onBlur"
|
||||
@focus="onFocus"
|
||||
>
|
||||
<template v-if="$slots.prefix" #prefix>
|
||||
<slot name="prefix" />
|
||||
<template v-if="showAvatar || $slots.prefix" #prefix>
|
||||
<N8nAvatar
|
||||
v-if="showAvatar"
|
||||
size="xsmall"
|
||||
:first-name="avatarNames.firstName"
|
||||
:last-name="avatarNames.lastName"
|
||||
data-test-id="user-select-avatar"
|
||||
/>
|
||||
<slot v-else name="prefix" />
|
||||
</template>
|
||||
<N8nOption
|
||||
v-for="user in sortedUsers"
|
||||
@@ -136,6 +168,16 @@ const getLabel = (user: IUser) =>
|
||||
--select--option--line-height: 1;
|
||||
}
|
||||
|
||||
/* The select reserves room for an icon. The avatar is wider, so the text starts after it. */
|
||||
.withAvatar :global(.el-select .el-input--prefix .el-input__inner) {
|
||||
padding-left: calc(var(--spacing--2xs) * 2 + var(--spacing--md));
|
||||
}
|
||||
|
||||
.borderless:not(:hover, :focus-within) :global(.el-input__inner) {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:root .limitPopperWidth {
|
||||
width: 0;
|
||||
|
||||
|
||||
@@ -4630,14 +4630,15 @@
|
||||
"settings.migrationReport.detail.table.unassigned": "Unassigned",
|
||||
"settings.migrationReport.detail.owner.error.title": "Could not change the owner",
|
||||
"settings.migrationReport.detail.owner.search.error.title": "Could not load users",
|
||||
"settings.migrationReport.detail.table.status": "Status",
|
||||
"settings.migrationReport.detail.table.active": "Active",
|
||||
"settings.migrationReport.detail.table.deactivated": "Deactivated",
|
||||
"settings.migrationReport.detail.table.nodesAffected": "Nodes affected",
|
||||
"settings.migrationReport.detail.table.numberOfExecutions": "Number of executions",
|
||||
"settings.migrationReport.detail.table.lastExecuted": "Last executed",
|
||||
"settings.migrationReport.detail.table.lastUpdated": "Last updated",
|
||||
"settings.migrationReport.detail.table.never": "Never",
|
||||
"settings.migrationReport.detail.table.published": "Published",
|
||||
"settings.migrationReport.detail.table.notPublished": "Not published",
|
||||
"settings.migrationReport.detail.table.updated": "updated {time}",
|
||||
"settings.migrationReport.detail.table.usage": "Usage",
|
||||
"settings.migrationReport.detail.table.runs": "{count} run | {count} runs",
|
||||
"settings.migrationReport.detail.table.lastRun": "last run {time}",
|
||||
"settings.migrationReport.detail.table.neverRun": "never run",
|
||||
"settings.migrationReport.detail.table.open": "Open",
|
||||
"settings.migrationReport.detail.table.state": "State",
|
||||
"settings.migrationReport.detail.state.open": "Open",
|
||||
"settings.migrationReport.detail.state.open.description": "Still needs a fix before upgrading",
|
||||
|
||||
+79
-79
@@ -42,7 +42,7 @@ vi.mock('@n8n/composables/useToast', async (importOriginal) => ({
|
||||
useToast: () => ({ showError }),
|
||||
}));
|
||||
|
||||
// The test renderer has no router. The row click needs `resolve` to build the workflow URL.
|
||||
// The test renderer has no router. The open link needs `resolve` to build the workflow URL.
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
useRouter: () => ({ push: vi.fn(), resolve: resolveRoute }),
|
||||
@@ -206,23 +206,11 @@ describe('MigrationRuleDetail', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Status', { selector: 'th' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Nodes affected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Number of executions/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last executed/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last updated/)).toBeInTheDocument();
|
||||
expect(screen.getByText('State', { selector: 'th' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the state right after the affected nodes', async () => {
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
const titles = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim());
|
||||
expect(titles.indexOf('State')).toBe(titles.indexOf('Nodes affected') + 1);
|
||||
// The sorted column shows a direction arrow after its title.
|
||||
const titles = screen
|
||||
.getAllByRole('columnheader')
|
||||
.map((th) => th.textContent?.replace(/[↑↓]/g, '').trim());
|
||||
expect(titles).toEqual(['Name', 'Nodes affected', 'Usage', 'Owner', 'State', '']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -244,14 +232,6 @@ describe('MigrationRuleDetail', () => {
|
||||
await userEvent.click(within(listbox).getByText(label));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(window.open).mockRestore();
|
||||
});
|
||||
|
||||
it('should show the state of each finding', async () => {
|
||||
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(
|
||||
createMockRuleResult({
|
||||
@@ -370,27 +350,6 @@ describe('MigrationRuleDetail', () => {
|
||||
expect(isStateDisabled('Test Workflow 2')).toBe(false);
|
||||
expect(screen.getByText('1 affected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not open the workflow when the state is changed', async () => {
|
||||
vi.mocked(breakingChangesApi.updateFindingStatus).mockResolvedValue();
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
await screen.findByText('2 affected');
|
||||
|
||||
await selectState('Test Workflow 1', "Won't fix");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(breakingChangesApi.updateFindingStatus).toHaveBeenCalled();
|
||||
});
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open the workflow when the row is clicked', async () => {
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
await userEvent.click(await screen.findByText('Test Workflow 1'));
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith('/workflow/workflow-1', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
@@ -492,7 +451,7 @@ describe('MigrationRuleDetail', () => {
|
||||
});
|
||||
// No picker was opened yet, so no member search ran.
|
||||
expect(usersApi.getUsers).not.toHaveBeenCalled();
|
||||
expect(screen.getByDisplayValue('Ada Lovelace ([email protected])')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Ada Lovelace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a plain label for a user without the migrate scope', async () => {
|
||||
@@ -540,7 +499,7 @@ describe('MigrationRuleDetail', () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Grace Hopper ([email protected])')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Grace Hopper')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -590,7 +549,7 @@ describe('MigrationRuleDetail', () => {
|
||||
);
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Ada Lovelace ([email protected])')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Ada Lovelace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The clear icon replaces the caret while the trigger is hovered and has a value.
|
||||
@@ -610,7 +569,7 @@ describe('MigrationRuleDetail', () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Grace Hopper ([email protected])')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Grace Hopper')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -638,31 +597,75 @@ describe('MigrationRuleDetail', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should display workflow execution counts', async () => {
|
||||
renderComponent({
|
||||
props: {
|
||||
migrationRuleId: 'rule-1',
|
||||
},
|
||||
});
|
||||
it('should show the publish state of each workflow', async () => {
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Published/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Not published/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "Never" for workflows never executed', async () => {
|
||||
renderComponent({
|
||||
props: {
|
||||
migrationRuleId: 'rule-1',
|
||||
},
|
||||
});
|
||||
it('should display workflow run counts', async () => {
|
||||
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(
|
||||
createMockRuleResult({
|
||||
affectedWorkflows: [
|
||||
mockWorkflowWithIssue,
|
||||
{ ...mockWorkflowWithMultipleNodes, numberOfExecutions: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
expect(screen.getByText('100 runs')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 run')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "never run" for workflows never executed', async () => {
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('never run')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark the last run of a workflow without a run in the last 60 days', async () => {
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(
|
||||
createMockRuleResult({
|
||||
affectedWorkflows: [
|
||||
{ ...mockWorkflowWithIssue, lastExecutedAt: new Date(Date.now() - 61 * day) },
|
||||
{
|
||||
...mockWorkflowWithMultipleNodes,
|
||||
lastExecutedAt: new Date(Date.now() - 59 * day),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
const getLastRun = (workflowName: string) => {
|
||||
const row = screen.getByText(workflowName).closest('tr');
|
||||
if (!row) throw new Error('Row not found');
|
||||
return within(row).getByTestId('migration-workflow-last-run');
|
||||
};
|
||||
await waitFor(() => {
|
||||
expect(getLastRun('Test Workflow 1')).toHaveClass('danger');
|
||||
expect(getLastRun('Test Workflow 2')).not.toHaveClass('danger');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not mark a workflow that never ran', async () => {
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
const lastRun = await screen.findByText('never run');
|
||||
expect(lastRun.closest('[data-test-id="migration-workflow-last-run"]')).not.toHaveClass(
|
||||
'danger',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display multiple nodes with comma separation', async () => {
|
||||
renderComponent({
|
||||
props: {
|
||||
@@ -677,20 +680,17 @@ describe('MigrationRuleDetail', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('row interaction', () => {
|
||||
it('should have clickable rows with proper styling', async () => {
|
||||
renderComponent({
|
||||
props: {
|
||||
migrationRuleId: 'rule-1',
|
||||
},
|
||||
});
|
||||
describe('open link', () => {
|
||||
it('should link each row to its workflow in a new tab', async () => {
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Workflow 1')).toBeInTheDocument();
|
||||
});
|
||||
const [link] = await screen.findAllByTestId('migration-workflow-open-link');
|
||||
|
||||
const row = screen.getByText('Test Workflow 1').closest('tr');
|
||||
expect(row).toHaveClass('clickableRow');
|
||||
expect(link).toHaveAttribute('href', '/workflow/workflow-1');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(resolveRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ params: { workflowId: 'workflow-1' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -747,7 +747,7 @@ describe('MigrationRuleDetail', () => {
|
||||
const rows = screen.getAllByRole('row');
|
||||
const firstDataRow = rows[1]; // Skip header row
|
||||
expect(firstDataRow.textContent).toContain('Test Workflow 1');
|
||||
expect(firstDataRow.textContent).toContain('100');
|
||||
expect(firstDataRow.textContent).toContain('100 runs');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+164
-88
@@ -2,7 +2,7 @@
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
import ResourceFiltersDropdown from '@/app/components/forms/ResourceFiltersDropdown.vue';
|
||||
import { getDebounceTime } from '@n8n/composables/useDebounce';
|
||||
import { DEBOUNCE_TIME, MIGRATE_WORKFLOW_MODAL_KEY, VIEWS } from '@/app/constants';
|
||||
import { DEBOUNCE_TIME, MIGRATE_WORKFLOW_MODAL_KEY, TIME, VIEWS } from '@/app/constants';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import type {
|
||||
BreakingChangeRuleDetailResult,
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { getUsers } from '@n8n/rest-api-client/api/users';
|
||||
import {
|
||||
N8nAvatar,
|
||||
N8nBadge,
|
||||
N8nButton,
|
||||
N8nDataTableServer,
|
||||
@@ -38,6 +39,7 @@ import { useAsyncState, useDebounceFn } from '@vueuse/core';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { I18nT } from 'vue-i18n';
|
||||
import FindingStateSelect from './components/FindingStateSelect.vue';
|
||||
import ImpactTag from './components/ImpactTag.vue';
|
||||
|
||||
@@ -195,6 +197,9 @@ function ownerLabel(workflow: AffectedWorkflow): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate needs the same scope as a state change.
|
||||
const showMigrate = computed(() => state.value.migratable && canMigrate.value);
|
||||
|
||||
const tableHeaders = computed<Array<TableHeader<AffectedWorkflow>>>(() => {
|
||||
const headers: Array<TableHeader<AffectedWorkflow>> = [
|
||||
{
|
||||
@@ -202,61 +207,56 @@ const tableHeaders = computed<Array<TableHeader<AffectedWorkflow>>>(() => {
|
||||
key: 'name',
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.owner'),
|
||||
key: 'owner',
|
||||
value: ownerLabel,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.status'),
|
||||
key: 'active',
|
||||
value: (row: AffectedWorkflow) =>
|
||||
row.active
|
||||
? i18n.baseText('settings.migrationReport.detail.table.active')
|
||||
: i18n.baseText('settings.migrationReport.detail.table.deactivated'),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.nodesAffected'),
|
||||
key: 'issues',
|
||||
width: 240,
|
||||
width: 200,
|
||||
disableSort: true,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.state'),
|
||||
key: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.numberOfExecutions'),
|
||||
// The cell shows the last run under the run count. It sorts by the run count.
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.usage'),
|
||||
key: 'numberOfExecutions',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.lastExecuted'),
|
||||
key: 'lastExecutedAt',
|
||||
width: 120,
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.owner'),
|
||||
key: 'owner',
|
||||
value: ownerLabel,
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.lastUpdated'),
|
||||
key: 'lastUpdatedAt',
|
||||
width: 120,
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.state'),
|
||||
key: 'status',
|
||||
width: 140,
|
||||
},
|
||||
];
|
||||
|
||||
if (state.value.migratable && canMigrate.value) {
|
||||
headers.push({
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
value: () => '',
|
||||
width: 40,
|
||||
width: showMigrate.value ? 160 : 80,
|
||||
disableSort: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
return headers;
|
||||
});
|
||||
|
||||
// A workflow without a run in this period is probably not used any more.
|
||||
const STALE_WORKFLOW_AFTER = 60 * TIME.DAY;
|
||||
|
||||
function isStale(workflow: AffectedWorkflow): boolean {
|
||||
if (!workflow.lastExecutedAt) return false;
|
||||
return Date.now() - new Date(workflow.lastExecutedAt).getTime() > STALE_WORKFLOW_AFTER;
|
||||
}
|
||||
|
||||
function runCountLabel(workflow: AffectedWorkflow): string {
|
||||
return i18n.baseText('settings.migrationReport.detail.table.runs', {
|
||||
adjustToNumber: workflow.numberOfExecutions,
|
||||
interpolate: { count: workflow.numberOfExecutions.toLocaleString(rootStore.defaultLocale) },
|
||||
});
|
||||
}
|
||||
|
||||
// Workflows successfully migrated this session (the row shows a "Migrated" state).
|
||||
const migratedWorkflowIds = ref<Set<string>>(new Set());
|
||||
|
||||
@@ -327,14 +327,8 @@ async function onFindingStatusChange(
|
||||
}
|
||||
}
|
||||
|
||||
function handleRowClick(_event: MouseEvent, { item }: { item: AffectedWorkflow }) {
|
||||
window.open(
|
||||
router.resolve({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { workflowId: item.id },
|
||||
}).href,
|
||||
'_blank',
|
||||
);
|
||||
function workflowUrl(workflow: AffectedWorkflow): string {
|
||||
return router.resolve({ name: VIEWS.WORKFLOW, params: { workflowId: workflow.id } }).href;
|
||||
}
|
||||
|
||||
const sortBy = ref([{ id: 'numberOfExecutions', desc: true }]);
|
||||
@@ -533,49 +527,92 @@ const sortedWorkflows = computed(() => {
|
||||
:items="sortedWorkflows"
|
||||
:items-length="sortedWorkflows.length"
|
||||
:headers="tableHeaders"
|
||||
:row-props="{ class: $style.clickableRow }"
|
||||
:loading="isLoading"
|
||||
@click:row="handleRowClick"
|
||||
>
|
||||
<template #[`item.issues`]="{ item }">
|
||||
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
|
||||
<template v-for="(issue, index) in item.issues" :key="issue.nodeId">
|
||||
<N8nLink
|
||||
theme="text"
|
||||
:to="`/workflow/${item.id}/${issue.nodeId}`"
|
||||
new-window
|
||||
@click.capture.stop
|
||||
<template #[`item.name`]="{ item }">
|
||||
<div :class="$style.cellStack">
|
||||
<N8nText color="text-dark" :class="$style.truncate" :title="item.name">
|
||||
{{ item.name }}
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{
|
||||
item.active
|
||||
? i18n.baseText('settings.migrationReport.detail.table.published')
|
||||
: i18n.baseText('settings.migrationReport.detail.table.notPublished')
|
||||
}}
|
||||
·
|
||||
<I18nT
|
||||
keypath="settings.migrationReport.detail.table.updated"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #time>
|
||||
<TimeAgo :date="item.lastUpdatedAt.toString()" />
|
||||
</template>
|
||||
</I18nT>
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.issues`]="{ item }">
|
||||
<div :class="[$style.truncate, $style.nodeNames]">
|
||||
<template v-for="(issue, index) in item.issues" :key="issue.nodeId">
|
||||
<N8nLink theme="text" :to="`/workflow/${item.id}/${issue.nodeId}`" new-window>
|
||||
{{ issue.nodeName }}
|
||||
</N8nLink>
|
||||
<template v-if="index < item.issues.length - 1">, </template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.owner`]="{ item }">
|
||||
<div v-if="canMigrate" @click.stop>
|
||||
<N8nUserSelect
|
||||
<template #[`item.numberOfExecutions`]="{ item }">
|
||||
<div :class="$style.cellStack">
|
||||
<N8nText color="text-dark">{{ runCountLabel(item) }}</N8nText>
|
||||
<N8nText
|
||||
size="small"
|
||||
:users="ownerOptionsFor(item)"
|
||||
:model-value="item.owner?.id ?? ''"
|
||||
:placeholder="i18n.baseText('settings.migrationReport.detail.table.unassigned')"
|
||||
remote
|
||||
:remote-method="searchMembers"
|
||||
:loading="isLoadingUsers"
|
||||
clearable
|
||||
data-test-id="migration-owner-select"
|
||||
@focus="onOwnerPickerFocus(item)"
|
||||
@update:model-value="(userId: string) => onOwnerChange(item, userId)"
|
||||
/>
|
||||
:color="isStale(item) ? 'danger' : 'text-light'"
|
||||
data-test-id="migration-workflow-last-run"
|
||||
>
|
||||
<I18nT
|
||||
v-if="item.lastExecutedAt"
|
||||
keypath="settings.migrationReport.detail.table.lastRun"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #time>
|
||||
<TimeAgo :date="item.lastExecutedAt.toString()" />
|
||||
</template>
|
||||
</I18nT>
|
||||
<template v-else>
|
||||
{{ i18n.baseText('settings.migrationReport.detail.table.neverRun') }}
|
||||
</template>
|
||||
</N8nText>
|
||||
</div>
|
||||
<span v-else>{{ ownerLabel(item) }}</span>
|
||||
</template>
|
||||
<template #[`item.lastExecutedAt`]="{ item }">
|
||||
<TimeAgo v-if="item.lastExecutedAt" :date="item.lastExecutedAt.toString()" />
|
||||
<span v-else>{{ i18n.baseText('settings.migrationReport.detail.table.never') }}</span>
|
||||
</template>
|
||||
<template #[`item.lastUpdatedAt`]="{ item }">
|
||||
<TimeAgo :date="item.lastUpdatedAt.toString()" />
|
||||
<template #[`item.owner`]="{ item }">
|
||||
<N8nUserSelect
|
||||
v-if="canMigrate"
|
||||
size="small"
|
||||
:users="ownerOptionsFor(item)"
|
||||
hide-email-in-label
|
||||
show-avatar
|
||||
borderless
|
||||
:model-value="item.owner?.id ?? ''"
|
||||
:placeholder="i18n.baseText('settings.migrationReport.detail.table.unassigned')"
|
||||
remote
|
||||
:remote-method="searchMembers"
|
||||
:loading="isLoadingUsers"
|
||||
clearable
|
||||
data-test-id="migration-owner-select"
|
||||
@focus="onOwnerPickerFocus(item)"
|
||||
@update:model-value="(userId: string) => onOwnerChange(item, userId)"
|
||||
/>
|
||||
<div v-else :class="$style.ownerLabel">
|
||||
<N8nAvatar
|
||||
size="xsmall"
|
||||
:first-name="item.owner?.firstName"
|
||||
:last-name="item.owner?.lastName"
|
||||
/>
|
||||
<span :class="$style.truncate">{{ ownerLabel(item) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.status`]="{ item }">
|
||||
<FindingStateSelect
|
||||
@@ -584,20 +621,32 @@ const sortedWorkflows = computed(() => {
|
||||
!canMigrate || savingWorkflowIds.has(item.id) || migratedWorkflowIds.has(item.id)
|
||||
"
|
||||
@update:model-value="onFindingStatusChange(item, $event)"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<N8nText v-if="migratedWorkflowIds.has(item.id)" color="text-light" size="small">
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.migrated') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
v-else
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.migrationReport.detail.migrate.button')"
|
||||
data-test-id="migrate-workflow-button"
|
||||
@click.stop="openMigrateModal(item)"
|
||||
/>
|
||||
<div :class="$style.actions">
|
||||
<template v-if="showMigrate">
|
||||
<N8nText v-if="migratedWorkflowIds.has(item.id)" color="text-light" size="small">
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.migrated') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
v-else
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.migrationReport.detail.migrate.button')"
|
||||
data-test-id="migrate-workflow-button"
|
||||
@click="openMigrateModal(item)"
|
||||
/>
|
||||
</template>
|
||||
<N8nLink
|
||||
theme="text"
|
||||
:to="workflowUrl(item)"
|
||||
new-window
|
||||
:class="$style.NoLineBreak"
|
||||
data-test-id="migration-workflow-open-link"
|
||||
>
|
||||
{{ i18n.baseText('settings.migrationReport.detail.table.open') }} ↗
|
||||
</N8nLink>
|
||||
</div>
|
||||
</template>
|
||||
</N8nDataTableServer>
|
||||
</N8nSettingsLayout>
|
||||
@@ -612,8 +661,35 @@ const sortedWorkflows = computed(() => {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.clickableRow {
|
||||
cursor: pointer;
|
||||
.cellStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nodeNames {
|
||||
font-family: var(--font-family--monospace);
|
||||
}
|
||||
|
||||
.ownerLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.filterControls {
|
||||
|
||||
Reference in New Issue
Block a user