feat(editor): Add support for changing sticky notes color in new canvas (no-changelog) (#10593)

This commit is contained in:
Alex Grozav 2024-08-29 13:07:39 +03:00 committed by GitHub
parent 821ca16a57
commit c988931898
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 340 additions and 17 deletions

View file

@ -3,11 +3,14 @@ import { ref } from 'vue';
import type {
CanvasNode,
CanvasNodeData,
CanvasNodeEventBusEvents,
CanvasNodeHandleInjectionData,
CanvasNodeInjectionData,
} from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { NodeConnectionType } from 'n8n-workflow';
import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system';
export function createCanvasNodeData({
id = 'node',
@ -89,11 +92,13 @@ export function createCanvasNodeProvide({
label = 'Test Node',
selected = false,
data = {},
eventBus = createEventBus<CanvasNodeEventBusEvents>(),
}: {
id?: string;
label?: string;
selected?: boolean;
data?: Partial<CanvasNodeData>;
eventBus?: EventBus<CanvasNodeEventBusEvents>;
} = {}) {
const props = createCanvasNodeProps({ id, label, selected, data });
return {
@ -102,6 +107,7 @@ export function createCanvasNodeProvide({
label: ref(props.label),
selected: ref(props.selected),
data: ref(props.data),
eventBus: ref(eventBus),
} satisfies CanvasNodeInjectionData,
};
}

View file

@ -1,5 +1,11 @@
<script lang="ts" setup>
import type { CanvasConnection, CanvasNode, CanvasNodeMoveEvent, ConnectStartEvent } from '@/types';
import type {
CanvasConnection,
CanvasNode,
CanvasNodeMoveEvent,
CanvasEventBusEvents,
ConnectStartEvent,
} from '@/types';
import type {
EdgeMouseEvent,
Connection,
@ -65,7 +71,7 @@ const props = withDefaults(
nodes: CanvasNode[];
connections: CanvasConnection[];
controlsPosition?: PanelPosition;
eventBus?: EventBus;
eventBus?: EventBus<CanvasEventBusEvents>;
readOnly?: boolean;
}>(),
{
@ -102,8 +108,8 @@ useKeybindings({
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)),
p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')),
enter: () => emitWithLastSelectedNode((id) => emit('update:node:active', id)),
f2: () => emitWithLastSelectedNode((id) => emit('update:node:name', id)),
enter: emitWithLastSelectedNode((id) => onSetNodeActive(id)),
f2: emitWithLastSelectedNode((id) => emit('update:node:name', id)),
tab: () => emit('create:node', 'tab'),
shift_s: () => emit('create:sticky'),
ctrl_alt_n: () => emit('create:workflow'),
@ -154,6 +160,7 @@ function onNodesChange(events: NodeChange[]) {
}
function onSetNodeActive(id: string) {
props.eventBus.emit('nodes:action', { ids: [id], action: 'update:node:active' });
emit('update:node:active', id);
}
@ -166,7 +173,7 @@ function onSelectNode() {
emit('update:node:selected', lastSelectedNode.value.id);
}
function onSelectNodes(ids: string[]) {
function onSelectNodes({ ids }: CanvasEventBusEvents['nodes:select']) {
clearSelectedNodes();
addSelectedNodes(ids.map(findNode).filter(isPresent));
}
@ -358,9 +365,11 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
case 'toggle_activation':
return emit('update:nodes:enabled', nodeIds);
case 'open':
return emit('update:node:active', nodeIds[0]);
return onSetNodeActive(nodeIds[0]);
case 'rename':
return emit('update:node:name', nodeIds[0]);
case 'change_color':
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
}
}
@ -378,12 +387,12 @@ function minimapNodeClassnameFn(node: CanvasNode) {
onMounted(() => {
props.eventBus.on('fitView', onFitView);
props.eventBus.on('selectNodes', onSelectNodes);
props.eventBus.on('nodes:select', onSelectNodes);
});
onUnmounted(() => {
props.eventBus.off('fitView', onFitView);
props.eventBus.off('selectNodes', onSelectNodes);
props.eventBus.off('nodes:select', onSelectNodes);
});
onPaneReady(async () => {
@ -431,6 +440,7 @@ provide(CanvasKey, {
<Node
v-bind="canvasNodeProps"
:read-only="readOnly"
:event-bus="eventBus"
@delete="onDeleteNode"
@run="onRunNode"
@select="onSelectNode"

View file

@ -6,6 +6,7 @@ import type { IWorkflowDb } from '@/Interface';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system';
import type { CanvasEventBusEvents } from '@/types';
import { STICKY_NODE_TYPE } from '@/constants';
defineOptions({
@ -18,12 +19,12 @@ const props = withDefaults(
workflow: IWorkflowDb;
workflowObject: Workflow;
fallbackNodes?: IWorkflowDb['nodes'];
eventBus?: EventBus;
eventBus?: EventBus<CanvasEventBusEvents>;
readOnly?: boolean;
}>(),
{
id: 'canvas',
eventBus: () => createEventBus(),
eventBus: () => createEventBus<CanvasEventBusEvents>(),
fallbackNodes: () => [],
},
);

View file

@ -1,9 +1,11 @@
<script lang="ts" setup>
import { computed, provide, toRef, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue';
import type {
CanvasConnectionPort,
CanvasElementPortWithRenderData,
CanvasNodeData,
CanvasNodeEventBusEvents,
CanvasEventBusEvents,
} from '@/types';
import { CanvasConnectionMode } from '@/types';
import NodeIcon from '@/components/NodeIcon.vue';
@ -18,9 +20,12 @@ import type { NodeProps, XYPosition } from '@vue-flow/core';
import { Position } from '@vue-flow/core';
import { useCanvas } from '@/composables/useCanvas';
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system';
type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean;
eventBus?: EventBus<CanvasEventBusEvents>;
};
const emit = defineEmits<{
@ -58,6 +63,18 @@ const nodeTypeDescription = computed(() => {
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
});
/**
* Event bus
*/
const canvasNodeEventBus = ref(createEventBus<CanvasNodeEventBusEvents>());
function emitCanvasNodeEvent(event: CanvasEventBusEvents['nodes:action']) {
if (event.ids.includes(props.id)) {
canvasNodeEventBus.value.emit(event.action, event.payload);
}
}
/**
* Inputs
*/
@ -208,6 +225,7 @@ provide(CanvasNodeKey, {
data,
label,
selected,
eventBus: canvasNodeEventBus,
});
const showToolbar = computed(() => {
@ -225,6 +243,14 @@ watch(
emit('select', props.id, value);
},
);
onMounted(() => {
props.eventBus?.on('nodes:action', emitCanvasNodeEvent);
});
onBeforeUnmount(() => {
props.eventBus?.off('nodes:action', emitCanvasNodeEvent);
});
</script>
<template>
@ -272,6 +298,7 @@ watch(
@delete="onDelete"
@toggle="onDisabledToggle"
@run="onRun"
@update="onUpdate"
@open:contextmenu="onOpenContextMenuFromToolbar"
/>
@ -296,6 +323,7 @@ watch(
<style lang="scss" module>
.canvasNode {
&:hover,
&:focus-within,
&.showToolbar {
.canvasNodeToolbar {
opacity: 1;
@ -311,5 +339,10 @@ watch(
transform: translate(-50%, -100%);
opacity: 0;
z-index: 1;
&:focus-within,
&:hover {
opacity: 1;
}
}
</style>

View file

@ -8,6 +8,7 @@ const emit = defineEmits<{
delete: [];
toggle: [];
run: [];
update: [parameters: Record<string, unknown>];
'open:contextmenu': [event: MouseEvent];
}>();
@ -46,6 +47,8 @@ const isDisableNodeVisible = computed(() => {
const isDeleteNodeVisible = computed(() => !props.readOnly);
const isStickyNoteNodeType = computed(() => render.value.type === CanvasNodeRenderType.StickyNote);
function executeNode() {
emit('run');
}
@ -58,6 +61,12 @@ function onDeleteNode() {
emit('delete');
}
function onChangeStickyColor(color: number) {
emit('update', {
color,
});
}
function onOpenContextMenu(event: MouseEvent) {
emit('open:contextmenu', event);
}
@ -97,6 +106,7 @@ function onOpenContextMenu(event: MouseEvent) {
:title="i18n.baseText('node.delete')"
@click="onDeleteNode"
/>
<CanvasNodeStickyColorSelector v-if="isStickyNoteNodeType" @update="onChangeStickyColor" />
<N8nIconButton
data-test-id="overflow-node-button"
type="tertiary"

View file

@ -2,7 +2,7 @@
/* eslint-disable vue/no-multiple-template-root */
import { useCanvasNode } from '@/composables/useCanvasNode';
import type { CanvasNodeStickyNoteRender } from '@/types';
import { ref, computed, useCssModule } from 'vue';
import { ref, computed, useCssModule, onMounted, onBeforeUnmount } from 'vue';
import { NodeResizer } from '@vue-flow/node-resizer';
import type { OnResize } from '@vue-flow/node-resizer/dist/types';
import type { XYPosition } from '@vue-flow/core';
@ -15,11 +15,12 @@ const emit = defineEmits<{
update: [parameters: Record<string, unknown>];
move: [position: XYPosition];
dblclick: [event: MouseEvent];
'open:contextmenu': [event: MouseEvent];
}>();
const $style = useCssModule();
const { id, isSelected, render } = useCanvasNode();
const { id, isSelected, render, eventBus } = useCanvasNode();
const renderOptions = computed(() => render.value.options as CanvasNodeStickyNoteRender['options']);
@ -63,6 +64,30 @@ function onEdit(edit: boolean) {
function onDoubleClick(event: MouseEvent) {
emit('dblclick', event);
}
function onActivate() {
onEdit(true);
}
/**
* Context menu
*/
function openContextMenu(event: MouseEvent) {
emit('open:contextmenu', event);
}
/**
* Lifecycle
*/
onMounted(() => {
eventBus.value?.on('update:node:active', onActivate);
});
onBeforeUnmount(() => {
eventBus.value?.off('update:node:active', onActivate);
});
</script>
<template>
<NodeResizer
@ -80,11 +105,12 @@ function onDoubleClick(event: MouseEvent) {
:height="renderOptions.height"
:width="renderOptions.width"
:model-value="renderOptions.content"
:background="renderOptions.color"
:background-color="renderOptions.color"
:edit-mode="isActive"
@edit="onEdit"
@dblclick="onDoubleClick"
@update:model-value="onInputChange"
@contextmenu="openContextMenu"
/>
</template>

View file

@ -0,0 +1,43 @@
import { fireEvent } from '@testing-library/vue';
import CanvasNodeStickyColorSelector from '@/components/canvas/elements/nodes/toolbar/CanvasNodeStickyColorSelector.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeStickyColorSelector);
describe('CanvasNodeStickyColorSelector', () => {
it('should render trigger correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
const colorSelector = getByTestId('change-sticky-color');
expect(colorSelector).toBeVisible();
});
it('should render all colors and apply selected color correctly', async () => {
const { getByTestId, getAllByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
const colorSelector = getByTestId('change-sticky-color');
await fireEvent.click(colorSelector);
const colorOption = getAllByTestId('color');
const selectedIndex = 2;
await fireEvent.click(colorOption[selectedIndex]);
expect(colorOption).toHaveLength(7);
expect(emitted()).toHaveProperty('update');
expect(emitted().update[0]).toEqual([selectedIndex + 1]);
});
});

View file

@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import type { CanvasNodeStickyNoteRender } from '@/types';
const emit = defineEmits<{
update: [color: number];
}>();
const i18n = useI18n();
const { render, eventBus } = useCanvasNode();
const renderOptions = computed(() => render.value.options as CanvasNodeStickyNoteRender['options']);
const autoHideTimeout = ref<NodeJS.Timeout | null>(null);
const isPopoverVisible = ref(false);
const colors = computed(() => Array.from({ length: 7 }).map((_, index) => index + 1));
function togglePopover() {
isPopoverVisible.value = !isPopoverVisible.value;
}
function hidePopover() {
isPopoverVisible.value = false;
}
function showPopover() {
isPopoverVisible.value = true;
}
function changeColor(index: number) {
emit('update', index);
hidePopover();
}
function onMouseEnter() {
if (autoHideTimeout.value) {
clearTimeout(autoHideTimeout.value);
autoHideTimeout.value = null;
}
}
function onMouseLeave() {
autoHideTimeout.value = setTimeout(() => {
hidePopover();
}, 1000);
}
onMounted(() => {
eventBus.value?.on('update:sticky:color', showPopover);
});
onBeforeUnmount(() => {
eventBus.value?.off('update:sticky:color', showPopover);
});
</script>
<template>
<N8nPopover
effect="dark"
trigger="click"
placement="top"
:popper-class="$style.popover"
:popper-style="{ width: '208px' }"
:visible="isPopoverVisible"
:teleported="false"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<template #reference>
<div
:class="$style.option"
data-test-id="change-sticky-color"
:title="i18n.baseText('node.changeColor')"
@click.stop="togglePopover"
>
<FontAwesomeIcon icon="palette" />
</div>
</template>
<div :class="$style.content">
<div
v-for="color in colors"
:key="color"
data-test-id="color"
:class="[
$style.color,
$style[`sticky-color-${color}`],
renderOptions.color === color ? $style.selected : '',
]"
@click="changeColor(color)"
></div>
</div>
</N8nPopover>
</template>
<style lang="scss" module>
.popover {
min-width: 208px;
margin-bottom: -8px;
margin-left: -2px;
}
.content {
display: flex;
flex-direction: row;
width: fit-content;
gap: var(--spacing-2xs);
}
.color {
width: 20px;
height: 20px;
border-width: 1px;
border-style: solid;
border-color: var(--color-foreground-xdark);
border-radius: 50%;
background: var(--color-sticky-background);
&:hover {
cursor: pointer;
}
&.selected {
box-shadow: 0 0 0 1px var(--color-sticky-background);
}
&.sticky-color-1 {
--color-sticky-background: var(--color-sticky-background-1);
}
&.sticky-color-2 {
--color-sticky-background: var(--color-sticky-background-2);
}
&.sticky-color-3 {
--color-sticky-background: var(--color-sticky-background-3);
}
&.sticky-color-4 {
--color-sticky-background: var(--color-sticky-background-4);
}
&.sticky-color-5 {
--color-sticky-background: var(--color-sticky-background-5);
}
&.sticky-color-6 {
--color-sticky-background: var(--color-sticky-background-6);
}
&.sticky-color-7 {
--color-sticky-background: var(--color-sticky-background-7);
}
}
.option {
display: inline-block;
padding: var(--spacing-3xs);
color: var(--color-text-light);
svg {
width: var(--font-size-s) !important;
}
&:hover {
color: var(--color-primary);
}
}
</style>

View file

@ -63,6 +63,8 @@ export function useCanvasNode() {
const render = computed(() => data.value.render);
const eventBus = computed(() => node?.eventBus.value);
return {
node,
id,
@ -84,5 +86,6 @@ export function useCanvasNode() {
executionWaiting,
executionRunning,
render,
eventBus,
};
}

View file

@ -6,9 +6,10 @@ import type {
NodeConnectionType,
} from 'n8n-workflow';
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
import type { INodeUi } from '@/Interface';
import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { Ref } from 'vue';
import type { PartialBy } from '@/utils/typeHelpers';
import type { EventBus } from 'n8n-design-system';
export type CanvasConnectionPortType = NodeConnectionType;
@ -124,11 +125,29 @@ export interface CanvasInjectionData {
connectingHandle: Ref<ConnectStartEvent | undefined>;
}
export type CanvasNodeEventBusEvents = {
'update:sticky:color': never;
'update:node:active': never;
};
export type CanvasEventBusEvents = {
fitView: never;
'saved:workflow': never;
'open:execution': IExecutionResponse;
'nodes:select': { ids: string[] };
'nodes:action': {
ids: string[];
action: keyof CanvasNodeEventBusEvents;
payload?: CanvasNodeEventBusEvents[keyof CanvasNodeEventBusEvents];
};
};
export interface CanvasNodeInjectionData {
id: Ref<string>;
data: Ref<CanvasNodeData>;
label: Ref<NodeProps['label']>;
selected: Ref<NodeProps['selected']>;
eventBus: Ref<EventBus<CanvasNodeEventBusEvents>>;
}
export interface CanvasNodeHandleInjectionData {

View file

@ -36,6 +36,7 @@ import type {
import type { Connection, ViewportTransform } from '@vue-flow/core';
import type {
CanvasConnectionCreateData,
CanvasEventBusEvents,
CanvasNode,
CanvasNodeMoveEvent,
ConnectStartEvent,
@ -137,7 +138,7 @@ const pushConnectionStore = usePushConnectionStore();
const ndvStore = useNDVStore();
const templatesStore = useTemplatesStore();
const canvasEventBus = createEventBus();
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
route,
@ -1346,7 +1347,7 @@ function fitView() {
}
function selectNodes(ids: string[]) {
setTimeout(() => canvasEventBus.emit('selectNodes', ids));
setTimeout(() => canvasEventBus.emit('nodes:select', { ids }));
}
/**