feat(editor): Improve UI for highlighted data, tags and rating in executions (#15926)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
Benjamin Schroth
2025-06-04 14:14:48 +02:00
committed by GitHub
parent 4c9198df37
commit 9abb333507
9 changed files with 928 additions and 309 deletions

View File

@@ -18,13 +18,18 @@ withDefaults(defineProps<TagProps>(), {
<style lang="scss" module> <style lang="scss" module>
.tag { .tag {
display: flex;
align-items: center;
justify-content: center;
min-width: max-content; min-width: max-content;
padding: 1px var(--spacing-4xs); height: var(--tag-height);
color: var(--color-text-base); padding: var(--tag-padding);
background-color: var(--color-background-base); line-height: var(--tag-line-height);
border-radius: var(--border-radius-base); color: var(--tag-text-color);
font-size: var(--font-size-2xs); background-color: var(--tag-background-color);
border: 1px solid var(--tag-border-color);
border-radius: var(--tag-border-radius);
font-size: var(--tag-font-size);
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
&.clickable { &.clickable {

View File

@@ -224,6 +224,17 @@
--color-line-break: var(--prim-gray-320); --color-line-break: var(--prim-gray-320);
--color-code-line-break: var(--prim-color-secondary-tint-200); --color-code-line-break: var(--prim-color-secondary-tint-200);
// Tag
--tag-height: 22px;
--tag-padding: 0 var(--spacing-4xs);
--tag-background-color: var(--prim-gray-40);
--tag-border-color: var(--color-foreground-light);
--tag-border-radius: var(--border-radius-base);
--tag-text-color: var(--color-text-base);
--tag-font-size: var(--font-size-2xs);
--tag-font-weight: var(--font-weight-regular);
--tag-line-height: 0;
// Variables // Variables
--color-variables-usage-font: var(--color-success); --color-variables-usage-font: var(--color-success);
--color-variables-usage-syntax-bg: var(--color-success-tint-2); --color-variables-usage-syntax-bg: var(--color-success-tint-2);

View File

@@ -710,6 +710,7 @@
"error.pageNotFound": "Oops, couldnt find that", "error.pageNotFound": "Oops, couldnt find that",
"executions.ExecutionStatus": "Execution status", "executions.ExecutionStatus": "Execution status",
"executions.concurrency.docsLink": "https://docs.n8n.io/hosting/scaling/concurrency-control/", "executions.concurrency.docsLink": "https://docs.n8n.io/hosting/scaling/concurrency-control/",
"executionDetails.additionalActions": "Additional Actions",
"executionDetails.confirmMessage.confirmButtonText": "Yes, delete", "executionDetails.confirmMessage.confirmButtonText": "Yes, delete",
"executionDetails.confirmMessage.headline": "Delete Execution?", "executionDetails.confirmMessage.headline": "Delete Execution?",
"executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?", "executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?",

View File

@@ -17,18 +17,18 @@ const onVoteClick = (vote: AnnotationVote) => {
<template> <template>
<div :class="$style.ratingIcon"> <div :class="$style.ratingIcon">
<n8n-icon-button <n8n-icon-button
:class="{ [$style.up]: vote === 'up' }" :class="[$style.icon, vote === 'up' && $style.up]"
type="tertiary" type="tertiary"
text text
size="medium" size="small"
icon="thumbs-up" icon="thumbs-up"
@click="onVoteClick('up')" @click="onVoteClick('up')"
/> />
<n8n-icon-button <n8n-icon-button
:class="{ [$style.down]: vote === 'down' }" :class="[$style.icon, vote === 'down' && $style.down]"
type="tertiary" type="tertiary"
text text
size="medium" size="small"
icon="thumbs-down" icon="thumbs-down"
@click="onVoteClick('down')" @click="onVoteClick('down')"
/> />
@@ -40,6 +40,14 @@ const onVoteClick = (vote: AnnotationVote) => {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
.icon {
color: var(--color-text-light);
&:not(.up):not(.down):hover {
color: var(--color-primary);
}
}
.up { .up {
color: var(--color-success); color: var(--color-success);
} }

View File

@@ -1,209 +1,137 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { computed, ref } from 'vue';
import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow'; import type { ExecutionSummary } from 'n8n-workflow';
import { useExecutionsStore } from '@/stores/executions.store';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.ee.vue';
import { createEventBus } from '@n8n/utils/event-bus';
import VoteButtons from '@/components/executions/workflow/VoteButtons.vue';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry'; import { ElDropdown } from 'element-plus';
import { getResourcePermissions } from '@/permissions';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRoute } from 'vue-router';
const executionsStore = useExecutionsStore(); const props = defineProps<{
execution: ExecutionSummary & {
const { showError } = useToast();
const i18n = useI18n();
const telemetry = useTelemetry();
const tagsEventBus = createEventBus();
const isTagsEditEnabled = ref(false);
const appliedTagIds = ref<string[]>([]);
const tagsSaving = ref(false);
const activeExecution = computed(() => {
return executionsStore.activeExecution as ExecutionSummary & {
customData?: Record<string, string>; customData?: Record<string, string>;
}; };
}>();
const workflowsStore = useWorkflowsStore();
const route = useRoute();
const i18n = useI18n();
const annotationDropdownRef = ref<InstanceType<typeof ElDropdown> | null>(null);
const isDropdownVisible = ref(false);
const workflowId = computed(() => route.params.name as string);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
);
const customDataLength = computed(() => {
return props.execution?.customData ? Object.keys(props.execution?.customData).length : 0;
}); });
const vote = computed(() => activeExecution.value?.annotation?.vote || null); function onEllipsisButtonBlur(event: FocusEvent) {
const tagIds = computed(() => activeExecution.value?.annotation?.tags.map((tag) => tag.id) ?? []); // Hide dropdown when clicking outside of current document
const tags = computed(() => activeExecution.value?.annotation?.tags); if (annotationDropdownRef.value && event.relatedTarget === null) {
annotationDropdownRef.value.handleClose();
const tagsHasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
return true;
} }
}
const set = new Set(prev); function onDropdownVisibleChange(visible: boolean) {
return curr.reduce((acc, val) => acc || !set.has(val), false); isDropdownVisible.value = visible;
}; }
const onVoteClick = async (voteValue: AnnotationVote) => {
if (!activeExecution.value) {
return;
}
const voteToSet = voteValue === vote.value ? null : voteValue;
try {
await executionsStore.annotateExecution(activeExecution.value.id, { vote: voteToSet });
} catch (e) {
showError(e, 'executionAnnotationView.vote.error');
}
};
const onTagsEditEnable = () => {
appliedTagIds.value = tagIds.value;
isTagsEditEnabled.value = true;
setTimeout(() => {
tagsEventBus.emit('focus');
}, 0);
};
const onTagsBlur = async () => {
if (!activeExecution.value) {
return;
}
const currentTagIds = tagIds.value ?? [];
const newTagIds = appliedTagIds.value;
if (!tagsHasChanged(currentTagIds, newTagIds)) {
isTagsEditEnabled.value = false;
return;
}
if (tagsSaving.value) {
return;
}
tagsSaving.value = true;
try {
await executionsStore.annotateExecution(activeExecution.value.id, { tags: newTagIds });
if (newTagIds.length > 0) {
telemetry.track('User added execution annotation tag', {
tag_ids: newTagIds,
execution_id: activeExecution.value.id,
});
}
} catch (e) {
showError(e, 'executionAnnotationView.tag.error');
}
tagsSaving.value = false;
isTagsEditEnabled.value = false;
};
const onTagsEditEsc = () => {
isTagsEditEnabled.value = false;
};
</script> </script>
<template> <template>
<div <ElDropdown
ref="container" v-if="execution"
:class="['execution-annotation-panel', $style.container]" ref="annotationDropdownRef"
data-test-id="execution-annotation-panel" trigger="click"
@visible-change="onDropdownVisibleChange"
> >
<div :class="$style.section"> <N8nButton
<div :class="$style.vote"> :title="i18n.baseText('executionDetails.additionalActions')"
<div>{{ i18n.baseText('generic.rating') }}</div> :disabled="!workflowPermissions.update"
<VoteButtons :vote="vote" @vote-click="onVoteClick" /> icon="tasks"
</div> :class="{
<span :class="$style.tags" data-test-id="annotation-tags-container"> [$style.highlightDataButton]: true,
<AnnotationTagsDropdown [$style.highlightDataButtonActive]: customDataLength > 0,
v-if="isTagsEditEnabled" [$style.highlightDataButtonOpen]: isDropdownVisible,
ref="dropdown" }"
v-model="appliedTagIds" size="small"
:create-enabled="true" type="secondary"
:event-bus="tagsEventBus" data-test-id="execution-preview-ellipsis-button"
:placeholder="i18n.baseText('executionAnnotationView.chooseOrCreateATag')" @blur="onEllipsisButtonBlur"
class="tags-edit" >
data-test-id="workflow-tags-dropdown" <n8n-badge :class="$style.badge" theme="primary" v-if="customDataLength > 0">
@blur="onTagsBlur" {{ customDataLength.toString() }}
@esc="onTagsEditEsc" </n8n-badge>
/> </N8nButton>
<div v-else-if="tagIds.length === 0"> <template #dropdown>
<span
:class="[$style.addTag, $style.addTagStandalone, 'clickable']"
data-test-id="new-tag-link"
@click="onTagsEditEnable"
>
+ {{ i18n.baseText('executionAnnotationView.addTag') }}
</span>
</div>
<span
v-else
:class="[
'tags-container', // FIXME: There are some global styles for tags relying on this classname
$style.tagsContainer,
]"
data-test-id="execution-annotation-tags"
@click="onTagsEditEnable"
>
<span v-for="tag in tags" :key="tag.id" class="clickable">
<el-tag :title="tag.name" type="info" size="small" :disable-transitions="true">
{{ tag.name }}
</el-tag>
</span>
<span :class="$style.addTagWrapper">
<n8n-button
:class="$style.addTag"
:label="`+ ` + i18n.baseText('executionAnnotationView.addTag')"
type="secondary"
size="mini"
:outline="false"
:text="true"
@click="onTagsEditEnable"
/>
</span>
</span>
</span>
</div>
<div :class="$style.section">
<div :class="$style.heading">
<n8n-heading tag="h3" size="small" color="text-dark">
{{ i18n.baseText('generic.annotationData') }}
</n8n-heading>
</div>
<div <div
v-if="activeExecution?.customData && Object.keys(activeExecution?.customData).length > 0" ref="container"
:class="$style.metadata" :class="['execution-annotation-panel', $style.container]"
data-test-id="execution-annotation-panel"
> >
<div <div :class="$style.section">
v-for="attr in Object.keys(activeExecution?.customData)" <div :class="$style.heading">
:key="attr" <n8n-heading tag="h3" size="small" color="text-dark">
:class="$style.customDataEntry" {{ i18n.baseText('generic.annotationData') }}
> </n8n-heading>
<n8n-text :class="$style.key" size="small" color="text-base"> </div>
{{ attr }} <div
</n8n-text> v-if="execution?.customData && Object.keys(execution?.customData).length > 0"
<n8n-text :class="$style.value" size="small" color="text-base"> :class="$style.metadata"
{{ activeExecution?.customData[attr] }} >
</n8n-text> <div
v-for="attr in Object.keys(execution?.customData)"
:key="attr"
:class="$style.customDataEntry"
>
<n8n-text :class="$style.key" size="small" color="text-base">
{{ attr }}
</n8n-text>
<n8n-text :class="$style.value" size="small" color="text-base">
{{ execution?.customData[attr] }}
</n8n-text>
</div>
</div>
<div
v-else
:class="$style.noResultsContainer"
data-test-id="execution-annotation-data-empty"
>
<n8n-text color="text-base" size="small" align="center">
<span v-n8n-html="i18n.baseText('executionAnnotationView.data.notFound')" />
</n8n-text>
</div>
</div> </div>
</div> </div>
<div v-else :class="$style.noResultsContainer" data-test-id="execution-annotation-data-empty"> </template>
<n8n-text color="text-base" size="small" align="center"> </ElDropdown>
<span v-n8n-html="i18n.baseText('executionAnnotationView.data.notFound')" />
</n8n-text>
</div>
</div>
</div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.highlightDataButton {
height: 30px;
width: 30px;
}
.highlightDataButtonActive {
width: auto;
}
.highlightDataButtonOpen {
color: var(--color-primary);
background-color: var(--color-button-secondary-hover-background);
border-color: var(--color-button-secondary-hover-active-focus-border);
}
.badge {
border: 0;
}
.container { .container {
z-index: 1; z-index: 1;
position: absolute;
bottom: 0;
right: var(--spacing-xl);
transform: translate(0, 100%);
max-height: calc(100vh - 250px); max-height: calc(100vh - 250px);
width: 250px; width: 250px;
@@ -250,25 +178,6 @@ const onTagsEditEsc = () => {
} }
} }
.vote {
padding: 0 0 var(--spacing-xs);
font-size: var(--font-size-xs);
flex: 1;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
.ratingIcon {
display: flex;
flex-direction: row;
.highlight {
color: var(--color-primary);
}
}
}
.customDataEntry { .customDataEntry {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -293,35 +202,4 @@ const onTagsEditEsc = () => {
border-radius: 0; border-radius: 0;
} }
} }
.tagsContainer {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
margin-top: calc(var(--spacing-4xs) * -1); // Cancel out top margin of first tags row
* {
margin: var(--spacing-4xs) var(--spacing-4xs) 0 0;
}
}
.addTag {
font-size: var(--font-size-2xs);
color: $custom-font-very-light;
font-weight: var(--font-weight-bold);
white-space: nowrap;
&:hover {
color: $color-primary;
text-decoration: none;
}
}
.addTagStandalone {
padding: var(--spacing-m) 0; // to be more clickable
}
.addTagWrapper {
margin-left: calc(var(--spacing-2xs) * -1); // Cancel out right margin of last tag
}
</style> </style>

View File

@@ -0,0 +1,226 @@
import { describe, expect, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker';
import type { ExecutionSummary, AnnotationVote } from 'n8n-workflow';
import WorkflowExecutionAnnotationTags from '@/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.vue';
import { EnterpriseEditionFeature } from '@/constants';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { STORES } from '@n8n/stores';
import { nextTick } from 'vue';
const showError = vi.fn();
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ showError }),
}));
const mockTrack = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: () => ({
track: mockTrack,
}),
}));
const executionDataFactory = (
tags: Array<{ id: string; name: string }> = [],
): ExecutionSummary => ({
id: faker.string.uuid(),
finished: faker.datatype.boolean(),
mode: faker.helpers.arrayElement(['manual', 'trigger']),
createdAt: faker.date.past(),
startedAt: faker.date.past(),
stoppedAt: faker.date.past(),
workflowId: faker.number.int().toString(),
workflowName: faker.string.sample(),
status: faker.helpers.arrayElement(['error', 'success']),
nodeExecutionStatus: {},
retryOf: null,
retrySuccessId: null,
annotation: { tags, vote: 'up' },
});
const renderComponent = createComponentRenderer(WorkflowExecutionAnnotationTags);
describe('WorkflowExecutionAnnotationTags.ee.vue', () => {
const executionData: ExecutionSummary = executionDataFactory();
beforeEach(() => {
vi.clearAllMocks();
});
it('displays existing tags', async () => {
const executionWithTags = {
...executionData,
annotation: {
tags: [
{ id: 'tag1', name: 'Test Tag 1' },
{ id: 'tag2', name: 'Test Tag 2' },
],
vote: 'up' as AnnotationVote,
},
};
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
},
});
const { getByTestId, queryByTestId } = renderComponent({
pinia,
props: { execution: executionWithTags },
});
await nextTick();
expect(getByTestId('annotation-tags-container')).toBeInTheDocument();
expect(getByTestId('execution-annotation-tags')).toBeInTheDocument();
expect(queryByTestId('workflow-tags-dropdown')).not.toBeInTheDocument();
expect(getByTestId('execution-annotation-tags')).toHaveTextContent('Test Tag 1');
expect(getByTestId('execution-annotation-tags')).toHaveTextContent('Test Tag 2');
});
it('shows add tag button when no tags exist', async () => {
const executionWithoutTags = {
...executionData,
annotation: {
tags: [],
vote: 'up' as AnnotationVote,
},
};
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
},
});
const { getByTestId, queryByTestId } = renderComponent({
pinia,
props: { execution: executionWithoutTags },
});
await nextTick();
expect(getByTestId('new-tag-link')).toBeInTheDocument();
expect(queryByTestId('execution-annotation-tags')).not.toBeInTheDocument();
});
it('shows existing tags with add button when tags exist', async () => {
const executionWithTags = {
...executionData,
annotation: {
tags: [{ id: 'tag1', name: 'Test Tag 1' }],
vote: 'up' as AnnotationVote,
},
};
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
},
});
const { getByTestId } = renderComponent({
pinia,
props: { execution: executionWithTags },
});
await nextTick();
expect(getByTestId('execution-annotation-tags')).toBeInTheDocument();
expect(getByTestId('new-tag-link')).toBeInTheDocument();
});
it('enables tag editing when add button is clicked', async () => {
const executionWithoutTags = {
...executionData,
annotation: { tags: [], vote: 'up' as AnnotationVote },
};
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
},
});
const { getByTestId, queryByTestId } = renderComponent({
pinia,
props: { execution: executionWithoutTags },
});
await nextTick();
const addTagButton = getByTestId('new-tag-link');
expect(addTagButton).toBeInTheDocument();
expect(queryByTestId('workflow-tags-dropdown')).not.toBeInTheDocument();
await userEvent.click(addTagButton);
expect(getByTestId('workflow-tags-dropdown')).toBeInTheDocument();
});
it('enables tag editing when existing tags are clicked', async () => {
const executionWithTags = {
...executionData,
annotation: {
tags: [{ id: 'tag1', name: 'Test Tag 1' }],
vote: 'up' as AnnotationVote,
},
};
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
},
});
const { getByTestId, queryByTestId } = renderComponent({
pinia,
props: { execution: executionWithTags },
});
await nextTick();
const tagsContainer = getByTestId('execution-annotation-tags');
expect(tagsContainer).toBeInTheDocument();
expect(queryByTestId('workflow-tags-dropdown')).not.toBeInTheDocument();
await userEvent.click(tagsContainer);
expect(getByTestId('workflow-tags-dropdown')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,207 @@
<script lang="ts" setup>
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.ee.vue';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { useExecutionsStore } from '@/stores/executions.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { createEventBus } from '@n8n/utils/event-bus';
import type { ExecutionSummary } from 'n8n-workflow';
import { computed, ref } from 'vue';
const props = defineProps<{
execution: ExecutionSummary;
}>();
const locale = useI18n();
const telemetry = useTelemetry();
const { showError } = useToast();
const executionsStore = useExecutionsStore();
const tagIds = computed(() => props.execution.annotation?.tags.map((tag) => tag.id) ?? []);
const tags = computed(() => props.execution.annotation?.tags);
const tagsEventBus = createEventBus();
const isTagsEditEnabled = ref(false);
const appliedTagIds = ref<string[]>([]);
const tagsSaving = ref(false);
const tagsHasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
return true;
}
const set = new Set(prev);
return curr.reduce((acc, val) => acc || !set.has(val), false);
};
const onTagsEditEnable = () => {
appliedTagIds.value = tagIds.value;
isTagsEditEnabled.value = true;
tagsEventBus.emit('focus');
};
const onTagsBlur = async () => {
if (!props.execution) {
return;
}
const currentTagIds = tagIds.value ?? [];
const newTagIds = appliedTagIds.value;
if (!tagsHasChanged(currentTagIds, newTagIds)) {
isTagsEditEnabled.value = false;
return;
}
if (tagsSaving.value) {
return;
}
tagsSaving.value = true;
try {
await executionsStore.annotateExecution(props.execution.id, { tags: newTagIds });
if (newTagIds.length > 0) {
telemetry.track('User added execution annotation tag', {
tag_ids: newTagIds,
execution_id: props.execution.id,
});
}
} catch (e) {
showError(e, 'executionAnnotationView.tag.error');
}
tagsSaving.value = false;
isTagsEditEnabled.value = false;
};
const onTagsEditEsc = () => {
isTagsEditEnabled.value = false;
};
</script>
<template>
<div :class="$style.executionDetailsTags">
<span :class="$style.tags" data-test-id="annotation-tags-container">
<AnnotationTagsDropdown
v-if="isTagsEditEnabled"
ref="dropdown"
v-model="appliedTagIds"
:create-enabled="true"
:event-bus="tagsEventBus"
:placeholder="locale.baseText('executionAnnotationView.chooseOrCreateATag')"
class="tags-edit"
data-test-id="workflow-tags-dropdown"
@blur="onTagsBlur"
@esc="onTagsEditEsc"
/>
<div v-else-if="tagIds.length === 0">
<N8nButton
:class="[$style.addTagButton, 'clickable']"
:label="locale.baseText('executionAnnotationView.addTag')"
type="secondary"
size="mini"
:outline="false"
:text="true"
@click="onTagsEditEnable"
data-test-id="new-tag-link"
icon="plus"
/>
</div>
<span
v-else
:class="[
'tags-container', // FIXME: There are some global styles for tags relying on this classname
$style.tagsContainer,
]"
data-test-id="execution-annotation-tags"
@click="onTagsEditEnable"
>
<span v-for="tag in tags" :key="tag.id" class="clickable">
<el-tag :title="tag.name" type="info" size="small" :disable-transitions="true">
{{ tag.name }}
</el-tag>
</span>
<span :class="$style.addTagWrapper">
<N8nButton
:class="[$style.addTagButton, $style.addTagButtonIconOnly, 'clickable']"
type="secondary"
size="mini"
:outline="false"
:text="true"
@click="onTagsEditEnable"
data-test-id="new-tag-link"
icon="plus"
/>
</span>
</span>
</span>
</div>
</template>
<style module lang="scss">
.executionDetailsTags {
// Container styles if needed
}
.tags {
display: block;
margin-top: var(--spacing-4xs);
}
.addTagButton {
height: 24px;
font-size: var(--font-size-2xs);
white-space: nowrap;
padding: var(--spacing-4xs) var(--spacing-3xs);
background-color: var(--color-button-secondary-background);
border: 1px solid var(--color-foreground-light);
border-radius: var(--border-radius-base);
font-weight: var(--font-weight-regular);
&:hover {
color: $color-primary;
text-decoration: none;
background-color: var(--color-button-secondary-hover-background);
border: 1px solid var(--color-button-secondary-hover-active-focus-border);
border-radius: var(--border-radius-base);
}
span + span {
margin-left: var(--spacing-4xs);
}
}
.addTagButtonIconOnly {
height: 22px;
width: 22px;
}
.tagsContainer {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4xs);
max-width: 360px;
:global(.el-tag) {
display: flex;
align-items: center;
justify-content: center;
min-width: max-content;
height: var(--tag-height);
padding: var(--tag-padding);
line-height: var(--tag-line-height);
color: var(--tag-text-color);
background-color: var(--tag-background-color);
border: 1px solid var(--tag-border-color);
border-radius: var(--tag-border-radius);
font-size: var(--tag-font-size);
}
}
.addTagWrapper {
// Wrapper styles if needed
}
</style>

