diff --git a/packages/twenty-front/setupTests.ts b/packages/twenty-front/setupTests.ts index 8f2609b7b3..e5aa41c46c 100644 --- a/packages/twenty-front/setupTests.ts +++ b/packages/twenty-front/setupTests.ts @@ -3,3 +3,14 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; + +/** + * The structuredClone global function is not available in jsdom, it needs to be mocked for now. + * + * The most naive way to mock structuredClone is to use JSON.stringify and JSON.parse. This works + * for arguments with simple types like primitives, arrays and objects, but doesn't work with functions, + * Map, Set, etc. + */ +global.structuredClone = (val) => { + return JSON.parse(JSON.stringify(val)); +}; diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 95c2a58b79..bde6abe9c7 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -244,6 +244,17 @@ const testCases = [ { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, + { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index 6e0d13f48d..dd496e70d3 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -30,4 +30,5 @@ export enum CoreObjectNameSingular { MessageThreadSubscriber = 'messageThreadSubscriber', Workflow = 'workflow', MessageChannelMessageAssociation = 'messageChannelMessageAssociation', + WorkflowVersion = 'workflowVersion', } diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx index ee0a465904..e81f8701e8 100644 --- a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx +++ b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx @@ -254,6 +254,17 @@ const testCases = [ { loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true }, { loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: false }, + { loc: AppPath.WorkflowShowPage, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true }, + { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, + { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false }, { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false }, diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx index bf5ab29beb..972b2a75e3 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx @@ -9,10 +9,11 @@ import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isR import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar'; import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage'; +import { RightDrawerWorkflowEditStep } from '@/workflow/components/RightDrawerWorkflowEditStep'; +import { RightDrawerWorkflowSelectAction } from '@/workflow/components/RightDrawerWorkflowSelectAction'; import { isDefined } from 'twenty-ui'; import { rightDrawerPageState } from '../states/rightDrawerPageState'; import { RightDrawerPages } from '../types/RightDrawerPages'; -import { RightDrawerWorkflow } from '@/workflow/components/RightDrawerWorkflow'; const StyledRightDrawerPage = styled.div` display: flex; @@ -36,7 +37,10 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = { [RightDrawerPages.ViewCalendarEvent]: , [RightDrawerPages.ViewRecord]: , [RightDrawerPages.Copilot]: , - [RightDrawerPages.Workflow]: , + [RightDrawerPages.WorkflowStepSelectAction]: ( + + ), + [RightDrawerPages.WorkflowStepEdit]: , }; export const RightDrawerRouter = () => { diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageIcons.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageIcons.ts index aed0357838..7fc5d9849d 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageIcons.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageIcons.ts @@ -5,5 +5,6 @@ export const RIGHT_DRAWER_PAGE_ICONS = { [RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent', [RightDrawerPages.ViewRecord]: 'Icon123', [RightDrawerPages.Copilot]: 'IconSparkles', - [RightDrawerPages.Workflow]: 'IconSparkles', + [RightDrawerPages.WorkflowStepEdit]: 'IconSparkles', + [RightDrawerPages.WorkflowStepSelectAction]: 'IconSparkles', }; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageTitles.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageTitles.ts index bb74c9da81..749fb10384 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageTitles.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageTitles.ts @@ -5,5 +5,6 @@ export const RIGHT_DRAWER_PAGE_TITLES = { [RightDrawerPages.ViewCalendarEvent]: 'Calendar Event', [RightDrawerPages.ViewRecord]: 'Record Editor', [RightDrawerPages.Copilot]: 'Copilot', - [RightDrawerPages.Workflow]: 'Workflow', + [RightDrawerPages.WorkflowStepEdit]: 'Workflow', + [RightDrawerPages.WorkflowStepSelectAction]: 'Workflow', }; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerPages.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerPages.ts index 2217d437f4..f016669b48 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerPages.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerPages.ts @@ -3,5 +3,6 @@ export enum RightDrawerPages { ViewCalendarEvent = 'view-calendar-event', ViewRecord = 'view-record', Copilot = 'copilot', - Workflow = 'workflow', + WorkflowStepSelectAction = 'workflow-step-select-action', + WorkflowStepEdit = 'workflow-step-edit', } diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflow.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflow.tsx deleted file mode 100644 index b12e749a89..0000000000 --- a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflow.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import styled from '@emotion/styled'; - -const StyledContainer = styled.div` - box-sizing: border-box; - display: flex; - flex-direction: column; - height: 100%; - justify-content: flex-start; - overflow-y: auto; - position: relative; -`; - -const StyledChatArea = styled.div` - flex: 1; - display: flex; - flex-direction: column; - overflow-y: scroll; - padding: ${({ theme }) => theme.spacing(6)}; - padding-bottom: 0px; -`; - -const StyledNewMessageArea = styled.div` - display: flex; - flex-direction: column; - padding: ${({ theme }) => theme.spacing(6)}; - padding-top: 0px; -`; - -export const RightDrawerWorkflow = () => { - const handleCreateCodeBlock = () => {}; - - return ( - - {/* TODO */} - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStep.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStep.tsx new file mode 100644 index 0000000000..96b82e2f00 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStep.tsx @@ -0,0 +1,10 @@ +import { showPageWorkflowSelectedNodeState } from '@/workflow/states/showPageWorkflowSelectedNodeState'; +import { useRecoilValue } from 'recoil'; + +export const RightDrawerWorkflowEditStep = () => { + const showPageWorkflowSelectedNode = useRecoilValue( + showPageWorkflowSelectedNodeState, + ); + + return

{showPageWorkflowSelectedNode}

; +}; diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectAction.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectAction.tsx new file mode 100644 index 0000000000..d99189f66f --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectAction.tsx @@ -0,0 +1,28 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { RightDrawerWorkflowSelectActionContent } from '@/workflow/components/RightDrawerWorkflowSelectActionContent'; +import { showPageWorkflowIdState } from '@/workflow/states/showPageWorkflowIdState'; +import { Workflow } from '@/workflow/types/Workflow'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-ui'; + +export const RightDrawerWorkflowSelectAction = () => { + const showPageWorkflowId = useRecoilValue(showPageWorkflowIdState); + + const { record: workflow } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.Workflow, + objectRecordId: showPageWorkflowId, + recordGqlFields: { + id: true, + name: true, + versions: true, + publishedVersionId: true, + }, + }); + + if (!isDefined(workflow)) { + return null; + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectActionContent.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectActionContent.tsx new file mode 100644 index 0000000000..bc888238b2 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectActionContent.tsx @@ -0,0 +1,60 @@ +import { TabList } from '@/ui/layout/tab/components/TabList'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { useRightDrawerWorkflowSelectAction } from '@/workflow/hooks/useRightDrawerWorkflowSelectAction'; +import { Workflow } from '@/workflow/types/Workflow'; +import styled from '@emotion/styled'; + +// FIXME: copy-pasted +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: 40px; +`; + +const StyledActionListContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + + padding-block: ${({ theme }) => theme.spacing(1)}; + padding-inline: ${({ theme }) => theme.spacing(2)}; +`; + +export const TAB_LIST_COMPONENT_ID = + 'workflow-select-action-page-right-tab-list'; + +export const RightDrawerWorkflowSelectActionContent = ({ + workflow, +}: { + workflow: Workflow; +}) => { + const tabListId = `${TAB_LIST_COMPONENT_ID}`; + + const { tabs, options, handleActionClick } = + useRightDrawerWorkflowSelectAction({ tabListId, workflow }); + + return ( + <> + + + + + + {options.map((option) => ( + { + handleActionClick(option.id); + }} + /> + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagram.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagram.tsx index 090d074ad1..9643aaa7a0 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagram.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagram.tsx @@ -1,4 +1,5 @@ -import { WorkflowShowPageDiagramCreateStepNode } from '@/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx'; +import { WorkflowShowPageDiagramCreateStepNode } from '@/workflow/components/WorkflowShowPageDiagramCreateStepNode'; +import { WorkflowShowPageDiagramEffect } from '@/workflow/components/WorkflowShowPageDiagramEffect'; import { WorkflowShowPageDiagramStepNode } from '@/workflow/components/WorkflowShowPageDiagramStepNode'; import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; import { @@ -80,6 +81,8 @@ export const WorkflowShowPageDiagram = ({ onNodesChange={handleNodesChange} onEdgesChange={handleEdgesChange} > + + ); diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx similarity index 50% rename from packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx.tsx rename to packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx index a799480e81..95fb6e8366 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx @@ -1,6 +1,4 @@ import { IconButton } from '@/ui/input/button/components/IconButton'; -import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; -import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import styled from '@emotion/styled'; import { Handle, Position } from '@xyflow/react'; import { IconPlus } from 'twenty-ui'; @@ -10,17 +8,11 @@ export const StyledTargetHandle = styled(Handle)` `; export const WorkflowShowPageDiagramCreateStepNode = () => { - const { openRightDrawer } = useRightDrawer(); - - const handleCreateStepNodeButtonClick = () => { - openRightDrawer(RightDrawerPages.Workflow); - }; - return ( -
+ <> - -
+ + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramEffect.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramEffect.tsx new file mode 100644 index 0000000000..227d66a8d5 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramEffect.tsx @@ -0,0 +1,81 @@ +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; +import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation'; +import { showPageWorkflowDiagramTriggerNodeSelectionState } from '@/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState'; +import { showPageWorkflowSelectedNodeState } from '@/workflow/states/showPageWorkflowSelectedNodeState'; +import { + WorkflowDiagramEdge, + WorkflowDiagramNode, +} from '@/workflow/types/WorkflowDiagram'; +import { + OnSelectionChangeParams, + useOnSelectionChange, + useReactFlow, +} from '@xyflow/react'; +import { useCallback, useEffect } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-ui'; + +export const WorkflowShowPageDiagramEffect = () => { + const reactflow = useReactFlow(); + + const { startNodeCreation } = useStartNodeCreation(); + + const { openRightDrawer, closeRightDrawer } = useRightDrawer(); + const setShowPageWorkflowSelectedNode = useSetRecoilState( + showPageWorkflowSelectedNodeState, + ); + + const showPageWorkflowDiagramTriggerNodeSelection = useRecoilValue( + showPageWorkflowDiagramTriggerNodeSelectionState, + ); + + const handleSelectionChange = useCallback( + ({ nodes }: OnSelectionChangeParams) => { + const selectedNode = nodes[0] as WorkflowDiagramNode; + const isClosingStep = isDefined(selectedNode) === false; + + if (isClosingStep) { + closeRightDrawer(); + + return; + } + + const isCreateStepNode = selectedNode.type === 'create-step'; + if (isCreateStepNode) { + if (selectedNode.data.nodeType !== 'create-step') { + throw new Error('Expected selected node to be a create step node.'); + } + + startNodeCreation(selectedNode.data.parentNodeId); + + return; + } + + setShowPageWorkflowSelectedNode(selectedNode.id); + openRightDrawer(RightDrawerPages.WorkflowStepEdit); + }, + [ + closeRightDrawer, + openRightDrawer, + setShowPageWorkflowSelectedNode, + startNodeCreation, + ], + ); + + useOnSelectionChange({ + onChange: handleSelectionChange, + }); + + useEffect(() => { + if (!isDefined(showPageWorkflowDiagramTriggerNodeSelection)) { + return; + } + + reactflow.updateNode(showPageWorkflowDiagramTriggerNodeSelection, { + selected: true, + }); + }, [reactflow, showPageWorkflowDiagramTriggerNodeSelection]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageEffect.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageEffect.tsx index 6d41e5d7fd..56d27ffb70 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageEffect.tsx @@ -2,6 +2,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; import { showPageWorkflowErrorState } from '@/workflow/states/showPageWorkflowErrorState'; +import { showPageWorkflowIdState } from '@/workflow/states/showPageWorkflowIdState'; import { showPageWorkflowLoadingState } from '@/workflow/states/showPageWorkflowLoadingState'; import { Workflow } from '@/workflow/types/Workflow'; import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes'; @@ -32,6 +33,7 @@ export const WorkflowShowPageEffect = ({ }, }); + const setShowPageWorkflowId = useSetRecoilState(showPageWorkflowIdState); const setCurrentWorkflowData = useSetRecoilState( showPageWorkflowDiagramState, ); @@ -40,6 +42,10 @@ export const WorkflowShowPageEffect = ({ ); const setCurrentWorkflowError = useSetRecoilState(showPageWorkflowErrorState); + useEffect(() => { + setShowPageWorkflowId(workflowId); + }, [setShowPageWorkflowId, workflowId]); + useEffect(() => { const flowLastVersion = getWorkflowLastDiagramVersion(workflow); const flowWithCreateStepNodes = addCreateStepNodes(flowLastVersion); diff --git a/packages/twenty-front/src/modules/workflow/hooks/useCreateNode.tsx b/packages/twenty-front/src/modules/workflow/hooks/useCreateNode.tsx new file mode 100644 index 0000000000..c4873c9a33 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/hooks/useCreateNode.tsx @@ -0,0 +1,47 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { + Workflow, + WorkflowStep, + WorkflowVersion, +} from '@/workflow/types/Workflow'; +import { getWorkflowLastVersion } from '@/workflow/utils/getWorkflowLastVersion'; +import { insertStep } from '@/workflow/utils/insertStep'; +import { isDefined } from 'twenty-ui'; + +export const useCreateNode = ({ workflow }: { workflow: Workflow }) => { + const { updateOneRecord: updateOneWorkflowVersion } = + useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + }); + + const createNode = ({ + parentNodeId, + nodeToAdd, + }: { + parentNodeId: string; + nodeToAdd: WorkflowStep; + }) => { + const lastVersion = getWorkflowLastVersion(workflow); + if (!isDefined(lastVersion)) { + throw new Error( + "Can't add a node when no version exists yet. Create a first workflow version before trying to add a node.", + ); + } + + return updateOneWorkflowVersion({ + idToUpdate: lastVersion.id, + updateOneRecordInput: { + steps: insertStep({ + steps: lastVersion.steps, + parentStepId: parentNodeId, + stepToAdd: nodeToAdd, + }), + }, + }); + }; + + return { + createNode, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useRightDrawerWorkflowSelectAction.tsx b/packages/twenty-front/src/modules/workflow/hooks/useRightDrawerWorkflowSelectAction.tsx new file mode 100644 index 0000000000..5284de94dd --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/hooks/useRightDrawerWorkflowSelectAction.tsx @@ -0,0 +1,117 @@ +import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; +import { useCreateNode } from '@/workflow/hooks/useCreateNode'; +import { showPageWorkflowDiagramTriggerNodeSelectionState } from '@/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState'; +import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState'; +import { Workflow } from '@/workflow/types/Workflow'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { + IconPlaystationSquare, + IconPlug, + IconPlus, + IconSearch, + IconSettingsAutomation, +} from 'twenty-ui'; +import { v4 } from 'uuid'; + +export const useRightDrawerWorkflowSelectAction = ({ + tabListId, + workflow, +}: { + tabListId: string; + workflow: Workflow; +}) => { + const workflowCreateStepFromParentStepId = useRecoilValue( + workflowCreateStepFromParentStepIdState, + ); + + const setShowPageWorkflowDiagramTriggerNodeSelection = useSetRecoilState( + showPageWorkflowDiagramTriggerNodeSelectionState, + ); + + const { createNode } = useCreateNode({ workflow }); + + const allOptions: Array<{ + id: string; + name: string; + type: 'standard' | 'custom'; + icon: any; + }> = [ + { + id: 'create-record', + name: 'Create Record', + type: 'standard', + icon: IconPlus, + }, + { + id: 'find-records', + name: 'Find Records', + type: 'standard', + icon: IconSearch, + }, + ]; + + const tabs = [ + { + id: 'all', + title: 'All', + Icon: IconSettingsAutomation, + }, + { + id: 'standard', + title: 'Standard', + Icon: IconPlaystationSquare, + }, + { + id: 'custom', + title: 'Custom', + Icon: IconPlug, + }, + ]; + + const { activeTabIdState } = useTabList(tabListId); + const activeTabId = useRecoilValue(activeTabIdState); + + const options = allOptions.filter( + (option) => activeTabId === 'all' || option.type === activeTabId, + ); + + const handleActionClick = async (actionId: string) => { + if (workflowCreateStepFromParentStepId === undefined) { + throw new Error('Select a step to create a new step from first.'); + } + + const newNodeId = v4(); + + /** + * FIXME: For now, the data of the node to create are mostly static. + */ + await createNode({ + parentNodeId: workflowCreateStepFromParentStepId, + nodeToAdd: { + id: newNodeId, + name: actionId, + type: 'CODE_ACTION', + valid: true, + settings: { + serverlessFunctionId: '111', + errorHandlingOptions: { + continueOnFailure: { + value: true, + }, + retryOnFailure: { + value: true, + }, + }, + }, + }, + }); + + setShowPageWorkflowDiagramTriggerNodeSelection(newNodeId); + }; + + return { + tabs, + options, + handleActionClick, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useStartNodeCreation.tsx b/packages/twenty-front/src/modules/workflow/hooks/useStartNodeCreation.tsx new file mode 100644 index 0000000000..54e479cfb6 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/hooks/useStartNodeCreation.tsx @@ -0,0 +1,29 @@ +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; +import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState'; +import { useCallback } from 'react'; +import { useSetRecoilState } from 'recoil'; + +export const useStartNodeCreation = () => { + const { openRightDrawer } = useRightDrawer(); + const setWorkflowCreateStepFromParentStepId = useSetRecoilState( + workflowCreateStepFromParentStepIdState, + ); + + /** + * This function is used in a context where dependencies shouldn't change much. + * That's why its wrapped in a `useCallback` hook. Removing memoization might break the app unexpectedly. + */ + const startNodeCreation = useCallback( + (parentNodeId: string) => { + setWorkflowCreateStepFromParentStepId(parentNodeId); + + openRightDrawer(RightDrawerPages.WorkflowStepSelectAction); + }, + [openRightDrawer, setWorkflowCreateStepFromParentStepId], + ); + + return { + startNodeCreation, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState.ts b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState.ts new file mode 100644 index 0000000000..780b57f3cb --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; + +export const showPageWorkflowDiagramTriggerNodeSelectionState = createState< + string | undefined +>({ + key: 'showPageWorkflowDiagramTriggerNodeSelectionState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/workflow/states/showPageWorkflowIdState.ts b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowIdState.ts new file mode 100644 index 0000000000..a481713abd --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowIdState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const showPageWorkflowIdState = createState({ + key: 'showPageWorkflowIdState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/workflow/states/showPageWorkflowSelectedNodeState.ts b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowSelectedNodeState.ts new file mode 100644 index 0000000000..616c61c6e8 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowSelectedNodeState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; + +export const showPageWorkflowSelectedNodeState = createState< + string | undefined +>({ + key: 'showPageWorkflowSelectedNodeState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/workflow/states/workflowCreateStepFromParentStepIdState.ts b/packages/twenty-front/src/modules/workflow/states/workflowCreateStepFromParentStepIdState.ts new file mode 100644 index 0000000000..4e64010b96 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/states/workflowCreateStepFromParentStepIdState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; + +export const workflowCreateStepFromParentStepIdState = createState< + string | undefined +>({ + key: 'workflowCreateStepFromParentStepId', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/workflow/types/WorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/types/WorkflowDiagram.ts index f97d5027ed..237daab24b 100644 --- a/packages/twenty-front/src/modules/workflow/types/WorkflowDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/types/WorkflowDiagram.ts @@ -13,7 +13,10 @@ export type WorkflowDiagramStepNodeData = { label: string; }; -export type WorkflowDiagramCreateStepNodeData = Record; +export type WorkflowDiagramCreateStepNodeData = { + nodeType: 'create-step'; + parentNodeId: string; +}; export type WorkflowDiagramNodeData = | WorkflowDiagramStepNodeData diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts index 0716aa29bb..8853935765 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts @@ -70,8 +70,10 @@ describe('generateWorkflowDiagram', () => { const stepNodes = result.nodes.slice(1); for (const [index, step] of steps.entries()) { - expect(stepNodes[index].data.nodeType).toBe('action'); - expect(stepNodes[index].data.label).toBe(step.name); + expect(stepNodes[index].data).toEqual({ + nodeType: 'action', + label: step.name, + }); } }); diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/insertStep.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/insertStep.test.ts new file mode 100644 index 0000000000..4479abe6f9 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/insertStep.test.ts @@ -0,0 +1,217 @@ +import { WorkflowStep, WorkflowVersion } from '@/workflow/types/Workflow'; +import { insertStep } from '../insertStep'; + +describe('insertStep', () => { + it('returns a deep copy of the provided steps array instead of mutating it', () => { + const workflowVersionInitial: WorkflowVersion = { + __typename: 'WorkflowVersion', + createdAt: '', + id: '1', + name: '', + steps: [], + trigger: { + settings: { eventName: 'company.created' }, + type: 'DATABASE_EVENT', + }, + updatedAt: '', + workflowId: '', + }; + const stepToAdd: WorkflowStep = { + id: 'step-1', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }; + + const stepsUpdated = insertStep({ + steps: workflowVersionInitial.steps, + stepToAdd, + parentStepId: undefined, + }); + + expect(workflowVersionInitial.steps).not.toBe(stepsUpdated); + }); + + it('adds the step when the steps array is empty', () => { + const workflowVersionInitial: WorkflowVersion = { + __typename: 'WorkflowVersion', + createdAt: '', + id: '1', + name: '', + steps: [], + trigger: { + settings: { eventName: 'company.created' }, + type: 'DATABASE_EVENT', + }, + updatedAt: '', + workflowId: '', + }; + const stepToAdd: WorkflowStep = { + id: 'step-1', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }; + + const stepsUpdated = insertStep({ + steps: workflowVersionInitial.steps, + stepToAdd, + parentStepId: undefined, + }); + + const expectedUpdatedSteps: Array = [stepToAdd]; + expect(stepsUpdated).toEqual(expectedUpdatedSteps); + }); + + it('adds the step at the end of a non-empty steps array', () => { + const workflowVersionInitial: WorkflowVersion = { + __typename: 'WorkflowVersion', + createdAt: '', + id: '1', + name: '', + steps: [ + { + id: 'step-1', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }, + { + id: 'step-2', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }, + ], + trigger: { + settings: { eventName: 'company.created' }, + type: 'DATABASE_EVENT', + }, + updatedAt: '', + workflowId: '', + }; + const stepToAdd: WorkflowStep = { + id: 'step-3', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }; + + const stepsUpdated = insertStep({ + steps: workflowVersionInitial.steps, + stepToAdd, + parentStepId: workflowVersionInitial.steps[1].id, // Note the selected step. + }); + + const expectedUpdatedSteps: Array = [ + workflowVersionInitial.steps[0], + workflowVersionInitial.steps[1], + stepToAdd, + ]; + expect(stepsUpdated).toEqual(expectedUpdatedSteps); + }); + + it('adds the step in the middle of a non-empty steps array', () => { + const workflowVersionInitial: WorkflowVersion = { + __typename: 'WorkflowVersion', + createdAt: '', + id: '1', + name: '', + steps: [ + { + id: 'step-1', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }, + { + id: 'step-2', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }, + ], + trigger: { + settings: { eventName: 'company.created' }, + type: 'DATABASE_EVENT', + }, + updatedAt: '', + workflowId: '', + }; + const stepToAdd: WorkflowStep = { + id: 'step-3', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }; + + const stepsUpdated = insertStep({ + steps: workflowVersionInitial.steps, + stepToAdd, + parentStepId: workflowVersionInitial.steps[0].id, // Note the selected step. + }); + + const expectedUpdatedSteps: Array = [ + workflowVersionInitial.steps[0], + stepToAdd, + workflowVersionInitial.steps[1], + ]; + expect(stepsUpdated).toEqual(expectedUpdatedSteps); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/addCreateStepNodes.ts b/packages/twenty-front/src/modules/workflow/utils/addCreateStepNodes.ts index 9d2941adb2..381d058898 100644 --- a/packages/twenty-front/src/modules/workflow/utils/addCreateStepNodes.ts +++ b/packages/twenty-front/src/modules/workflow/utils/addCreateStepNodes.ts @@ -18,7 +18,10 @@ export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => { const newCreateStepNode: WorkflowDiagramNode = { id: v4(), type: 'create-step', - data: {}, + data: { + nodeType: 'create-step', + parentNodeId: node.id, + }, position: { x: 0, y: 0 }, }; diff --git a/packages/twenty-front/src/modules/workflow/utils/generateWorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/utils/generateWorkflowDiagram.ts index 6bb95f23cb..6399bb995b 100644 --- a/packages/twenty-front/src/modules/workflow/utils/generateWorkflowDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/utils/generateWorkflowDiagram.ts @@ -24,7 +24,7 @@ export const generateWorkflowDiagram = ({ xPos: number, yPos: number, ) => { - const nodeId = v4(); + const nodeId = step.id; nodes.push({ id: nodeId, data: { @@ -58,7 +58,7 @@ export const generateWorkflowDiagram = ({ }; // Start with the trigger node - const triggerNodeId = v4(); + const triggerNodeId = 'trigger'; nodes.push({ id: triggerNodeId, data: { diff --git a/packages/twenty-front/src/modules/workflow/utils/getOrganizedDiagram.ts b/packages/twenty-front/src/modules/workflow/utils/getOrganizedDiagram.ts index 0aa687d710..a5d549c71a 100644 --- a/packages/twenty-front/src/modules/workflow/utils/getOrganizedDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/utils/getOrganizedDiagram.ts @@ -1,9 +1,6 @@ import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; import Dagre from '@dagrejs/dagre'; -/** - * Set the position of the nodes in the diagram. The positions are computed with a layouting algorithm. - */ export const getOrganizedDiagram = ( diagram: WorkflowDiagram, ): WorkflowDiagram => { diff --git a/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastDiagramVersion.ts b/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastDiagramVersion.ts index 448ed7fec4..7e4d13a693 100644 --- a/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastDiagramVersion.ts +++ b/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastDiagramVersion.ts @@ -1,6 +1,7 @@ import { Workflow } from '@/workflow/types/Workflow'; import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram'; +import { getWorkflowLastVersion } from '@/workflow/utils/getWorkflowLastVersion'; import { isDefined } from 'twenty-ui'; const EMPTY_DIAGRAM: WorkflowDiagram = { @@ -15,7 +16,7 @@ export const getWorkflowLastDiagramVersion = ( return EMPTY_DIAGRAM; } - const lastVersion = workflow.versions.at(-1); + const lastVersion = getWorkflowLastVersion(workflow); if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) { return EMPTY_DIAGRAM; } diff --git a/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastVersion.ts b/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastVersion.ts new file mode 100644 index 0000000000..b69790e9c4 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastVersion.ts @@ -0,0 +1,10 @@ +import { Workflow, WorkflowVersion } from '@/workflow/types/Workflow'; + +export const getWorkflowLastVersion = ( + workflow: Workflow, +): WorkflowVersion | undefined => { + return workflow.versions + .slice() + .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)) + .at(-1); +}; diff --git a/packages/twenty-front/src/modules/workflow/utils/insertStep.ts b/packages/twenty-front/src/modules/workflow/utils/insertStep.ts new file mode 100644 index 0000000000..038402e789 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/insertStep.ts @@ -0,0 +1,61 @@ +import { WorkflowStep } from '@/workflow/types/Workflow'; + +const findStepPositionOrThrow = ({ + steps, + stepId, +}: { + steps: Array; + stepId: string | undefined; +}): { steps: Array; index: number } => { + if (stepId === undefined) { + return { + steps, + index: 0, + }; + } + + for (const [index, step] of steps.entries()) { + if (step.id === stepId) { + return { + steps, + index, + }; + } + + // TODO: When condition will have been implemented, put recursivity here. + // if (step.type === "CONDITION") { + // return findNodePosition({ + // workflowSteps: step.conditions, + // stepId, + // }) + // } + } + + throw new Error(`Couldn't locate the step. Unreachable step id: ${stepId}.`); +}; + +export const insertStep = ({ + steps: stepsInitial, + stepToAdd, + parentStepId, +}: { + steps: Array; + parentStepId: string | undefined; + stepToAdd: WorkflowStep; +}): Array => { + // Make a deep copy of the nested object to prevent unwanted side effects. + const steps = structuredClone(stepsInitial); + + const parentStepPosition = findStepPositionOrThrow({ + steps: steps, + stepId: parentStepId, + }); + + parentStepPosition.steps.splice( + parentStepPosition.index + 1, // The "+ 1" means that we add the step after its parent and not before. + 0, + stepToAdd, + ); + + return steps; +}; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index aa95be3a50..b3423733b3 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -135,6 +135,7 @@ export { IconPhoto, IconPilcrow, IconPlayerPlay, + IconPlaystationSquare, IconPlug, IconPlus, IconPresentation,