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.
---
.../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)