View File

@@ -2,7 +2,7 @@ import { describe, expect } from 'vitest';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { createRouter, createWebHistory, RouterLink } from 'vue-router'; import { createRouter, createWebHistory, RouterLink } from 'vue-router';
import { randomInt, type ExecutionSummary } from 'n8n-workflow'; import { randomInt, type ExecutionSummary, type AnnotationVote } from 'n8n-workflow';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue'; import WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue';
import { EnterpriseEditionFeature, VIEWS } from '@/constants'; import { EnterpriseEditionFeature, VIEWS } from '@/constants';
@@ -12,6 +12,8 @@ import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import type { FrontendSettings } from '@n8n/api-types'; import type { FrontendSettings } from '@n8n/api-types';
import { STORES } from '@n8n/stores';
import { nextTick } from 'vue';
const showMessage = vi.fn(); const showMessage = vi.fn();
const showError = vi.fn(); const showError = vi.fn();
@@ -82,7 +84,20 @@ describe('WorkflowExecutionsPreview.vue', () => {
const executionData: ExecutionSummary = executionDataFactory(); const executionData: ExecutionSummary = executionDataFactory();
beforeEach(() => { beforeEach(() => {
createTestingPinia(); createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
[STORES.EXECUTIONS]: {
activeExecution: executionData,
},
},
});
}); });
test.each([ test.each([
@@ -121,4 +136,182 @@ describe('WorkflowExecutionsPreview.vue', () => {
expect(getByTestId('stop-execution')).toBeDisabled(); expect(getByTestId('stop-execution')).toBeDisabled();
}); });
it('should display vote buttons when annotation is enabled', async () => {
// Set up the test with annotation enabled
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
[STORES.EXECUTIONS]: {
activeExecution: executionData,
},
},
});
const { getByTestId } = renderComponent({
props: { execution: executionData },
pinia,
});
await nextTick();
// Should show vote buttons container
const voteButtons = getByTestId('execution-preview-vote-buttons');
expect(voteButtons).toBeInTheDocument();
// Should contain two button elements (thumbs up and thumbs down)
const buttons = voteButtons.querySelectorAll('button');
expect(buttons).toHaveLength(2);
});
it('should show active vote state', async () => {
const executionWithUpVote = {
...executionData,
annotation: {
tags: [],
vote: 'up' as AnnotationVote,
},
};
// Set up the test with an up vote
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
[STORES.EXECUTIONS]: {
activeExecution: executionWithUpVote,
},
},
});
const { getByTestId } = renderComponent({
props: { execution: executionWithUpVote },
pinia,
});
await nextTick();
const voteButtons = getByTestId('execution-preview-vote-buttons');
expect(voteButtons).toBeInTheDocument();
// Should have two buttons for voting
const buttons = voteButtons.querySelectorAll('button');
expect(buttons).toHaveLength(2);
});
it('should display highlighted data dropdown when custom data exists', async () => {
const executionWithCustomData = {
...executionData,
customData: { key1: 'value1', key2: 'value2' },
};
// Set up the test with custom data
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
[STORES.EXECUTIONS]: {
activeExecution: executionWithCustomData,
},
},
});
const { getByTestId } = renderComponent({
props: { execution: executionWithCustomData },
pinia,
});
await nextTick();
const ellipsisButton = getByTestId('execution-preview-ellipsis-button');
expect(ellipsisButton).toBeInTheDocument();
// Should show badge with custom data count
const badge = ellipsisButton.querySelector('.badge');
expect(badge).toBeInTheDocument();
expect(badge?.textContent).toBe('2');
});
it('should not show badge when no custom data exists', async () => {
// Set up the test without custom data
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
},
},
},
[STORES.EXECUTIONS]: {
activeExecution: executionData,
},
},
});
const { getByTestId } = renderComponent({
props: { execution: executionData },
pinia,
});
await nextTick();
const ellipsisButton = getByTestId('execution-preview-ellipsis-button');
expect(ellipsisButton).toBeInTheDocument();
// Should not show badge when no custom data
const badge = ellipsisButton.querySelector('.badge');
expect(badge).not.toBeInTheDocument();
});
it('should not show vote buttons when annotation is disabled', async () => {
const settingsStore = mockedStore(useSettingsStore);
settingsStore.settings.enterprise = {
...settingsStore.settings.enterprise,
[EnterpriseEditionFeature.AdvancedExecutionFilters]: false,
} as FrontendSettings['enterprise'];
const { queryByTestId } = renderComponent({
props: { execution: executionData },
});
await nextTick();
// Should not show vote buttons when annotation is disabled
expect(queryByTestId('execution-preview-vote-buttons')).not.toBeInTheDocument();
});
it('should not show annotation features when annotation is disabled', async () => {
const settingsStore = mockedStore(useSettingsStore);
settingsStore.settings.enterprise = {
...settingsStore.settings.enterprise,
[EnterpriseEditionFeature.AdvancedExecutionFilters]: false,
} as FrontendSettings['enterprise'];
const { queryByTestId } = renderComponent({
props: { execution: executionData },
});
await nextTick();
// Should not show annotation-related elements
expect(queryByTestId('annotation-tags-container')).not.toBeInTheDocument();
expect(queryByTestId('execution-preview-ellipsis-button')).not.toBeInTheDocument();
});
}); });

