mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
fix(editor): "Trigger node not found" error when chat message is entered (#14954)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watchEffect, useTemplateRef } from 'vue';
|
import { computed, ref, watchEffect, useTemplateRef, watch } from 'vue';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
|
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
|
||||||
@@ -68,7 +68,7 @@ const {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
displayExecution,
|
displayExecution,
|
||||||
} = useChatState(false, onWindowResize);
|
} = useChatState(false);
|
||||||
|
|
||||||
// Expose internal state for testing
|
// Expose internal state for testing
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -91,6 +91,18 @@ function onPopOut() {
|
|||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
canvasStore.setPanelHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
|
canvasStore.setPanelHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => workflowsStore.logsPanelState,
|
||||||
|
(state) => {
|
||||||
|
if (state !== LOGS_PANEL_STATE.CLOSED) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onWindowResize?.();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import MessageOptionAction from './MessageOptionAction.vue';
|
|||||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
||||||
import ChatInput from '@n8n/chat/components/Input.vue';
|
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { watch, computed, ref } from 'vue';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
|
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
|
||||||
@@ -136,6 +136,18 @@ async function copySessionId() {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.isOpen,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
chatEventBus.emit('focusInput');
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
|||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { type INodeUi } from '@/Interface';
|
import { type INodeUi } from '@/Interface';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||||
@@ -31,11 +30,10 @@ interface ChatState {
|
|||||||
displayExecution: (executionId: string) => void;
|
displayExecution: (executionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChatState(isReadOnly: boolean, onWindowResize?: () => void): ChatState {
|
export function useChatState(isReadOnly: boolean): ChatState {
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const canvasStore = useCanvasStore();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
@@ -44,25 +42,16 @@ export function useChatState(isReadOnly: boolean, onWindowResize?: () => void):
|
|||||||
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||||
|
|
||||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
const canvasNodes = computed(() => workflowsStore.allNodes);
|
|
||||||
const allConnections = computed(() => workflowsStore.allConnections);
|
|
||||||
const logsPanelState = computed(() => workflowsStore.logsPanelState);
|
const logsPanelState = computed(() => workflowsStore.logsPanelState);
|
||||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
|
||||||
// Initialize features with injected dependencies
|
// Initialize features with injected dependencies
|
||||||
const {
|
const { chatTriggerNode, connectedNode, allowFileUploads, allowedFilesMimeTypes } =
|
||||||
chatTriggerNode,
|
useChatTrigger({
|
||||||
connectedNode,
|
workflow,
|
||||||
allowFileUploads,
|
getNodeByName: workflowsStore.getNodeByName,
|
||||||
allowedFilesMimeTypes,
|
getNodeType: nodeTypesStore.getNodeType,
|
||||||
setChatTriggerNode,
|
});
|
||||||
setConnectedNode,
|
|
||||||
} = useChatTrigger({
|
|
||||||
workflow,
|
|
||||||
canvasNodes,
|
|
||||||
getNodeByName: workflowsStore.getNodeByName,
|
|
||||||
getNodeType: nodeTypesStore.getNodeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { sendMessage, isLoading } = useChatMessaging({
|
const { sendMessage, isLoading } = useChatMessaging({
|
||||||
chatTrigger: chatTriggerNode,
|
chatTrigger: chatTriggerNode,
|
||||||
@@ -134,37 +123,6 @@ export function useChatState(isReadOnly: boolean, onWindowResize?: () => void):
|
|||||||
provide(ChatSymbol, chatConfig);
|
provide(ChatSymbol, chatConfig);
|
||||||
provide(ChatOptionsSymbol, chatOptions);
|
provide(ChatOptionsSymbol, chatOptions);
|
||||||
|
|
||||||
// Watchers
|
|
||||||
watch(
|
|
||||||
() => logsPanelState.value,
|
|
||||||
(state) => {
|
|
||||||
if (state !== LOGS_PANEL_STATE.CLOSED) {
|
|
||||||
setChatTriggerNode();
|
|
||||||
setConnectedNode();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
onWindowResize?.();
|
|
||||||
chatEventBus.emit('focusInput');
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => allConnections.value,
|
|
||||||
() => {
|
|
||||||
if (canvasStore.isLoading) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!chatTriggerNode.value) {
|
|
||||||
setChatTriggerNode();
|
|
||||||
}
|
|
||||||
setConnectedNode();
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// This function creates a promise that resolves when the workflow execution completes
|
// This function creates a promise that resolves when the workflow execution completes
|
||||||
// It's used to handle the loading state while waiting for the workflow to finish
|
// It's used to handle the loading state while waiting for the workflow to finish
|
||||||
async function createExecutionPromise() {
|
async function createExecutionPromise() {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ComputedRef, MaybeRef } from 'vue';
|
import type { ComputedRef } from 'vue';
|
||||||
import { ref, computed, unref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import {
|
import {
|
||||||
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
|
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
|
||||||
NodeConnectionTypes,
|
NodeConnectionTypes,
|
||||||
NodeHelpers,
|
NodeHelpers,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { INodeTypeDescription, Workflow, INode, INodeParameters } from 'n8n-workflow';
|
import type { INodeTypeDescription, Workflow, INodeParameters } from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
AI_CATEGORY_AGENTS,
|
AI_CATEGORY_AGENTS,
|
||||||
AI_CATEGORY_CHAINS,
|
AI_CATEGORY_CHAINS,
|
||||||
@@ -18,21 +18,12 @@ import { isChatNode } from '@/components/CanvasChat/utils';
|
|||||||
export interface ChatTriggerDependencies {
|
export interface ChatTriggerDependencies {
|
||||||
getNodeByName: (name: string) => INodeUi | null;
|
getNodeByName: (name: string) => INodeUi | null;
|
||||||
getNodeType: (type: string, version: number) => INodeTypeDescription | null;
|
getNodeType: (type: string, version: number) => INodeTypeDescription | null;
|
||||||
canvasNodes: MaybeRef<INodeUi[]>;
|
|
||||||
workflow: ComputedRef<Workflow>;
|
workflow: ComputedRef<Workflow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChatTrigger({
|
export function useChatTrigger({ getNodeByName, getNodeType, workflow }: ChatTriggerDependencies) {
|
||||||
getNodeByName,
|
const chatTriggerNode = computed(
|
||||||
getNodeType,
|
() => Object.values(workflow.value.nodes).find(isChatNode) ?? null,
|
||||||
canvasNodes,
|
|
||||||
workflow,
|
|
||||||
}: ChatTriggerDependencies) {
|
|
||||||
const chatTriggerName = ref<string | null>(null);
|
|
||||||
const connectedNode = ref<INode | null>(null);
|
|
||||||
|
|
||||||
const chatTriggerNode = computed(() =>
|
|
||||||
chatTriggerName.value ? getNodeByName(chatTriggerName.value) : null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allowFileUploads = computed(() => {
|
const allowFileUploads = computed(() => {
|
||||||
@@ -49,22 +40,12 @@ export function useChatTrigger({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Gets the chat trigger node from the workflow */
|
|
||||||
function setChatTriggerNode() {
|
|
||||||
const triggerNode = unref(canvasNodes).find(isChatNode);
|
|
||||||
|
|
||||||
if (!triggerNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chatTriggerName.value = triggerNode.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the connected node after finding the trigger */
|
/** Sets the connected node after finding the trigger */
|
||||||
function setConnectedNode() {
|
const connectedNode = computed(() => {
|
||||||
const triggerNode = chatTriggerNode.value;
|
const triggerNode = chatTriggerNode.value;
|
||||||
|
|
||||||
if (!triggerNode) {
|
if (!triggerNode) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatChildren = workflow.value.getChildNodes(triggerNode.name);
|
const chatChildren = workflow.value.getChildNodes(triggerNode.name);
|
||||||
@@ -121,15 +102,14 @@ export function useChatTrigger({
|
|||||||
const result = Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
const result = Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
connectedNode.value = chatRootNode ?? null;
|
|
||||||
}
|
return chatRootNode ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowFileUploads,
|
allowFileUploads,
|
||||||
allowedFilesMimeTypes,
|
allowedFilesMimeTypes,
|
||||||
chatTriggerNode,
|
chatTriggerNode,
|
||||||
connectedNode: computed(() => connectedNode.value),
|
connectedNode,
|
||||||
setChatTriggerNode,
|
|
||||||
setConnectedNode,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,17 @@ function isLastChild(level: number) {
|
|||||||
icon="exclamation-triangle"
|
icon="exclamation-triangle"
|
||||||
:class="$style.compactErrorIcon"
|
:class="$style.compactErrorIcon"
|
||||||
/>
|
/>
|
||||||
|
<N8nIconButton
|
||||||
|
v-if="!isCompact || !props.latestInfo?.deleted"
|
||||||
|
type="secondary"
|
||||||
|
size="medium"
|
||||||
|
icon="edit"
|
||||||
|
style="color: var(--color-text-base)"
|
||||||
|
:disabled="props.latestInfo?.deleted"
|
||||||
|
:class="$style.openNdvButton"
|
||||||
|
:aria-label="locale.baseText('logs.overview.body.open')"
|
||||||
|
@click.stop="emit('openNdv', props.data)"
|
||||||
|
/>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
v-if="
|
v-if="
|
||||||
!isCompact ||
|
!isCompact ||
|
||||||
@@ -160,17 +171,6 @@ function isLastChild(level: number) {
|
|||||||
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
|
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
|
||||||
@click.stop="emit('triggerPartialExecution', props.data)"
|
@click.stop="emit('triggerPartialExecution', props.data)"
|
||||||
/>
|
/>
|
||||||
<N8nIconButton
|
|
||||||
v-if="!isCompact || !props.latestInfo?.deleted"
|
|
||||||
type="secondary"
|
|
||||||
size="small"
|
|
||||||
icon="external-link-alt"
|
|
||||||
style="color: var(--color-text-base)"
|
|
||||||
:disabled="props.latestInfo?.deleted"
|
|
||||||
:class="$style.openNdvButton"
|
|
||||||
:aria-label="locale.baseText('logs.overview.body.open')"
|
|
||||||
@click.stop="emit('openNdv', props.data)"
|
|
||||||
/>
|
|
||||||
<N8nButton
|
<N8nButton
|
||||||
v-if="!isCompact || props.data.children.length > 0"
|
v-if="!isCompact || props.data.children.length > 0"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createAiData,
|
createAiData,
|
||||||
createLogEntries,
|
createLogEntries,
|
||||||
|
deepToRaw,
|
||||||
findSelectedLogEntry,
|
findSelectedLogEntry,
|
||||||
getTreeNodeData,
|
getTreeNodeData,
|
||||||
getTreeNodeDataV2,
|
getTreeNodeDataV2,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { type LogEntrySelection } from '../CanvasChat/types/logs';
|
import { type LogEntrySelection } from '../CanvasChat/types/logs';
|
||||||
import { type IExecutionResponse } from '@/Interface';
|
import { type IExecutionResponse } from '@/Interface';
|
||||||
|
import { isReactive, reactive } from 'vue';
|
||||||
|
|
||||||
describe(getTreeNodeData, () => {
|
describe(getTreeNodeData, () => {
|
||||||
it('should generate one node per execution', () => {
|
it('should generate one node per execution', () => {
|
||||||
@@ -608,3 +610,28 @@ describe(createLogEntries, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(deepToRaw, () => {
|
||||||
|
it('should convert reactive fields to raw in data with circular structure', () => {
|
||||||
|
const data = reactive({
|
||||||
|
foo: reactive({ bar: {} }),
|
||||||
|
bazz: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
data.foo.bar = data;
|
||||||
|
data.bazz = data;
|
||||||
|
|
||||||
|
const raw = deepToRaw(data);
|
||||||
|
|
||||||
|
expect(isReactive(data)).toBe(true);
|
||||||
|
expect(isReactive(data.foo)).toBe(true);
|
||||||
|
expect(isReactive(data.foo.bar)).toBe(true);
|
||||||
|
expect(isReactive(data.bazz)).toBe(true);
|
||||||
|
expect(isReactive(raw)).toBe(false);
|
||||||
|
expect(isReactive(raw.foo)).toBe(false);
|
||||||
|
expect(isReactive(raw.foo.bar)).toBe(false);
|
||||||
|
expect(isReactive(raw.bazz)).toBe(false);
|
||||||
|
|
||||||
|
console.log(raw.foo.bar);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -431,8 +431,18 @@ export function findSelectedLogEntry(
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function deepToRaw<T>(sourceObj: T): T {
|
export function deepToRaw<T>(sourceObj: T): T {
|
||||||
|
const seen = new WeakMap();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const objectIterator = (input: any): any => {
|
const objectIterator = (input: any): any => {
|
||||||
|
if (seen.has(input)) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input !== null && typeof input === 'object') {
|
||||||
|
seen.set(input, true);
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
return input.map((item) => objectIterator(item));
|
return input.map((item) => objectIterator(item));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,6 +505,13 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
|
|||||||
}
|
}
|
||||||
|
|
||||||
workflowsStore.executingNode.length = 0;
|
workflowsStore.executingNode.length = 0;
|
||||||
|
|
||||||
|
if (receivedData.type === 'executionFinished') {
|
||||||
|
// As a temporary workaround for https://linear.app/n8n/issue/PAY-2762,
|
||||||
|
// remove runs that is still 'running' status when execution is finished
|
||||||
|
executionData = removeRunningTaskData(executionData as IExecutionResponse);
|
||||||
|
}
|
||||||
|
|
||||||
workflowsStore.setWorkflowExecutionData(executionData as IExecutionResponse);
|
workflowsStore.setWorkflowExecutionData(executionData as IExecutionResponse);
|
||||||
uiStore.removeActiveAction('workflowRunning');
|
uiStore.removeActiveAction('workflowRunning');
|
||||||
|
|
||||||
@@ -647,3 +654,27 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
|
|||||||
retryTimeout,
|
retryTimeout,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeRunningTaskData(execution: IExecutionResponse): IExecutionResponse {
|
||||||
|
if (!execution.data) {
|
||||||
|
return execution;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...execution,
|
||||||
|
data: {
|
||||||
|
...execution.data,
|
||||||
|
resultData: {
|
||||||
|
...execution.data.resultData,
|
||||||
|
runData: Object.fromEntries(
|
||||||
|
Object.entries(execution.data.resultData.runData)
|
||||||
|
.map(([nodeName, runs]) => [
|
||||||
|
nodeName,
|
||||||
|
runs.filter((run) => run.executionStatus !== 'running'),
|
||||||
|
])
|
||||||
|
.filter(([, runs]) => runs.length > 0),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,18 +157,6 @@ export const faPopOut: IconDefinition = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const faTable: IconDefinition = {
|
|
||||||
prefix: 'fas',
|
|
||||||
iconName: 'table' as IconName,
|
|
||||||
icon: [
|
|
||||||
12,
|
|
||||||
12,
|
|
||||||
[],
|
|
||||||
'',
|
|
||||||
'M10.875 0C11.4844 0 12 0.589286 12 1.28571V10.7143C12 11.4375 11.4844 12 10.875 12H1.125C0.492188 12 0 11.4375 0 10.7143V1.28571C0 0.589286 0.492188 0 1.125 0H10.875ZM5.25 10.2857V7.71429H1.5V10.2857H5.25ZM5.25 6V3.42857H1.5V6H5.25ZM10.5 10.2857V7.71429H6.75V10.2857H10.5ZM10.5 6V3.42857H6.75V6H10.5Z',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const faSchema: IconDefinition = {
|
export const faSchema: IconDefinition = {
|
||||||
prefix: 'fas',
|
prefix: 'fas',
|
||||||
iconName: 'schema' as IconName,
|
iconName: 'schema' as IconName,
|
||||||
|
|||||||
Reference in New Issue
Block a user