feat(editor): Make highlighted data pane floating (#10638)

Co-authored-by: oleg <me@olegivaniv.com>
This commit is contained in:
Eugene 2024-09-04 13:11:33 +02:00 committed by GitHub
parent 0f91fd2b2e
commit 8b5c333d3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 119 additions and 189 deletions

View file

@ -182,7 +182,7 @@ onClickOutside(
() => { () => {
emit('blur'); emit('blur');
}, },
{ ignore: [`.tags-dropdown-${dropdownId}`, '#tags-manager-modal'] }, { ignore: [`.tags-dropdown-${dropdownId}`, '#tags-manager-modal'], detectIframe: true },
); );
</script> </script>
@ -199,7 +199,7 @@ onClickOutside(
multiple multiple
:reserve-keyword="false" :reserve-keyword="false"
loading-text="..." loading-text="..."
:popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId]" :popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId].join(' ')"
data-test-id="tags-dropdown" data-test-id="tags-dropdown"
@update:model-value="onTagsUpdated" @update:model-value="onTagsUpdated"
@visible-change="onVisibleChange" @visible-change="onVisibleChange"

View file

@ -1,16 +1,32 @@
<script lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue';
import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow'; import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow';
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import { useExecutionsStore } from '@/stores/executions.store'; import { useExecutionsStore } from '@/stores/executions.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue'; import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
import VoteButtons from '@/components/executions/workflow/VoteButtons.vue'; import VoteButtons from '@/components/executions/workflow/VoteButtons.vue';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
const hasChanged = (prev: string[], curr: string[]) => { const executionsStore = useExecutionsStore();
const { showError } = useToast();
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>;
};
});
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) { if (prev.length !== curr.length) {
return true; return true;
} }
@ -19,129 +35,79 @@ const hasChanged = (prev: string[], curr: string[]) => {
return curr.reduce((acc, val) => acc || !set.has(val), false); return curr.reduce((acc, val) => acc || !set.has(val), false);
}; };
export default defineComponent({ const onVoteClick = async (voteValue: AnnotationVote) => {
name: 'WorkflowExecutionAnnotationSidebar', if (!activeExecution.value) {
components: { return;
VoteButtons, }
AnnotationTagsDropdown,
},
props: {
execution: {
type: Object as PropType<ExecutionSummary>,
default: null,
},
loading: {
type: Boolean,
default: true,
},
},
computed: { const voteToSet = voteValue === vote.value ? null : voteValue;
...mapStores(useExecutionsStore, useWorkflowsStore),
vote() {
return this.activeExecution?.annotation?.vote || null;
},
activeExecution() {
// FIXME: this is a temporary workaround to make TS happy. activeExecution may contain customData, but it is type-casted to ExecutionSummary after fetching from the backend
return this.executionsStore.activeExecution as ExecutionSummary & {
customData?: Record<string, string>;
};
},
tagIds() {
return this.activeExecution?.annotation?.tags.map((tag) => tag.id) ?? [];
},
tags() {
return this.activeExecution?.annotation?.tags;
},
},
setup() {
return {
...useToast(),
};
},
data() {
return {
tagsEventBus: createEventBus(),
isTagsEditEnabled: false,
appliedTagIds: [] as string[],
tagsSaving: false,
};
},
methods: {
async onVoteClick(vote: AnnotationVote) {
if (!this.activeExecution) {
return;
}
// If user clicked on the same vote, remove it try {
// so that vote buttons act as toggle buttons await executionsStore.annotateExecution(activeExecution.value.id, { vote: voteToSet });
const voteToSet = vote === this.vote ? null : vote; } catch (e) {
showError(e, 'executionAnnotationView.vote.error');
}
};
try { const onTagsEditEnable = () => {
await this.executionsStore.annotateExecution(this.activeExecution.id, { vote: voteToSet }); appliedTagIds.value = tagIds.value;
} catch (e) { isTagsEditEnabled.value = true;
this.showError(e, this.$locale.baseText('executionAnnotationView.vote.error'));
}
},
onTagsEditEnable() {
this.appliedTagIds = this.tagIds;
this.isTagsEditEnabled = true;
setTimeout(() => { setTimeout(() => {
this.tagsEventBus.emit('focus'); tagsEventBus.emit('focus');
}, 0); }, 0);
}, };
async onTagsBlur() {
if (!this.activeExecution) {
return;
}
const current = (this.tagIds ?? []) as string[]; const onTagsBlur = async () => {
const tags = this.appliedTagIds; if (!activeExecution.value) {
return;
}
if (!hasChanged(current, tags)) { const currentTagIds = tagIds.value ?? [];
this.isTagsEditEnabled = false; const newTagIds = appliedTagIds.value;
return;
}
if (this.tagsSaving) { if (!tagsHasChanged(currentTagIds, newTagIds)) {
return; isTagsEditEnabled.value = false;
} return;
}
this.tagsSaving = true; if (tagsSaving.value) {
return;
}
try { tagsSaving.value = true;
await this.executionsStore.annotateExecution(this.activeExecution.id, { tags });
} catch (e) {
this.showError(e, this.$locale.baseText('executionAnnotationView.tag.error'));
}
this.tagsSaving = false; try {
this.isTagsEditEnabled = false; await executionsStore.annotateExecution(activeExecution.value.id, { tags: newTagIds });
}, } catch (e) {
onTagsEditEsc() { showError(e, 'executionAnnotationView.tag.error');
this.isTagsEditEnabled = false; }
},
}, tagsSaving.value = false;
}); isTagsEditEnabled.value = false;
};
const onTagsEditEsc = () => {
isTagsEditEnabled.value = false;
};
</script> </script>
<template> <template>
<div <div
ref="container" ref="container"
:class="['execution-annotation-sidebar', $style.container]" :class="['execution-annotation-panel', $style.container]"
data-test-id="execution-annotation-sidebar" data-test-id="execution-annotation-panel"
> >
<div :class="$style.section"> <div :class="$style.section">
<div :class="$style.vote"> <div :class="$style.vote">
<div>{{ $locale.baseText('generic.rating') }}</div> <div>{{ $locale.baseText('generic.rating') }}</div>
<VoteButtons :vote="vote" @vote-click="onVoteClick" /> <VoteButtons :vote="vote" @vote-click="onVoteClick" />
</div> </div>
<span class="tags" data-test-id="annotation-tags-container"> <span :class="$style.tags" data-test-id="annotation-tags-container">
<AnnotationTagsDropdown <AnnotationTagsDropdown
v-if="isTagsEditEnabled" v-if="isTagsEditEnabled"
v-model="appliedTagIds"
ref="dropdown" ref="dropdown"
v-model="appliedTagIds"
:create-enabled="true" :create-enabled="true"
:event-bus="tagsEventBus" :event-bus="tagsEventBus"
:placeholder="$locale.baseText('executionAnnotationView.chooseOrCreateATag')" :placeholder="$locale.baseText('executionAnnotationView.chooseOrCreateATag')"
@ -152,7 +118,7 @@ export default defineComponent({
/> />
<div v-else-if="tagIds.length === 0"> <div v-else-if="tagIds.length === 0">
<span <span
class="add-tag add-tag-standalone clickable" :class="[$style.addTag, $style.addTagStandalone, 'clickable']"
data-test-id="new-tag-link" data-test-id="new-tag-link"
@click="onTagsEditEnable" @click="onTagsEditEnable"
> >
@ -162,7 +128,10 @@ export default defineComponent({
<span <span
v-else v-else
class="tags-container" :class="[
'tags-container', // FIXME: There are some global styles for tags relying on this classname
$style.tagsContainer,
]"
data-test-id="execution-annotation-tags" data-test-id="execution-annotation-tags"
@click="onTagsEditEnable" @click="onTagsEditEnable"
> >
@ -171,9 +140,9 @@ export default defineComponent({
{{ tag.name }} {{ tag.name }}
</el-tag> </el-tag>
</span> </span>
<span class="add-tag-wrapper"> <span :class="$style.addTagWrapper">
<n8n-button <n8n-button
class="add-tag" :class="$style.addTag"
:label="`+ ` + $locale.baseText('executionAnnotationView.addTag')" :label="`+ ` + $locale.baseText('executionAnnotationView.addTag')"
type="secondary" type="secondary"
size="mini" size="mini"
@ -208,7 +177,7 @@ export default defineComponent({
</n8n-text> </n8n-text>
</div> </div>
</div> </div>
<div v-else :class="$style.noResultsContainer" data-test-id="execution-list-empty"> <div v-else :class="$style.noResultsContainer" data-test-id="execution-annotation-data-empty">
<n8n-text color="text-base" size="small" align="center"> <n8n-text color="text-base" size="small" align="center">
<span v-html="$locale.baseText('executionAnnotationView.data.notFound')" /> <span v-html="$locale.baseText('executionAnnotationView.data.notFound')" />
</n8n-text> </n8n-text>
@ -219,23 +188,29 @@ export default defineComponent({
<style module lang="scss"> <style module lang="scss">
.container { .container {
flex: 250px 0 0;
background-color: var(--color-background-xlight);
border-left: var(--border-base);
z-index: 1; z-index: 1;
position: absolute;
bottom: 0;
right: var(--spacing-xl);
transform: translate(0, 100%);
max-height: calc(100vh - 250px);
width: 250px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: auto;
background-color: var(--color-background-xlight);
border: var(--border-base);
border-radius: var(--border-radius-base);
} }
.section { .section {
padding: var(--spacing-l); padding: var(--spacing-s);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
&:not(:last-child) { &:not(:last-child) {
display: flex;
padding-bottom: var(--spacing-l);
border-bottom: var(--border-base); border-bottom: var(--border-base);
} }
} }
@ -296,57 +271,19 @@ export default defineComponent({
} }
} }
.executionList {
flex: 1;
overflow: auto;
margin-bottom: var(--spacing-m);
background-color: var(--color-background-xlight) !important;
// Scrolling fader
&::before {
position: absolute;
display: block;
width: 270px;
height: 6px;
background: linear-gradient(to bottom, rgba(251, 251, 251, 1) 0%, rgba(251, 251, 251, 0) 100%);
z-index: 999;
}
// Lower first execution card so fader is not visible when not scrolled
& > div:first-child {
margin-top: 3px;
}
}
.infoAccordion {
position: absolute;
bottom: 0;
margin-left: calc(-1 * var(--spacing-l));
border-top: var(--border-base);
& > div {
width: 309px;
background-color: var(--color-background-light);
margin-top: 0 !important;
}
}
.noResultsContainer { .noResultsContainer {
width: 100%; width: 100%;
margin-top: var(--spacing-s); margin-top: var(--spacing-s);
//text-align: center;
} }
</style>
<style lang="scss" scoped> .execution-annotation-panel {
.execution-annotation-sidebar {
:deep(.el-skeleton__item) { :deep(.el-skeleton__item) {
height: 60px; height: 60px;
border-radius: 0; border-radius: 0;
} }
} }
.tags-container { .tagsContainer {
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@ -358,10 +295,10 @@ export default defineComponent({
} }
} }
.add-tag { .addTag {
font-size: 12px; font-size: var(--font-size-2xs);
color: $custom-font-very-light; color: $custom-font-very-light;
font-weight: 600; font-weight: var(--font-weight-bold);
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {
color: $color-primary; color: $color-primary;
@ -369,11 +306,11 @@ export default defineComponent({
} }
} }
.add-tag-standalone { .addTagStandalone {
padding: 20px 0; // to be more clickable padding: var(--spacing-m) 0; // to be more clickable
} }
.add-tag-wrapper { .addTagWrapper {
margin-left: calc(var(--spacing-2xs) * -1); // Cancel out right margin of last tag margin-left: calc(var(--spacing-2xs) * -1); // Cancel out right margin of last tag
} }
</style> </style>

View file

@ -2,18 +2,11 @@
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router'; import { onBeforeRouteLeave, useRouter } from 'vue-router';
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue'; import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
import { import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
EnterpriseEditionFeature,
EXECUTION_ANNOTATION_EXPERIMENT,
MAIN_HEADER_TABS,
VIEWS,
} from '@/constants';
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface'; import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import type { ExecutionSummary } from 'n8n-workflow'; import type { ExecutionSummary } from 'n8n-workflow';
import { getNodeViewTab } from '@/utils/canvasUtils'; import { getNodeViewTab } from '@/utils/canvasUtils';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -43,18 +36,6 @@ const emit = defineEmits<{
const workflowHelpers = useWorkflowHelpers({ router: useRouter() }); const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
const router = useRouter(); const router = useRouter();
const posthogStore = usePostHog();
const settingsStore = useSettingsStore();
const isAdvancedExecutionFilterEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
);
const isAnnotationEnabled = computed(
() =>
isAdvancedExecutionFilterEnabled.value &&
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
);
const temporaryExecution = computed<ExecutionSummary | undefined>(() => const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
props.executions.find((execution) => execution.id === props.execution?.id) props.executions.find((execution) => execution.id === props.execution?.id)
? undefined ? undefined
@ -135,10 +116,6 @@ onBeforeRouteLeave(async (to, _, next) => {
@stop-execution="onStopExecution" @stop-execution="onStopExecution"
/> />
</div> </div>
<WorkflowExecutionAnnotationSidebar
v-if="isAnnotationEnabled && execution"
:execution="execution"
/>
</div> </div>
</template> </template>

View file

@ -5,13 +5,20 @@ import { ElDropdown } from 'element-plus';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging'; import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import WorkflowPreview from '@/components/WorkflowPreview.vue'; import WorkflowPreview from '@/components/WorkflowPreview.vue';
import { MODAL_CONFIRM, VIEWS } from '@/constants'; import {
EnterpriseEditionFeature,
EXECUTION_ANNOTATION_EXPERIMENT,
MODAL_CONFIRM,
VIEWS,
} from '@/constants';
import type { ExecutionSummary } from 'n8n-workflow'; import type { ExecutionSummary } from 'n8n-workflow';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers'; import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers'; import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
type RetryDropdownRef = InstanceType<typeof ElDropdown>; type RetryDropdownRef = InstanceType<typeof ElDropdown>;
@ -32,6 +39,8 @@ const executionHelpers = useExecutionHelpers();
const message = useMessage(); const message = useMessage();
const executionDebugging = useExecutionDebugging(); const executionDebugging = useExecutionDebugging();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const posthogStore = usePostHog();
const settingsStore = useSettingsStore();
const retryDropdownRef = ref<RetryDropdownRef | null>(null); const retryDropdownRef = ref<RetryDropdownRef | null>(null);
const workflowId = computed(() => route.params.name as string); const workflowId = computed(() => route.params.name as string);
@ -57,6 +66,12 @@ const isRetriable = computed(
() => !!props.execution && executionHelpers.isExecutionRetriable(props.execution), () => !!props.execution && executionHelpers.isExecutionRetriable(props.execution),
); );
const isAnnotationEnabled = computed(
() =>
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters] &&
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
);
async function onDeleteExecution(): Promise<void> { async function onDeleteExecution(): Promise<void> {
const deleteConfirmed = await message.confirm( const deleteConfirmed = await message.confirm(
locale.baseText('executionDetails.confirmMessage.message'), locale.baseText('executionDetails.confirmMessage.message'),
@ -115,6 +130,7 @@ 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> <div>
<N8nText size="large" color="text-base" :bold="true" data-test-id="execution-time">{{ <N8nText size="large" color="text-base" :bold="true" data-test-id="execution-time">{{
executionUIDetails?.startTime executionUIDetails?.startTime