mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
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:
@@ -17,18 +17,18 @@ const onVoteClick = (vote: AnnotationVote) => {
|
||||
<template>
|
||||
<div :class="$style.ratingIcon">
|
||||
<n8n-icon-button
|
||||
:class="{ [$style.up]: vote === 'up' }"
|
||||
:class="[$style.icon, vote === 'up' && $style.up]"
|
||||
type="tertiary"
|
||||
text
|
||||
size="medium"
|
||||
size="small"
|
||||
icon="thumbs-up"
|
||||
@click="onVoteClick('up')"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
:class="{ [$style.down]: vote === 'down' }"
|
||||
:class="[$style.icon, vote === 'down' && $style.down]"
|
||||
type="tertiary"
|
||||
text
|
||||
size="medium"
|
||||
size="small"
|
||||
icon="thumbs-down"
|
||||
@click="onVoteClick('down')"
|
||||
/>
|
||||
@@ -40,6 +40,14 @@ const onVoteClick = (vote: AnnotationVote) => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.icon {
|
||||
color: var(--color-text-light);
|
||||
|
||||
&:not(.up):not(.down):hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.up {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
@@ -1,209 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import type { AnnotationVote, 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 { computed, ref } from 'vue';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
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 { 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 & {
|
||||
const props = defineProps<{
|
||||
execution: ExecutionSummary & {
|
||||
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);
|
||||
const tagIds = computed(() => activeExecution.value?.annotation?.tags.map((tag) => tag.id) ?? []);
|
||||
const tags = computed(() => activeExecution.value?.annotation?.tags);
|
||||
|
||||
const tagsHasChanged = (prev: string[], curr: string[]) => {
|
||||
if (prev.length !== curr.length) {
|
||||
return true;
|
||||
function onEllipsisButtonBlur(event: FocusEvent) {
|
||||
// Hide dropdown when clicking outside of current document
|
||||
if (annotationDropdownRef.value && event.relatedTarget === null) {
|
||||
annotationDropdownRef.value.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
const set = new Set(prev);
|
||||
return curr.reduce((acc, val) => acc || !set.has(val), false);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
function onDropdownVisibleChange(visible: boolean) {
|
||||
isDropdownVisible.value = visible;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:class="['execution-annotation-panel', $style.container]"
|
||||
data-test-id="execution-annotation-panel"
|
||||
<ElDropdown
|
||||
v-if="execution"
|
||||
ref="annotationDropdownRef"
|
||||
trigger="click"
|
||||
@visible-change="onDropdownVisibleChange"
|
||||
>
|
||||
<div :class="$style.section">
|
||||
<div :class="$style.vote">
|
||||
<div>{{ i18n.baseText('generic.rating') }}</div>
|
||||
<VoteButtons :vote="vote" @vote-click="onVoteClick" />
|
||||
</div>
|
||||
<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="i18n.baseText('executionAnnotationView.chooseOrCreateATag')"
|
||||
class="tags-edit"
|
||||
data-test-id="workflow-tags-dropdown"
|
||||
@blur="onTagsBlur"
|
||||
@esc="onTagsEditEsc"
|
||||
/>
|
||||
<div v-else-if="tagIds.length === 0">
|
||||
<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>
|
||||
<N8nButton
|
||||
:title="i18n.baseText('executionDetails.additionalActions')"
|
||||
:disabled="!workflowPermissions.update"
|
||||
icon="tasks"
|
||||
:class="{
|
||||
[$style.highlightDataButton]: true,
|
||||
[$style.highlightDataButtonActive]: customDataLength > 0,
|
||||
[$style.highlightDataButtonOpen]: isDropdownVisible,
|
||||
}"
|
||||
size="small"
|
||||
type="secondary"
|
||||
data-test-id="execution-preview-ellipsis-button"
|
||||
@blur="onEllipsisButtonBlur"
|
||||
>
|
||||
<n8n-badge :class="$style.badge" theme="primary" v-if="customDataLength > 0">
|
||||
{{ customDataLength.toString() }}
|
||||
</n8n-badge>
|
||||
</N8nButton>
|
||||
<template #dropdown>
|
||||
<div
|
||||
v-if="activeExecution?.customData && Object.keys(activeExecution?.customData).length > 0"
|
||||
:class="$style.metadata"
|
||||
ref="container"
|
||||
:class="['execution-annotation-panel', $style.container]"
|
||||
data-test-id="execution-annotation-panel"
|
||||
>
|
||||
<div
|
||||
v-for="attr in Object.keys(activeExecution?.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">
|
||||
{{ activeExecution?.customData[attr] }}
|
||||
</n8n-text>
|
||||
<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
|
||||
v-if="execution?.customData && Object.keys(execution?.customData).length > 0"
|
||||
:class="$style.metadata"
|
||||
>
|
||||
<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 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>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: var(--spacing-xl);
|
||||
transform: translate(0, 100%);
|
||||
max-height: calc(100vh - 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -293,35 +202,4 @@ const onTagsEditEsc = () => {
|
||||
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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { faker } from '@faker-js/faker';
|
||||
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 WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue';
|
||||
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
|
||||
@@ -12,6 +12,8 @@ import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
@@ -82,7 +84,20 @@ describe('WorkflowExecutionsPreview.vue', () => {
|
||||
const executionData: ExecutionSummary = executionDataFactory();
|
||||
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
[STORES.EXECUTIONS]: {
|
||||
activeExecution: executionData,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -121,4 +136,182 @@ describe('WorkflowExecutionsPreview.vue', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
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 { useExecutionDebugging } from '@/composables/useExecutionDebugging';
|
||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
|
||||
import { N8nButton, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow';
|
||||
import { computed, ref } from 'vue';
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
|
||||
type RetryDropdownRef = InstanceType<typeof ElDropdown>;
|
||||
|
||||
@@ -30,6 +32,7 @@ const emit = defineEmits<{
|
||||
|
||||
const route = useRoute();
|
||||
const locale = useI18n();
|
||||
const { showError } = useToast();
|
||||
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
const message = useMessage();
|
||||
@@ -70,6 +73,16 @@ const hasAnnotation = computed(
|
||||
(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> {
|
||||
// Prepend the message with a note about annotations if they exist
|
||||
const confirmationText = [
|
||||
@@ -108,6 +121,20 @@ function onRetryButtonBlur(event: FocusEvent) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -142,65 +169,89 @@ function onRetryButtonBlur(event: FocusEvent) {
|
||||
:class="$style.executionDetails"
|
||||
:data-test-id="`execution-preview-details-${executionId}`"
|
||||
>
|
||||
<WorkflowExecutionAnnotationPanel v-if="isAnnotationEnabled && execution" />
|
||||
<div>
|
||||
<N8nText size="large" color="text-base" :bold="true" data-test-id="execution-time">{{
|
||||
executionUIDetails?.startTime
|
||||
}}</N8nText
|
||||
><br />
|
||||
<N8nSpinner
|
||||
v-if="executionUIDetails?.name === 'running'"
|
||||
size="small"
|
||||
:class="[$style.spinner, 'mr-4xs']"
|
||||
/>
|
||||
<N8nText
|
||||
size="medium"
|
||||
:class="[$style.status, $style[executionUIDetails.name]]"
|
||||
data-test-id="execution-preview-label"
|
||||
>
|
||||
{{ executionUIDetails.label }}
|
||||
</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>
|
||||
<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,
|
||||
},
|
||||
}"
|
||||
<div :class="$style.executionDetailsLeft">
|
||||
<div :class="$style.executionTitle">
|
||||
<N8nText size="large" color="text-dark" :bold="true" data-test-id="execution-time">{{
|
||||
executionUIDetails?.startTime
|
||||
}}</N8nText
|
||||
><VoteButtons
|
||||
v-if="isAnnotationEnabled && execution"
|
||||
data-test-id="execution-preview-vote-buttons"
|
||||
:vote="vote"
|
||||
:class="$style.voteButtons"
|
||||
@vote-click="onVoteClick"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.executionDetailsInfo">
|
||||
<N8nSpinner
|
||||
v-if="executionUIDetails?.name === 'running'"
|
||||
size="small"
|
||||
:class="[$style.spinner, 'mr-4xs']"
|
||||
/>
|
||||
<N8nText
|
||||
size="medium"
|
||||
:class="[$style.status, $style[executionUIDetails.name]]"
|
||||
data-test-id="execution-preview-label"
|
||||
>
|
||||
#{{ execution.retryOf }}
|
||||
</RouterLink>
|
||||
</N8nText>
|
||||
{{ executionUIDetails.label }}
|
||||
</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 :class="$style.actions">
|
||||
<RouterLink
|
||||
:to="{
|
||||
@@ -229,7 +280,6 @@ function onRetryButtonBlur(event: FocusEvent) {
|
||||
v-if="isRetriable"
|
||||
ref="retryDropdown"
|
||||
trigger="click"
|
||||
class="mr-xs"
|
||||
@command="handleRetryClick"
|
||||
>
|
||||
<span class="retry-button">
|
||||
@@ -254,6 +304,12 @@ function onRetryButtonBlur(event: FocusEvent) {
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<WorkflowExecutionAnnotationPanel
|
||||
:execution="activeExecution"
|
||||
v-if="isAnnotationEnabled && activeExecution"
|
||||
/>
|
||||
|
||||
<N8nIconButton
|
||||
:title="locale.baseText('executionDetails.deleteExecution')"
|
||||
:disabled="!workflowPermissions.update"
|
||||
@@ -285,11 +341,10 @@ function onRetryButtonBlur(event: FocusEvent) {
|
||||
.executionDetails {
|
||||
position: absolute;
|
||||
padding: var(--spacing-m);
|
||||
padding-right: var(--spacing-xl);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
transition: all 150ms ease-in-out;
|
||||
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 {
|
||||
div div {
|
||||
width: 30px;
|
||||
@@ -350,6 +421,25 @@ function onRetryButtonBlur(event: FocusEvent) {
|
||||
|
||||
.actions {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user