View File

@@ -1,20 +1,22 @@
<script lang="ts" setup> <script lang="ts" setup>
import WorkflowExecutionAnnotationPanel from '@/components/executions/workflow/WorkflowExecutionAnnotationPanel.ee.vue'; import WorkflowExecutionAnnotationPanel from '@/components/executions/workflow/WorkflowExecutionAnnotationPanel.ee.vue';
import WorkflowExecutionAnnotationTags from '@/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.vue';
import WorkflowPreview from '@/components/WorkflowPreview.vue'; import WorkflowPreview from '@/components/WorkflowPreview.vue';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging'; import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers'; import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers'; import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants'; import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'; import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
import { N8nButton, N8nIconButton, N8nText } from '@n8n/design-system'; import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow';
import type { ExecutionSummary } from 'n8n-workflow';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router'; import { RouterLink, useRoute } from 'vue-router';
import { useExecutionsStore } from '@/stores/executions.store';
type RetryDropdownRef = InstanceType<typeof ElDropdown>; type RetryDropdownRef = InstanceType<typeof ElDropdown>;
@@ -30,6 +32,7 @@ const emit = defineEmits<{
const route = useRoute(); const route = useRoute();
const locale = useI18n(); const locale = useI18n();
const { showError } = useToast();
const executionHelpers = useExecutionHelpers(); const executionHelpers = useExecutionHelpers();
const message = useMessage(); const message = useMessage();
@@ -70,6 +73,16 @@ const hasAnnotation = computed(
(props.execution?.annotation.vote || props.execution?.annotation.tags.length > 0), (props.execution?.annotation.vote || props.execution?.annotation.tags.length > 0),
); );
const executionsStore = useExecutionsStore();
const activeExecution = computed(() => {
return executionsStore.activeExecution as ExecutionSummary & {
customData?: Record<string, string>;
};
});
const vote = computed(() => activeExecution.value?.annotation?.vote || null);
async function onDeleteExecution(): Promise<void> { async function onDeleteExecution(): Promise<void> {
// Prepend the message with a note about annotations if they exist // Prepend the message with a note about annotations if they exist
const confirmationText = [ const confirmationText = [
@@ -108,6 +121,20 @@ function onRetryButtonBlur(event: FocusEvent) {
retryDropdownRef.value.handleClose(); retryDropdownRef.value.handleClose();
} }
} }
const onVoteClick = async (voteValue: AnnotationVote) => {
if (!activeExecution.value) {
return;
}
const voteToSet = voteValue === vote.value ? null : voteValue;
try {
await executionsStore.annotateExecution(activeExecution.value.id, { vote: voteToSet });
} catch (e) {
showError(e, 'executionAnnotationView.vote.error');
}
};
</script> </script>
<template> <template>
@@ -142,65 +169,89 @@ function onRetryButtonBlur(event: FocusEvent) {
:class="$style.executionDetails" :class="$style.executionDetails"
:data-test-id="`execution-preview-details-${executionId}`" :data-test-id="`execution-preview-details-${executionId}`"
> >
<WorkflowExecutionAnnotationPanel v-if="isAnnotationEnabled && execution" /> <div :class="$style.executionDetailsLeft">
<div> <div :class="$style.executionTitle">
<N8nText size="large" color="text-base" :bold="true" data-test-id="execution-time">{{ <N8nText size="large" color="text-dark" :bold="true" data-test-id="execution-time">{{
executionUIDetails?.startTime executionUIDetails?.startTime
}}</N8nText }}</N8nText
><br /> ><VoteButtons
<N8nSpinner v-if="isAnnotationEnabled && execution"
v-if="executionUIDetails?.name === 'running'" data-test-id="execution-preview-vote-buttons"
size="small" :vote="vote"
:class="[$style.spinner, 'mr-4xs']" :class="$style.voteButtons"
/> @vote-click="onVoteClick"
<N8nText />
size="medium" </div>
:class="[$style.status, $style[executionUIDetails.name]]" <div :class="$style.executionDetailsInfo">
data-test-id="execution-preview-label" <N8nSpinner
> v-if="executionUIDetails?.name === 'running'"
{{ executionUIDetails.label }} size="small"
</N8nText> :class="[$style.spinner, 'mr-4xs']"
{{ ' ' }} />
<N8nText v-if="executionUIDetails?.showTimestamp === false" color="text-base" size="medium"> <N8nText
| ID#{{ execution.id }} size="medium"
</N8nText> :class="[$style.status, $style[executionUIDetails.name]]"
<N8nText v-else-if="executionUIDetails.name === 'running'" color="text-base" size="medium"> data-test-id="execution-preview-label"
{{
locale.baseText('executionDetails.runningTimeRunning', {
interpolate: { time: executionUIDetails?.runningTime },
})
}}
| ID#{{ execution.id }}
</N8nText>
<N8nText
v-else-if="executionUIDetails.name !== 'waiting'"
color="text-base"
size="medium"
data-test-id="execution-preview-id"
>
{{
locale.baseText('executionDetails.runningTimeFinished', {
interpolate: { time: executionUIDetails?.runningTime ?? 'unknown' },
})
}}
| ID#{{ execution.id }}
</N8nText>
<br /><N8nText v-if="execution.mode === 'retry'" color="text-base" size="medium">
{{ locale.baseText('executionDetails.retry') }}
<RouterLink
:class="$style.executionLink"
:to="{
name: VIEWS.EXECUTION_PREVIEW,
params: {
workflowId: execution.workflowId,
executionId: execution.retryOf,
},
}"
> >
#{{ execution.retryOf }} {{ executionUIDetails.label }}
</RouterLink> </N8nText>
</N8nText> {{ ' ' }}
<N8nText
v-if="executionUIDetails?.showTimestamp === false"
color="text-base"
size="medium"
>
| ID#{{ execution.id }}
</N8nText>
<N8nText
v-else-if="executionUIDetails.name === 'running'"
color="text-base"
size="medium"
>
{{
locale.baseText('executionDetails.runningTimeRunning', {
interpolate: { time: executionUIDetails?.runningTime },
})
}}
| ID#{{ execution.id }}
</N8nText>
<N8nText
v-else-if="executionUIDetails.name !== 'waiting'"
color="text-base"
size="medium"
data-test-id="execution-preview-id"
>
{{
locale.baseText('executionDetails.runningTimeFinished', {
interpolate: { time: executionUIDetails?.runningTime ?? 'unknown' },
})
}}
| ID#{{ execution.id }}
</N8nText>
</div>
<div :class="$style.executionDetailsRetry" v-if="execution.mode === 'retry'">
<N8nText color="text-base" size="small">
{{ locale.baseText('executionDetails.retry') }}
<RouterLink
:class="$style.executionLink"
:to="{
name: VIEWS.EXECUTION_PREVIEW,
params: {
workflowId: execution.workflowId,
executionId: execution.retryOf,
},
}"
>
#{{ execution.retryOf }}
</RouterLink>
</N8nText>
</div>
<WorkflowExecutionAnnotationTags
v-if="isAnnotationEnabled && execution"
:execution="execution"
/>
</div> </div>
<div :class="$style.actions"> <div :class="$style.actions">
<RouterLink <RouterLink
:to="{ :to="{
@@ -229,7 +280,6 @@ function onRetryButtonBlur(event: FocusEvent) {
v-if="isRetriable" v-if="isRetriable"
ref="retryDropdown" ref="retryDropdown"
trigger="click" trigger="click"
class="mr-xs"
@command="handleRetryClick" @command="handleRetryClick"
> >
<span class="retry-button"> <span class="retry-button">
@@ -254,6 +304,12 @@ function onRetryButtonBlur(event: FocusEvent) {
</ElDropdownMenu> </ElDropdownMenu>
</template> </template>
</ElDropdown> </ElDropdown>
<WorkflowExecutionAnnotationPanel
:execution="activeExecution"
v-if="isAnnotationEnabled && activeExecution"
/>
<N8nIconButton <N8nIconButton
:title="locale.baseText('executionDetails.deleteExecution')" :title="locale.baseText('executionDetails.deleteExecution')"
:disabled="!workflowPermissions.update" :disabled="!workflowPermissions.update"
@@ -285,11 +341,10 @@ function onRetryButtonBlur(event: FocusEvent) {
.executionDetails { .executionDetails {
position: absolute; position: absolute;
padding: var(--spacing-m); padding: var(--spacing-m);
padding-right: var(--spacing-xl);
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
pointer-events: none; pointer-events: none;
@@ -303,6 +358,22 @@ function onRetryButtonBlur(event: FocusEvent) {
} }
} }
.executionDetailsLeft {
display: flex;
flex-direction: column;
gap: var(--spacing-5xs);
}
.executionTitle {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
}
.voteButtons {
margin-bottom: 2px;
}
.spinner { .spinner {
div div { div div {
width: 30px; width: 30px;
@@ -350,6 +421,25 @@ function onRetryButtonBlur(event: FocusEvent) {
.actions { .actions {
display: flex; display: flex;
gap: var(--spacing-xs); gap: var(--spacing-2xs);
}
.highlightDataButton {
height: 30px;
width: 30px;
}
.highlightDataButtonActive {
width: auto;
}
.highlightDataButtonOpen {
color: var(--color-primary);
background-color: var(--color-button-secondary-hover-background);
border-color: var(--color-button-secondary-hover-active-focus-border);
}
.badge {
border: 0;
} }
</style> </style>