9024 workflow test serverless function follow up (#9066)
- Fix Tablist style - Fix dropdown style (wrong grey background) - Update dropdown variable when no outputSchema is available https://github.com/user-attachments/assets/56698fe8-8dd3-404a-b2b2-f1eca6f5fa28
This commit is contained in:
@ -10,14 +10,22 @@ describe('getFunctionOutputSchema', () => {
|
|||||||
e: [1, 2, 3],
|
e: [1, 2, 3],
|
||||||
};
|
};
|
||||||
const expectedOutputSchema = {
|
const expectedOutputSchema = {
|
||||||
a: { isLeaf: true, type: 'unknown', value: null },
|
a: { isLeaf: true, type: 'unknown', value: null, icon: 'IconVariable' },
|
||||||
b: { isLeaf: true, type: 'string', value: 'b' },
|
b: { isLeaf: true, type: 'string', value: 'b', icon: 'IconVariable' },
|
||||||
c: {
|
c: {
|
||||||
isLeaf: false,
|
isLeaf: false,
|
||||||
value: { cc: { isLeaf: true, type: 'number', value: 1 } },
|
icon: 'IconVariable',
|
||||||
|
value: {
|
||||||
|
cc: { isLeaf: true, type: 'number', value: 1, icon: 'IconVariable' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
d: { isLeaf: true, type: 'boolean', value: true, icon: 'IconVariable' },
|
||||||
|
e: {
|
||||||
|
isLeaf: true,
|
||||||
|
type: 'array',
|
||||||
|
value: [1, 2, 3],
|
||||||
|
icon: 'IconVariable',
|
||||||
},
|
},
|
||||||
d: { isLeaf: true, type: 'boolean', value: true },
|
|
||||||
e: { isLeaf: true, type: 'array', value: [1, 2, 3] },
|
|
||||||
};
|
};
|
||||||
expect(getFunctionOutputSchema(testResult)).toEqual(expectedOutputSchema);
|
expect(getFunctionOutputSchema(testResult)).toEqual(expectedOutputSchema);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export const getFunctionOutputSchema = (testResult: object) => {
|
|||||||
if (isObject(value) && !Array.isArray(value)) {
|
if (isObject(value) && !Array.isArray(value)) {
|
||||||
acc[key] = {
|
acc[key] = {
|
||||||
isLeaf: false,
|
isLeaf: false,
|
||||||
|
icon: 'IconVariable',
|
||||||
value: getFunctionOutputSchema(value),
|
value: getFunctionOutputSchema(value),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -39,6 +40,7 @@ export const getFunctionOutputSchema = (testResult: object) => {
|
|||||||
isLeaf: true,
|
isLeaf: true,
|
||||||
value,
|
value,
|
||||||
type: getValueType(value),
|
type: getValueType(value),
|
||||||
|
icon: 'IconVariable',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
|
|||||||
@ -8,9 +8,15 @@ import {
|
|||||||
UpdateOneServerlessFunctionMutationVariables,
|
UpdateOneServerlessFunctionMutationVariables,
|
||||||
UpdateServerlessFunctionInput,
|
UpdateServerlessFunctionInput,
|
||||||
} from '~/generated-metadata/graphql';
|
} from '~/generated-metadata/graphql';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FIND_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunction';
|
||||||
|
import { sleep } from '~/utils/sleep';
|
||||||
|
|
||||||
export const useUpdateOneServerlessFunction = () => {
|
export const useUpdateOneServerlessFunction = (
|
||||||
|
serverlessFunctionId: string,
|
||||||
|
) => {
|
||||||
const apolloMetadataClient = useApolloMetadataClient();
|
const apolloMetadataClient = useApolloMetadataClient();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [mutate] = useMutation<
|
const [mutate] = useMutation<
|
||||||
UpdateOneServerlessFunctionMutation,
|
UpdateOneServerlessFunctionMutation,
|
||||||
UpdateOneServerlessFunctionMutationVariables
|
UpdateOneServerlessFunctionMutationVariables
|
||||||
@ -19,18 +25,48 @@ export const useUpdateOneServerlessFunction = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateOneServerlessFunction = async (
|
const updateOneServerlessFunction = async (
|
||||||
input: UpdateServerlessFunctionInput,
|
input: Omit<UpdateServerlessFunctionInput, 'id'>,
|
||||||
) => {
|
) => {
|
||||||
return await mutate({
|
const result = await mutate({
|
||||||
variables: {
|
variables: {
|
||||||
input,
|
input: { ...input, id: serverlessFunctionId },
|
||||||
},
|
},
|
||||||
awaitRefetchQueries: true,
|
awaitRefetchQueries: true,
|
||||||
refetchQueries: [
|
refetchQueries: [
|
||||||
getOperationName(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE) ?? '',
|
getOperationName(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE) ?? '',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
setIsReady(false);
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { updateOneServerlessFunction };
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const pollFunctionStatus = async () => {
|
||||||
|
while (isMounted && !isReady) {
|
||||||
|
const { data } = await apolloMetadataClient.query({
|
||||||
|
query: FIND_ONE_SERVERLESS_FUNCTION,
|
||||||
|
variables: { input: { id: serverlessFunctionId } },
|
||||||
|
fetchPolicy: 'network-only', // Always fetch fresh data
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverlessFunction = data?.findOneServerlessFunction;
|
||||||
|
|
||||||
|
if (serverlessFunction?.syncStatus === 'READY') {
|
||||||
|
setIsReady(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pollFunctionStatus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false; // Cleanup when the component unmounts
|
||||||
|
};
|
||||||
|
}, [serverlessFunctionId, apolloMetadataClient, isReady]);
|
||||||
|
|
||||||
|
return { updateOneServerlessFunction, isReady };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import styled from '@emotion/styled';
|
|||||||
import { ReactElement } from 'react';
|
import { ReactElement } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { IconComponent, Pill } from 'twenty-ui';
|
import { IconComponent, Pill } from 'twenty-ui';
|
||||||
|
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
|
||||||
|
|
||||||
type TabProps = {
|
type TabProps = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -93,7 +94,7 @@ export const Tab = ({
|
|||||||
<StyledHover>
|
<StyledHover>
|
||||||
{logo && <StyledLogo src={logo} alt={`${title} logo`} />}
|
{logo && <StyledLogo src={logo} alt={`${title} logo`} />}
|
||||||
{Icon && <Icon size={theme.icon.size.md} />}
|
{Icon && <Icon size={theme.icon.size.md} />}
|
||||||
{title}
|
<EllipsisDisplay>{title}</EllipsisDisplay>
|
||||||
{pill && typeof pill === 'string' ? <Pill label={pill} /> : pill}
|
{pill && typeof pill === 'string' ? <Pill label={pill} /> : pill}
|
||||||
</StyledHover>
|
</StyledHover>
|
||||||
</StyledTab>
|
</StyledTab>
|
||||||
|
|||||||
@ -37,6 +37,10 @@ const StyledTabsContainer = styled.div`
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
@ -52,10 +56,10 @@ export const TabList = ({
|
|||||||
}: TabListProps) => {
|
}: TabListProps) => {
|
||||||
const visibleTabs = tabs.filter((tab) => !tab.hide);
|
const visibleTabs = tabs.filter((tab) => !tab.hide);
|
||||||
|
|
||||||
const initialActiveTabId = visibleTabs[0]?.id || '';
|
|
||||||
|
|
||||||
const { activeTabId, setActiveTabId } = useTabList(tabListInstanceId);
|
const { activeTabId, setActiveTabId } = useTabList(tabListInstanceId);
|
||||||
|
|
||||||
|
const initialActiveTabId = activeTabId || visibleTabs[0]?.id || '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTabId(initialActiveTabId);
|
setActiveTabId(initialActiveTabId);
|
||||||
}, [initialActiveTabId, setActiveTabId]);
|
}, [initialActiveTabId, setActiveTabId]);
|
||||||
|
|||||||
@ -13,11 +13,11 @@ const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe('useTriggerNodeSelection', () => {
|
describe('useTriggerNodeSelection', () => {
|
||||||
const mockUpdateNode = jest.fn();
|
const mockSetNodes = jest.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(useReactFlow as jest.Mock).mockReturnValue({
|
(useReactFlow as jest.Mock).mockReturnValue({
|
||||||
updateNode: mockUpdateNode,
|
setNodes: mockSetNodes,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -51,7 +51,6 @@ describe('useTriggerNodeSelection', () => {
|
|||||||
result.current.setWorkflowDiagramTriggerNodeSelection(mockNodeId);
|
result.current.setWorkflowDiagramTriggerNodeSelection(mockNodeId);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockUpdateNode).toHaveBeenCalledWith(mockNodeId, { selected: true });
|
|
||||||
expect(result.current.workflowDiagramTriggerNodeSelection).toBeUndefined();
|
expect(result.current.workflowDiagramTriggerNodeSelection).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -61,6 +60,6 @@ describe('useTriggerNodeSelection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Ensure updateNode is not called when state is undefined
|
// Ensure updateNode is not called when state is undefined
|
||||||
expect(mockUpdateNode).not.toHaveBeenCalled();
|
expect(mockSetNodes).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
||||||
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
|
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
|
||||||
import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState';
|
import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState';
|
||||||
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
|
|
||||||
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
|
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
|
||||||
import {
|
import {
|
||||||
WorkflowStepType,
|
WorkflowStepType,
|
||||||
@ -25,10 +24,6 @@ export const useCreateStep = ({
|
|||||||
workflowCreateStepFromParentStepIdState,
|
workflowCreateStepFromParentStepIdState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
|
|
||||||
workflowDiagramTriggerNodeSelectionState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
|
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
|
||||||
|
|
||||||
const createStep = async (newStepType: WorkflowStepType) => {
|
const createStep = async (newStepType: WorkflowStepType) => {
|
||||||
@ -51,15 +46,6 @@ export const useCreateStep = ({
|
|||||||
|
|
||||||
setWorkflowSelectedNode(createdStep.id);
|
setWorkflowSelectedNode(createdStep.id);
|
||||||
openRightDrawer(RightDrawerPages.WorkflowStepEdit);
|
openRightDrawer(RightDrawerPages.WorkflowStepEdit);
|
||||||
|
|
||||||
/**
|
|
||||||
* After the step has been created, select it.
|
|
||||||
* As the `insertNodeAndSave` function mutates the cached workflow before resolving,
|
|
||||||
* we are sure that the new node will have been created at this stage.
|
|
||||||
*
|
|
||||||
* Selecting the node will cause a right drawer to open in order to edit the step.
|
|
||||||
*/
|
|
||||||
setWorkflowDiagramTriggerNodeSelection(createdStep.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -21,9 +21,12 @@ export const useTriggerNodeSelection = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reactflow.updateNode(workflowDiagramTriggerNodeSelection, {
|
reactflow.setNodes((nodes) =>
|
||||||
selected: true,
|
nodes.map((node) => ({
|
||||||
});
|
...node,
|
||||||
|
selected: workflowDiagramTriggerNodeSelection === node.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
setWorkflowDiagramTriggerNodeSelection(undefined);
|
setWorkflowDiagramTriggerNodeSelection(undefined);
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@ -28,7 +28,8 @@ const StyledDropdownVariableButtonContainer = styled(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledDropdownComponentsContainer = styled(DropdownMenuItemsContainer)`
|
const StyledDropdownComponentsContainer = styled(DropdownMenuItemsContainer)`
|
||||||
background-color: ${({ theme }) => theme.background.transparent.light};
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SearchVariablesDropdown = ({
|
const SearchVariablesDropdown = ({
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenu
|
|||||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||||
import {
|
import {
|
||||||
BaseOutputSchema,
|
BaseOutputSchema,
|
||||||
|
LinkOutputSchema,
|
||||||
OutputSchema,
|
OutputSchema,
|
||||||
StepOutputSchema,
|
StepOutputSchema,
|
||||||
} from '@/workflow/search-variables/types/StepOutputSchema';
|
} from '@/workflow/search-variables/types/StepOutputSchema';
|
||||||
@ -13,10 +14,17 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
HorizontalSeparator,
|
HorizontalSeparator,
|
||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
|
isDefined,
|
||||||
MenuItemSelect,
|
MenuItemSelect,
|
||||||
OverflowingTextWithTooltip,
|
OverflowingTextWithTooltip,
|
||||||
useIcons,
|
useIcons,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
|
||||||
|
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
||||||
|
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
|
||||||
|
import { isLinkOutputSchema } from '@/workflow/search-variables/utils/isLinkOutputSchema';
|
||||||
|
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
|
||||||
|
|
||||||
type SearchVariablesDropdownFieldItemsProps = {
|
type SearchVariablesDropdownFieldItemsProps = {
|
||||||
step: StepOutputSchema;
|
step: StepOutputSchema;
|
||||||
@ -33,6 +41,13 @@ export const SearchVariablesDropdownFieldItems = ({
|
|||||||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||||||
const [searchInputValue, setSearchInputValue] = useState('');
|
const [searchInputValue, setSearchInputValue] = useState('');
|
||||||
const { getIcon } = useIcons();
|
const { getIcon } = useIcons();
|
||||||
|
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
|
||||||
|
const { setActiveTabId } = useTabList(
|
||||||
|
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
|
||||||
|
);
|
||||||
|
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
|
||||||
|
workflowDiagramTriggerNodeSelectionState,
|
||||||
|
);
|
||||||
|
|
||||||
const getCurrentSubStep = (): OutputSchema => {
|
const getCurrentSubStep = (): OutputSchema => {
|
||||||
let currentSubStep = step.outputSchema;
|
let currentSubStep = step.outputSchema;
|
||||||
@ -51,7 +66,9 @@ export const SearchVariablesDropdownFieldItems = ({
|
|||||||
const getDisplayedSubStepFields = () => {
|
const getDisplayedSubStepFields = () => {
|
||||||
const currentSubStep = getCurrentSubStep();
|
const currentSubStep = getCurrentSubStep();
|
||||||
|
|
||||||
if (isRecordOutputSchema(currentSubStep)) {
|
if (isLinkOutputSchema(currentSubStep)) {
|
||||||
|
return { link: currentSubStep.link };
|
||||||
|
} else if (isRecordOutputSchema(currentSubStep)) {
|
||||||
return currentSubStep.fields;
|
return currentSubStep.fields;
|
||||||
} else if (isBaseOutputSchema(currentSubStep)) {
|
} else if (isBaseOutputSchema(currentSubStep)) {
|
||||||
return currentSubStep;
|
return currentSubStep;
|
||||||
@ -60,6 +77,7 @@ export const SearchVariablesDropdownFieldItems = ({
|
|||||||
|
|
||||||
const handleSelectField = (key: string) => {
|
const handleSelectField = (key: string) => {
|
||||||
const currentSubStep = getCurrentSubStep();
|
const currentSubStep = getCurrentSubStep();
|
||||||
|
|
||||||
const handleSelectBaseOutputSchema = (
|
const handleSelectBaseOutputSchema = (
|
||||||
baseOutputSchema: BaseOutputSchema,
|
baseOutputSchema: BaseOutputSchema,
|
||||||
) => {
|
) => {
|
||||||
@ -71,7 +89,19 @@ export const SearchVariablesDropdownFieldItems = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isRecordOutputSchema(currentSubStep)) {
|
const handleSelectLinkOutputSchema = (
|
||||||
|
linkOutputSchema: LinkOutputSchema,
|
||||||
|
) => {
|
||||||
|
setWorkflowSelectedNode(step.id);
|
||||||
|
setWorkflowDiagramTriggerNodeSelection(step.id);
|
||||||
|
if (isDefined(linkOutputSchema.link.tab)) {
|
||||||
|
setActiveTabId(linkOutputSchema.link.tab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLinkOutputSchema(currentSubStep)) {
|
||||||
|
handleSelectLinkOutputSchema(currentSubStep);
|
||||||
|
} else if (isRecordOutputSchema(currentSubStep)) {
|
||||||
handleSelectBaseOutputSchema(currentSubStep.fields);
|
handleSelectBaseOutputSchema(currentSubStep.fields);
|
||||||
} else if (isBaseOutputSchema(currentSubStep)) {
|
} else if (isBaseOutputSchema(currentSubStep)) {
|
||||||
handleSelectBaseOutputSchema(currentSubStep);
|
handleSelectBaseOutputSchema(currentSubStep);
|
||||||
|
|||||||
@ -60,6 +60,10 @@ export const SearchVariablesDropdownObjectItems = ({
|
|||||||
const getDisplayedSubStepObject = () => {
|
const getDisplayedSubStepObject = () => {
|
||||||
const currentSubStep = getCurrentSubStep();
|
const currentSubStep = getCurrentSubStep();
|
||||||
|
|
||||||
|
if (!isRecordOutputSchema(currentSubStep)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return currentSubStep.object;
|
return currentSubStep.object;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
MenuItemSelect,
|
MenuItemSelect,
|
||||||
OverflowingTextWithTooltip,
|
OverflowingTextWithTooltip,
|
||||||
|
useIcons,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
|
|
||||||
type SearchVariablesDropdownWorkflowStepItemsProps = {
|
type SearchVariablesDropdownWorkflowStepItemsProps = {
|
||||||
@ -24,6 +25,7 @@ export const SearchVariablesDropdownWorkflowStepItems = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}: SearchVariablesDropdownWorkflowStepItemsProps) => {
|
}: SearchVariablesDropdownWorkflowStepItemsProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { getIcon } = useIcons();
|
||||||
const [searchInputValue, setSearchInputValue] = useState('');
|
const [searchInputValue, setSearchInputValue] = useState('');
|
||||||
|
|
||||||
const { closeDropdown } = useDropdown(dropdownId);
|
const { closeDropdown } = useDropdown(dropdownId);
|
||||||
@ -60,7 +62,7 @@ export const SearchVariablesDropdownWorkflowStepItems = ({
|
|||||||
hovered={false}
|
hovered={false}
|
||||||
onClick={() => onSelect(item.id)}
|
onClick={() => onSelect(item.id)}
|
||||||
text={item.name}
|
text={item.name}
|
||||||
LeftIcon={undefined}
|
LeftIcon={item.icon ? getIcon(item.icon) : undefined}
|
||||||
hasSubMenu
|
hasSubMenu
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -82,6 +82,7 @@ export const useAvailableVariablesInWorkflowStep = ({
|
|||||||
id: previousStep.id,
|
id: previousStep.id,
|
||||||
name: previousStep.name,
|
name: previousStep.name,
|
||||||
outputSchema: filteredOutputSchema,
|
outputSchema: filteredOutputSchema,
|
||||||
|
...(previousStep.type === 'CODE' ? { icon: 'IconCode' } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
|
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
|
||||||
|
|
||||||
export type Leaf = {
|
type Leaf = {
|
||||||
isLeaf: true;
|
isLeaf: true;
|
||||||
type?: InputSchemaPropertyType;
|
type?: InputSchemaPropertyType;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@ -8,13 +8,20 @@ export type Leaf = {
|
|||||||
value: any;
|
value: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Node = {
|
type Node = {
|
||||||
isLeaf: false;
|
isLeaf: false;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
value: OutputSchema;
|
value: OutputSchema;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Link = {
|
||||||
|
isLeaf: true;
|
||||||
|
tab?: string;
|
||||||
|
icon?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BaseOutputSchema = Record<string, Leaf | Node>;
|
export type BaseOutputSchema = Record<string, Leaf | Node>;
|
||||||
|
|
||||||
export type RecordOutputSchema = {
|
export type RecordOutputSchema = {
|
||||||
@ -23,10 +30,19 @@ export type RecordOutputSchema = {
|
|||||||
_outputSchemaType: 'RECORD';
|
_outputSchemaType: 'RECORD';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OutputSchema = BaseOutputSchema | RecordOutputSchema;
|
export type LinkOutputSchema = {
|
||||||
|
link: Link;
|
||||||
|
_outputSchemaType: 'LINK';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutputSchema =
|
||||||
|
| BaseOutputSchema
|
||||||
|
| RecordOutputSchema
|
||||||
|
| LinkOutputSchema;
|
||||||
|
|
||||||
export type StepOutputSchema = {
|
export type StepOutputSchema = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
icon?: string;
|
||||||
outputSchema: OutputSchema;
|
outputSchema: OutputSchema;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { isBaseOutputSchema } from '@/workflow/search-variables/utils/isBaseOutputSchema';
|
import { isBaseOutputSchema } from '@/workflow/search-variables/utils/isBaseOutputSchema';
|
||||||
import { isRecordOutputSchema } from '@/workflow/search-variables/utils/isRecordOutputSchema';
|
import { isRecordOutputSchema } from '@/workflow/search-variables/utils/isRecordOutputSchema';
|
||||||
import { isDefined } from 'twenty-ui';
|
import { isDefined } from 'twenty-ui';
|
||||||
|
import { isLinkOutputSchema } from '@/workflow/search-variables/utils/isLinkOutputSchema';
|
||||||
|
|
||||||
const isValidRecordOutputSchema = (
|
const isValidRecordOutputSchema = (
|
||||||
outputSchema: RecordOutputSchema,
|
outputSchema: RecordOutputSchema,
|
||||||
@ -105,7 +106,9 @@ export const filterOutputSchema = (
|
|||||||
return outputSchema;
|
return outputSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRecordOutputSchema(outputSchema)) {
|
if (isLinkOutputSchema(outputSchema)) {
|
||||||
|
return outputSchema;
|
||||||
|
} else if (isRecordOutputSchema(outputSchema)) {
|
||||||
return filterRecordOutputSchema(outputSchema, objectNameSingularToSelect);
|
return filterRecordOutputSchema(outputSchema, objectNameSingularToSelect);
|
||||||
} else if (isBaseOutputSchema(outputSchema)) {
|
} else if (isBaseOutputSchema(outputSchema)) {
|
||||||
return filterBaseOutputSchema(outputSchema, objectNameSingularToSelect);
|
return filterBaseOutputSchema(outputSchema, objectNameSingularToSelect);
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
import {
|
||||||
|
OutputSchema,
|
||||||
|
LinkOutputSchema,
|
||||||
|
} from '@/workflow/search-variables/types/StepOutputSchema';
|
||||||
|
|
||||||
|
export const isLinkOutputSchema = (
|
||||||
|
outputSchema: OutputSchema,
|
||||||
|
): outputSchema is LinkOutputSchema => {
|
||||||
|
return outputSchema._outputSchemaType === 'LINK';
|
||||||
|
};
|
||||||
@ -15,7 +15,7 @@ import { editor } from 'monaco-editor';
|
|||||||
import { AutoTypings } from 'monaco-editor-auto-typings';
|
import { AutoTypings } from 'monaco-editor-auto-typings';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { CodeEditor, IconCode, isDefined, IconPlayerPlay } from 'twenty-ui';
|
import { CodeEditor, IconCode, IconPlayerPlay, isDefined } from 'twenty-ui';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
||||||
import { TabList } from '@/ui/layout/tab/components/TabList';
|
import { TabList } from '@/ui/layout/tab/components/TabList';
|
||||||
@ -32,6 +32,7 @@ import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctio
|
|||||||
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
|
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
|
||||||
import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput';
|
import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput';
|
||||||
import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields';
|
import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields';
|
||||||
|
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -66,17 +67,19 @@ type ServerlessFunctionInputFormData = {
|
|||||||
[field: string]: string | ServerlessFunctionInputFormData;
|
[field: string]: string | ServerlessFunctionInputFormData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TAB_LIST_COMPONENT_ID = 'serverless-function-code-step';
|
|
||||||
|
|
||||||
export const WorkflowEditActionFormServerlessFunction = ({
|
export const WorkflowEditActionFormServerlessFunction = ({
|
||||||
action,
|
action,
|
||||||
actionOptions,
|
actionOptions,
|
||||||
}: WorkflowEditActionFormServerlessFunctionProps) => {
|
}: WorkflowEditActionFormServerlessFunctionProps) => {
|
||||||
const theme = useTheme();
|
|
||||||
const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
|
|
||||||
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
|
|
||||||
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
|
|
||||||
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
|
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
|
||||||
|
const theme = useTheme();
|
||||||
|
const { activeTabId, setActiveTabId } = useTabList(
|
||||||
|
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
|
||||||
|
);
|
||||||
|
const { updateOneServerlessFunction, isReady } =
|
||||||
|
useUpdateOneServerlessFunction(serverlessFunctionId);
|
||||||
|
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
|
||||||
|
|
||||||
const workflowId = useRecoilValue(workflowIdState);
|
const workflowId = useRecoilValue(workflowIdState);
|
||||||
const workflow = useWorkflowWithCurrentVersion(workflowId);
|
const workflow = useWorkflowWithCurrentVersion(workflowId);
|
||||||
const { availablePackages } = useGetAvailablePackages({
|
const { availablePackages } = useGetAvailablePackages({
|
||||||
@ -112,12 +115,11 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
|||||||
|
|
||||||
const handleSave = useDebouncedCallback(async () => {
|
const handleSave = useDebouncedCallback(async () => {
|
||||||
await updateOneServerlessFunction({
|
await updateOneServerlessFunction({
|
||||||
id: serverlessFunctionId,
|
|
||||||
name: formValues.name,
|
name: formValues.name,
|
||||||
description: formValues.description,
|
description: formValues.description,
|
||||||
code: formValues.code,
|
code: formValues.code,
|
||||||
});
|
});
|
||||||
}, 1_000);
|
}, 500);
|
||||||
|
|
||||||
const onCodeChange = async (newCode: string) => {
|
const onCodeChange = async (newCode: string) => {
|
||||||
if (actionOptions.readonly === true) {
|
if (actionOptions.readonly === true) {
|
||||||
@ -161,7 +163,15 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
|||||||
...action,
|
...action,
|
||||||
settings: {
|
settings: {
|
||||||
...action.settings,
|
...action.settings,
|
||||||
outputSchema: {},
|
outputSchema: {
|
||||||
|
link: {
|
||||||
|
isLeaf: true,
|
||||||
|
icon: 'IconVariable',
|
||||||
|
tab: 'test',
|
||||||
|
label: 'Generate Function Input',
|
||||||
|
},
|
||||||
|
_outputSchemaType: 'LINK',
|
||||||
|
},
|
||||||
input: {
|
input: {
|
||||||
...action.settings.input,
|
...action.settings.input,
|
||||||
serverlessFunctionInput: newMergedInput,
|
serverlessFunctionInput: newMergedInput,
|
||||||
@ -169,7 +179,7 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
1_000,
|
500,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInputChange = async (value: any, path: string[]) => {
|
const handleInputChange = async (value: any, path: string[]) => {
|
||||||
@ -254,7 +264,7 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
|||||||
!loading && (
|
!loading && (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<StyledTabList
|
<StyledTabList
|
||||||
tabListInstanceId={TAB_LIST_COMPONENT_ID}
|
tabListInstanceId={WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
behaveAsLinks={false}
|
behaveAsLinks={false}
|
||||||
/>
|
/>
|
||||||
@ -277,7 +287,7 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
|||||||
readonly={actionOptions.readonly}
|
readonly={actionOptions.readonly}
|
||||||
/>
|
/>
|
||||||
<StyledCodeEditorContainer>
|
<StyledCodeEditorContainer>
|
||||||
<InputLabel>Code</InputLabel>
|
<InputLabel>Code {!isReady && <span>•</span>}</InputLabel>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
height={343}
|
height={343}
|
||||||
value={formValues.code?.[INDEX_FILE_PATH]}
|
value={formValues.code?.[INDEX_FILE_PATH]}
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
export const WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID =
|
||||||
|
'workflow-serverless-function-tab-list-component-id';
|
||||||
@ -33,21 +33,6 @@ import {
|
|||||||
import { SETTINGS_OBJECT_DETAIL_TABS } from '~/pages/settings/data-model/constants/SettingsObjectDetailTabs';
|
import { SETTINGS_OBJECT_DETAIL_TABS } from '~/pages/settings/data-model/constants/SettingsObjectDetailTabs';
|
||||||
import { updatedObjectSlugState } from '~/pages/settings/data-model/states/updatedObjectSlugState';
|
import { updatedObjectSlugState } from '~/pages/settings/data-model/states/updatedObjectSlugState';
|
||||||
|
|
||||||
const StyledTabListContainer = styled.div`
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
|
||||||
height: ${({ theme }) => theme.spacing(10)};
|
|
||||||
.tab-list {
|
|
||||||
padding-left: 0px;
|
|
||||||
}
|
|
||||||
.tab-list > div {
|
|
||||||
padding: ${({ theme }) => theme.spacing(3) + ' 0'};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledContentContainer = styled.div`
|
const StyledContentContainer = styled.div`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -177,15 +162,13 @@ export const SettingsObjectDetailPage = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsPageContainer>
|
<SettingsPageContainer>
|
||||||
<StyledTabListContainer>
|
<TabList
|
||||||
<TabList
|
tabListInstanceId={
|
||||||
tabListInstanceId={
|
SETTINGS_OBJECT_DETAIL_TABS.COMPONENT_INSTANCE_ID
|
||||||
SETTINGS_OBJECT_DETAIL_TABS.COMPONENT_INSTANCE_ID
|
}
|
||||||
}
|
tabs={tabs}
|
||||||
tabs={tabs}
|
className="tab-list"
|
||||||
className="tab-list"
|
/>
|
||||||
/>
|
|
||||||
</StyledTabListContainer>
|
|
||||||
<StyledContentContainer>
|
<StyledContentContainer>
|
||||||
{renderActiveTabContent()}
|
{renderActiveTabContent()}
|
||||||
</StyledContentContainer>
|
</StyledContentContainer>
|
||||||
|
|||||||
@ -32,7 +32,8 @@ export const SettingsServerlessFunctionDetail = () => {
|
|||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
|
const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
|
||||||
const [isCodeValid, setIsCodeValid] = useState(true);
|
const [isCodeValid, setIsCodeValid] = useState(true);
|
||||||
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
|
const { updateOneServerlessFunction } =
|
||||||
|
useUpdateOneServerlessFunction(serverlessFunctionId);
|
||||||
const { publishOneServerlessFunction } = usePublishOneServerlessFunction();
|
const { publishOneServerlessFunction } = usePublishOneServerlessFunction();
|
||||||
const { formValues, setFormValues, loading } =
|
const { formValues, setFormValues, loading } =
|
||||||
useServerlessFunctionUpdateFormState(serverlessFunctionId);
|
useServerlessFunctionUpdateFormState(serverlessFunctionId);
|
||||||
@ -45,7 +46,6 @@ export const SettingsServerlessFunctionDetail = () => {
|
|||||||
|
|
||||||
const handleSave = useDebouncedCallback(async () => {
|
const handleSave = useDebouncedCallback(async () => {
|
||||||
await updateOneServerlessFunction({
|
await updateOneServerlessFunction({
|
||||||
id: serverlessFunctionId,
|
|
||||||
name: formValues.name,
|
name: formValues.name,
|
||||||
description: formValues.description,
|
description: formValues.description,
|
||||||
code: formValues.code,
|
code: formValues.code,
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc
|
|||||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||||
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
|
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
|
||||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
import {
|
||||||
|
ServerlessFunctionEntity,
|
||||||
|
ServerlessFunctionSyncStatus,
|
||||||
|
} from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||||
import { isDefined } from 'src/utils/is-defined';
|
import { isDefined } from 'src/utils/is-defined';
|
||||||
|
|
||||||
export type BuildServerlessFunctionBatchEvent = {
|
export type BuildServerlessFunctionBatchEvent = {
|
||||||
@ -42,10 +45,16 @@ export class BuildServerlessFunctionJob {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isDefined(serverlessFunction)) {
|
if (isDefined(serverlessFunction)) {
|
||||||
|
await this.serverlessFunctionRepository.update(serverlessFunction.id, {
|
||||||
|
syncStatus: ServerlessFunctionSyncStatus.NOT_READY,
|
||||||
|
});
|
||||||
await this.serverlessService.build(
|
await this.serverlessService.build(
|
||||||
serverlessFunction,
|
serverlessFunction,
|
||||||
serverlessFunctionVersion,
|
serverlessFunctionVersion,
|
||||||
);
|
);
|
||||||
|
await this.serverlessFunctionRepository.update(serverlessFunction.id, {
|
||||||
|
syncStatus: ServerlessFunctionSyncStatus.READY,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -277,12 +277,6 @@ export class ServerlessFunctionService {
|
|||||||
serverlessFunctionVersion: 'draft',
|
serverlessFunctionVersion: 'draft',
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
await this.serverlessFunctionRepository.update(
|
|
||||||
existingServerlessFunction.id,
|
|
||||||
{
|
|
||||||
syncStatus: ServerlessFunctionSyncStatus.READY,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.serverlessFunctionRepository.findOneBy({
|
return this.serverlessFunctionRepository.findOneBy({
|
||||||
id: existingServerlessFunction.id,
|
id: existingServerlessFunction.id,
|
||||||
|
|||||||
@ -81,6 +81,15 @@ export class WorkflowVersionStepWorkspaceService {
|
|||||||
valid: false,
|
valid: false,
|
||||||
settings: {
|
settings: {
|
||||||
...BASE_STEP_DEFINITION,
|
...BASE_STEP_DEFINITION,
|
||||||
|
outputSchema: {
|
||||||
|
link: {
|
||||||
|
isLeaf: true,
|
||||||
|
icon: 'IconVariable',
|
||||||
|
tab: 'test',
|
||||||
|
label: 'Generate Function Input',
|
||||||
|
},
|
||||||
|
_outputSchemaType: 'LINK',
|
||||||
|
},
|
||||||
input: {
|
input: {
|
||||||
serverlessFunctionId: newServerlessFunction.id,
|
serverlessFunctionId: newServerlessFunction.id,
|
||||||
serverlessFunctionVersion: 'draft',
|
serverlessFunctionVersion: 'draft',
|
||||||
|
|||||||
@ -15,6 +15,13 @@ export type Node = {
|
|||||||
value: OutputSchema;
|
value: OutputSchema;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Link = {
|
||||||
|
isLeaf: true;
|
||||||
|
tab?: string;
|
||||||
|
icon?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BaseOutputSchema = Record<string, Leaf | Node>;
|
export type BaseOutputSchema = Record<string, Leaf | Node>;
|
||||||
|
|
||||||
export type RecordOutputSchema = {
|
export type RecordOutputSchema = {
|
||||||
@ -23,4 +30,12 @@ export type RecordOutputSchema = {
|
|||||||
_outputSchemaType: 'RECORD';
|
_outputSchemaType: 'RECORD';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OutputSchema = BaseOutputSchema | RecordOutputSchema;
|
export type LinkOutputSchema = {
|
||||||
|
link: Link;
|
||||||
|
_outputSchemaType: 'LINK';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutputSchema =
|
||||||
|
| BaseOutputSchema
|
||||||
|
| RecordOutputSchema
|
||||||
|
| LinkOutputSchema;
|
||||||
|
|||||||
@ -250,6 +250,7 @@ export {
|
|||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUserPlus,
|
IconUserPlus,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
|
IconVariable,
|
||||||
IconVariablePlus,
|
IconVariablePlus,
|
||||||
IconVideo,
|
IconVideo,
|
||||||
IconWand,
|
IconWand,
|
||||||
|
|||||||
Reference in New Issue
Block a user