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:
Alex Grozav
2026-10-09 12:50:38 +00:00
committed by GitHub
co-authored by Michael Siega Claude Fable 5.1
parent c92141d141
commit ff7ef60841
5 changed files with 359 additions and 178 deletions
@@ -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",
@@ -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');
});
});
@@ -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 {