mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +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:
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -710,6 +710,7 @@
|
|||||||
"error.pageNotFound": "Oops, couldn’t find that",
|
"error.pageNotFound": "Oops, couldn’t 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?",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user