From 9c405384f357a6cd8d4f04b9b44c2b239a58ebd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:12:11 +0200 Subject: [PATCH] Only propose configured apps during onboarding install step (#22712) ## What - Onboarding "Install your first apps" now proposes only apps that are actually installable: it intersects the onboarding list with `findManyMarketplaceApps`, which the backend already filters to listed + configured apps (all required server variables set). - If none are available, the step auto-skips. If the marketplace query fails, it shows an intentional fallback (heading + Skip) instead of silently skipping or rendering an empty install card. - `findManyMarketplaceApps` now accepts `universalIdentifiers`, so onboarding fetches and configuration-checks only its own apps instead of the entire catalog. ## Why Previously the step rendered all hardcoded apps regardless of configuration, only borrowing logos from the marketplace, so a user could be offered an app the admin never configured. This centralizes onboarding availability on the marketplace's existing logic and keeps the query bounded as the marketplace grows. Review in cubic --- .../src/metadata/generated/schema.graphql | 2 +- .../src/metadata/generated/schema.ts | 2 +- .../src/metadata/generated/types.ts | 8 +- .../src/generated-metadata/graphql.ts | 11 +- .../queries/findManyMarketplaceApps.ts | 4 +- .../marketplace/hooks/useMarketplaceApps.ts | 8 +- .../InstallAppsAutoSkipEffect.tsx | 35 +++++ .../src/pages/onboarding/InstallApps.tsx | 51 ++++++-- .../pages/onboarding/InstallAppsContent.tsx | 118 +++++++++-------- .../__stories__/InstallApps.stories.tsx | 122 +++++++++++++++--- .../marketplace-public.resolver.ts | 2 +- .../marketplace-query.service.ts | 19 ++- .../marketplace.resolver.ts | 13 +- 13 files changed, 295 insertions(+), 100 deletions(-) create mode 100644 packages/twenty-front/src/modules/onboarding/effect-components/InstallAppsAutoSkipEffect.tsx diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 278981d323..7cecd8b9fd 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3151,7 +3151,7 @@ type Query { getViewGroup(id: String!): ViewGroup findManyApplications: [Application!]! findOneApplication(id: UUID, universalIdentifier: UUID): Application! - findManyMarketplaceApps: [MarketplaceApp!]! + findManyMarketplaceApps(universalIdentifiers: [String!]): [MarketplaceApp!]! findMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail! publicMarketplaceApps(isVetted: Boolean! = true): [MarketplaceApp!]! publicMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index aa6f2ef676..86081d4fbb 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -5990,7 +5990,7 @@ export interface QueryGenqlSelection{ getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} }) findManyApplications?: ApplicationGenqlSelection findOneApplication?: (ApplicationGenqlSelection & { __args?: {id?: (Scalars['UUID'] | null), universalIdentifier?: (Scalars['UUID'] | null)} }) - findManyMarketplaceApps?: MarketplaceAppGenqlSelection + findManyMarketplaceApps?: (MarketplaceAppGenqlSelection & { __args?: {universalIdentifiers?: (Scalars['String'][] | null)} }) findMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} }) publicMarketplaceApps?: (MarketplaceAppGenqlSelection & { __args: {isVetted: Scalars['Boolean']} }) publicMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} }) diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 03ab1f0e53..b1688e9490 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -6529,7 +6529,13 @@ export default { } ], "findManyMarketplaceApps": [ - 228 + 228, + { + "universalIdentifiers": [ + 1, + "[String!]" + ] + } ], "findMarketplaceAppDetail": [ 232, diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a986475d53..549a1a4ddf 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4593,6 +4593,11 @@ export type QueryFindApplicationRegistrationVariablesArgs = { }; +export type QueryFindManyMarketplaceAppsArgs = { + universalIdentifiers?: InputMaybe>; +}; + + export type QueryFindMarketplaceAppDetailArgs = { universalIdentifier: Scalars['String']['input']; }; @@ -7259,7 +7264,9 @@ export type UpgradeApplicationMutationVariables = Exact<{ export type UpgradeApplicationMutation = { __typename?: 'Mutation', upgradeApplication: boolean }; -export type FindManyMarketplaceAppsQueryVariables = Exact<{ [key: string]: never; }>; +export type FindManyMarketplaceAppsQueryVariables = Exact<{ + universalIdentifiers?: InputMaybe | Scalars['String']['input']>; +}>; export type FindManyMarketplaceAppsQuery = { __typename?: 'Query', findManyMarketplaceApps: Array<{ __typename?: 'MarketplaceApp', id: string, name: string, description: string, author: string, category: string, logo?: string | null, sourcePackage?: string | null, isVetted: boolean }> }; @@ -8951,7 +8958,7 @@ export const FindOneLogicFunctionDocument = {"kind":"Document","definitions":[{" export const GetLogicFunctionSourceCodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLogicFunctionSourceCode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"LogicFunctionIdInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getLogicFunctionSourceCode"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const InstallApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InstallApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installApplication"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"universalIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const UpgradeApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpgradeApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"targetVersion"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upgradeApplication"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appRegistrationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"targetVersion"},"value":{"kind":"Variable","name":{"kind":"Name","value":"targetVersion"}}}]}]}}]} as unknown as DocumentNode; -export const FindManyMarketplaceAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyMarketplaceApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findManyMarketplaceApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"MarketplaceAppFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"MarketplaceAppFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"MarketplaceApp"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"isVetted"}}]}}]} as unknown as DocumentNode; +export const FindManyMarketplaceAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyMarketplaceApps"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifiers"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findManyMarketplaceApps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"universalIdentifiers"},"value":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifiers"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"MarketplaceAppFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"MarketplaceAppFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"MarketplaceApp"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"isVetted"}}]}}]} as unknown as DocumentNode; export const FindMarketplaceAppDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindMarketplaceAppDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findMarketplaceAppDetail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"universalIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"MarketplaceAppDetailFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"MarketplaceAppDetailFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"MarketplaceAppDetail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isVetted"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"websiteUrl"}},{"kind":"Field","name":{"kind":"Name","value":"aboutDescription"}},{"kind":"Field","name":{"kind":"Name","value":"termsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"emailSupport"}},{"kind":"Field","name":{"kind":"Name","value":"issueReportUrl"}},{"kind":"Field","name":{"kind":"Name","value":"screenshots"}},{"kind":"Field","name":{"kind":"Name","value":"defaultRoleUniversalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"roles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"permissionFlagUniversalIdentifiers"}},{"kind":"Field","name":{"kind":"Name","value":"objectPermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"objectUniversalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"canReadObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyObjectRecords"}}]}},{"kind":"Field","name":{"kind":"Name","value":"fieldPermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"objectUniversalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"fieldUniversalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"canReadFieldValue"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateFieldValue"}}]}}]}}]}}]} as unknown as DocumentNode; export const FindMarketplaceAppManifestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindMarketplaceAppManifest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findMarketplaceAppDetail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"universalIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"manifest"}}]}}]}}]} as unknown as DocumentNode; export const CreateManyNavigationMenuItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateManyNavigationMenuItems"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inputs"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateNavigationMenuItemInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createManyNavigationMenuItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputs"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inputs"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NavigationMenuItemQueryFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordId"}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"folderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemQueryFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NavigationMenuItemFields"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordIdentifier"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifier"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/marketplace/graphql/queries/findManyMarketplaceApps.ts b/packages/twenty-front/src/modules/marketplace/graphql/queries/findManyMarketplaceApps.ts index 3ca3e0131e..83ab9217a0 100644 --- a/packages/twenty-front/src/modules/marketplace/graphql/queries/findManyMarketplaceApps.ts +++ b/packages/twenty-front/src/modules/marketplace/graphql/queries/findManyMarketplaceApps.ts @@ -4,8 +4,8 @@ import { MARKETPLACE_APP_FRAGMENT } from '@/marketplace/graphql/fragments/market export const FIND_MANY_MARKETPLACE_APPS = gql` ${MARKETPLACE_APP_FRAGMENT} - query FindManyMarketplaceApps { - findManyMarketplaceApps { + query FindManyMarketplaceApps($universalIdentifiers: [String!]) { + findManyMarketplaceApps(universalIdentifiers: $universalIdentifiers) { ...MarketplaceAppFields } } diff --git a/packages/twenty-front/src/modules/marketplace/hooks/useMarketplaceApps.ts b/packages/twenty-front/src/modules/marketplace/hooks/useMarketplaceApps.ts index 6e6efb72d4..66bce26070 100644 --- a/packages/twenty-front/src/modules/marketplace/hooks/useMarketplaceApps.ts +++ b/packages/twenty-front/src/modules/marketplace/hooks/useMarketplaceApps.ts @@ -1,8 +1,12 @@ import { useQuery } from '@apollo/client/react'; import { FindManyMarketplaceAppsDocument } from '~/generated-metadata/graphql'; -export const useMarketplaceApps = () => { - const { data, loading, error } = useQuery(FindManyMarketplaceAppsDocument); +export const useMarketplaceApps = ({ + universalIdentifiers, +}: { universalIdentifiers?: string[] } = {}) => { + const { data, loading, error } = useQuery(FindManyMarketplaceAppsDocument, { + variables: { universalIdentifiers }, + }); return { data: data?.findManyMarketplaceApps ?? [], diff --git a/packages/twenty-front/src/modules/onboarding/effect-components/InstallAppsAutoSkipEffect.tsx b/packages/twenty-front/src/modules/onboarding/effect-components/InstallAppsAutoSkipEffect.tsx new file mode 100644 index 0000000000..3ba89fa758 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/effect-components/InstallAppsAutoSkipEffect.tsx @@ -0,0 +1,35 @@ +import { useTriggerInstallAppsOnboardingStep } from '@/onboarding/hooks/useTriggerInstallAppsOnboardingStep'; +import { useEffect, useRef } from 'react'; + +type InstallAppsAutoSkipEffectProps = { + onError: () => void; +}; + +export const InstallAppsAutoSkipEffect = ({ + onError, +}: InstallAppsAutoSkipEffectProps) => { + const triggerInstallAppsOnboardingStep = + useTriggerInstallAppsOnboardingStep(); + + // oxlint-disable-next-line twenty/no-state-useref + const hasSkippedRef = useRef(false); + + useEffect(() => { + if (hasSkippedRef.current) { + return; + } + hasSkippedRef.current = true; + + const skip = async () => { + try { + await triggerInstallAppsOnboardingStep([]); + } catch { + onError(); + } + }; + + void skip(); + }, [triggerInstallAppsOnboardingStep, onError]); + + return null; +}; diff --git a/packages/twenty-front/src/pages/onboarding/InstallApps.tsx b/packages/twenty-front/src/pages/onboarding/InstallApps.tsx index fe5fb36bc1..3e739a5f2b 100644 --- a/packages/twenty-front/src/pages/onboarding/InstallApps.tsx +++ b/packages/twenty-front/src/pages/onboarding/InstallApps.tsx @@ -1,12 +1,24 @@ import { onboardingConfigState } from '@/client-config/states/onboardingConfigState'; import { useMarketplaceApps } from '@/marketplace/hooks/useMarketplaceApps'; import { ONBOARDING_INSTALLABLE_APPS } from '@/onboarding/constants/OnboardingInstallableApps'; +import { InstallAppsAutoSkipEffect } from '@/onboarding/effect-components/InstallAppsAutoSkipEffect'; import { useInstallOnboardingApps } from '@/onboarding/hooks/useInstallOnboardingApps'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useCallback, useState } from 'react'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { InstallAppsContent } from '~/pages/onboarding/InstallAppsContent'; +const ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS = + ONBOARDING_INSTALLABLE_APPS.map((app) => app.universalIdentifier); + export const InstallApps = () => { - const { data: marketplaceApps } = useMarketplaceApps(); + const { + data: marketplaceApps, + isLoading, + error, + } = useMarketplaceApps({ + universalIdentifiers: ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS, + }); const onboardingConfig = useAtomStateValue(onboardingConfigState); const { selectedUniversalIdentifiers, @@ -15,18 +27,39 @@ export const InstallApps = () => { installSelectedAppsAndContinue, skip, } = useInstallOnboardingApps(); + const [hasAutoSkipFailed, setHasAutoSkipFailed] = useState(false); - const apps = ONBOARDING_INSTALLABLE_APPS.map((app) => ({ - ...app, - logo: - marketplaceApps.find( - (marketplaceApp) => marketplaceApp.id === app.universalIdentifier, - )?.logo ?? null, - })); + const availableApps = ONBOARDING_INSTALLABLE_APPS.flatMap((app) => { + const marketplaceApp = marketplaceApps.find( + (marketplaceApp) => marketplaceApp.id === app.universalIdentifier, + ); + + return isDefined(marketplaceApp) + ? [{ ...app, logo: marketplaceApp.logo ?? null }] + : []; + }); + + const handleAutoSkipError = useCallback(() => { + setHasAutoSkipFailed(true); + }, []); + + if (isLoading) { + return null; + } + + const hasLoadedAvailabilitySuccessfully = !isDefined(error); + const shouldAutoSkip = + hasLoadedAvailabilitySuccessfully && + !isNonEmptyArray(availableApps) && + !hasAutoSkipFailed; + + if (shouldAutoSkip) { + return ; + } return ( @@ -129,10 +131,12 @@ export const InstallAppsContent = ({ - {t`Get the most out of your CRM by installing some apps`} + {hasApps + ? t`Get the most out of your CRM by installing some apps` + : t`No apps are available to install right now`} - {isDefined(creditsRewardPerApp) && ( + {isDefined(creditsRewardPerApp) && hasApps && ( - - - {apps.map((app) => { - const labelText = t(app.label); - const isSelected = selectedUniversalIdentifiers.includes( - app.universalIdentifier, - ); + {hasApps && ( + + + {apps.map((app) => { + const labelText = t(app.label); + const isSelected = selectedUniversalIdentifiers.includes( + app.universalIdentifier, + ); - return ( - - - - {labelText} - - {t(app.description)} - - - onToggleApp(app.universalIdentifier)} - > - + - - - ); - })} - - + + {labelText} + + {t(app.description)} + + + onToggleApp(app.universalIdentifier)} + > + + + + ); + })} + + + )} - - - + {hasApps && ( + + + + )} diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/InstallApps.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/InstallApps.stories.tsx index a3e134189c..3a2940db0d 100644 --- a/packages/twenty-front/src/pages/onboarding/__stories__/InstallApps.stories.tsx +++ b/packages/twenty-front/src/pages/onboarding/__stories__/InstallApps.stories.tsx @@ -1,7 +1,7 @@ import { getOperationName } from '~/utils/getOperationName'; import { type Meta, type StoryObj } from '@storybook/react-vite'; import { HttpResponse, graphql } from 'msw'; -import { within } from 'storybook/test'; +import { expect, within } from 'storybook/test'; import { AppPath } from 'twenty-shared/types'; import { FIND_MANY_MARKETPLACE_APPS } from '@/marketplace/graphql/queries/findManyMarketplaceApps'; @@ -13,35 +13,70 @@ import { type PageDecoratorArgs, } from '~/testing/decorators/PageDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; +import { mockedApolloClient } from '~/testing/mockedApolloClient'; import { mockedOnboardingUserData } from '~/testing/mock-data/users'; +const CALL_RECORDER_UNIVERSAL_IDENTIFIER = + '8da4b8b5-5edf-4880-b51f-ab6e679ec617'; +const ENRICHMENT_UNIVERSAL_IDENTIFIER = '4a1178c1-3535-4a47-b592-231d3216b36f'; +const LAST_CONTACT_UNIVERSAL_IDENTIFIER = + '66a504cc-0a75-410e-a43f-cdeae1db1522'; + +const buildMarketplaceApp = (id: string, name: string) => ({ + __typename: 'MarketplaceApp', + id, + name, + description: name, + author: 'Twenty', + category: 'productivity', + logo: null, + sourcePackage: name, + isVetted: true, +}); + +const currentUserHandler = graphql.query( + getOperationName(GET_CURRENT_USER) ?? '', + () => { + return HttpResponse.json({ + data: { + currentUser: mockedOnboardingUserData( + OnboardingStatus.APPS_INSTALLATION, + ), + }, + }); + }, +); + +const buildHandlers = ( + marketplaceApps: ReturnType[], +) => [ + currentUserHandler, + graphql.query(getOperationName(FIND_MANY_MARKETPLACE_APPS) ?? '', () => { + return HttpResponse.json({ + data: { findManyMarketplaceApps: marketplaceApps }, + }); + }), + graphqlMocks.handlers, +]; + const meta: Meta = { title: 'Pages/Onboarding/InstallApps', component: InstallApps, decorators: [PageDecorator], args: { routePath: AppPath.InstallApps }, + beforeEach: async () => { + await mockedApolloClient.clearStore(); + }, parameters: { msw: { - handlers: [ - graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => { - return HttpResponse.json({ - data: { - currentUser: mockedOnboardingUserData( - OnboardingStatus.APPS_INSTALLATION, - ), - }, - }); - }), - graphql.query( - getOperationName(FIND_MANY_MARKETPLACE_APPS) ?? '', - () => { - return HttpResponse.json({ - data: { findManyMarketplaceApps: [] }, - }); - }, + handlers: buildHandlers([ + buildMarketplaceApp( + CALL_RECORDER_UNIVERSAL_IDENTIFIER, + 'Call recorder', ), - graphqlMocks.handlers, - ], + buildMarketplaceApp(ENRICHMENT_UNIVERSAL_IDENTIFIER, 'Enrichment'), + buildMarketplaceApp(LAST_CONTACT_UNIVERSAL_IDENTIFIER, 'Last contact'), + ]), }, }, }; @@ -54,5 +89,52 @@ export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement.ownerDocument.body); await canvas.findByText('Install your first apps'); + await canvas.findByText('Call recorder'); + await canvas.findByText('Enrichment'); + await canvas.findByText('Last contact'); + }, +}; + +export const OnlyAvailableApps: Story = { + parameters: { + msw: { + handlers: buildHandlers([ + buildMarketplaceApp( + CALL_RECORDER_UNIVERSAL_IDENTIFIER, + 'Call recorder', + ), + ]), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + await canvas.findByText('Call recorder'); + expect(canvas.queryByText('Enrichment')).toBeNull(); + expect(canvas.queryByText('Last contact')).toBeNull(); + }, +}; + +export const MarketplaceUnavailable: Story = { + parameters: { + msw: { + handlers: [ + currentUserHandler, + graphql.query( + getOperationName(FIND_MANY_MARKETPLACE_APPS) ?? '', + () => { + return HttpResponse.json({ + errors: [{ message: 'Marketplace unavailable' }], + }); + }, + ), + graphqlMocks.handlers, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + await canvas.findByText('No apps are available to install right now'); + expect(canvas.queryByText('Install')).toBeNull(); + await canvas.findByText('Skip'); }, }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-public.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-public.resolver.ts index b19a54fea9..a2beac8b29 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-public.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-public.resolver.ts @@ -26,7 +26,7 @@ export class MarketplacePublicResolver { @Args('isVetted', { type: () => Boolean, defaultValue: true }) isVetted: boolean, ): Promise { - return this.marketplaceQueryService.findManyMarketplaceApps(isVetted); + return this.marketplaceQueryService.findManyMarketplaceApps({ isVetted }); } @Query(() => MarketplaceAppDetailDTO, { name: 'publicMarketplaceAppDetail' }) diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-query.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-query.service.ts index ae2f274e41..0af47344e8 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-query.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-query.service.ts @@ -22,16 +22,27 @@ export class MarketplaceQueryService { private readonly coreEntityCacheService: CoreEntityCacheService, ) {} - async findManyMarketplaceApps( - isVetted?: boolean, - ): Promise { + async findManyMarketplaceApps({ + universalIdentifiers, + isVetted, + }: { + universalIdentifiers?: string[]; + isVetted?: boolean; + } = {}): Promise { const appsByUniversalIdentifier = (await this.coreEntityCacheService.get( 'marketplaceCatalog', MARKETPLACE_CATALOG_CACHE_ENTITY_ID, )) ?? {}; - const apps = Object.values(appsByUniversalIdentifier); + const apps = isNonEmptyArray(universalIdentifiers) + ? universalIdentifiers + .map( + (universalIdentifier) => + appsByUniversalIdentifier[universalIdentifier], + ) + .filter(isDefined) + : Object.values(appsByUniversalIdentifier); if (!isDefined(isVetted)) { return apps; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.resolver.ts index 287d626109..74e6509860 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.resolver.ts @@ -28,8 +28,17 @@ export class MarketplaceResolver { ) {} @Query(() => [MarketplaceAppDTO]) - async findManyMarketplaceApps(): Promise { - return this.marketplaceQueryService.findManyMarketplaceApps(); + async findManyMarketplaceApps( + @Args({ + name: 'universalIdentifiers', + type: () => [String], + nullable: true, + }) + universalIdentifiers?: string[], + ): Promise { + return this.marketplaceQueryService.findManyMarketplaceApps({ + universalIdentifiers, + }); } @Query(() => MarketplaceAppDetailDTO)