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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22712?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-09 18:12:11 +02:00
committed by GitHub
parent b9cd62f396
commit 9c405384f3
13 changed files with 295 additions and 100 deletions
@@ -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!
@@ -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']} })
@@ -6529,7 +6529,13 @@ export default {
}
],
"findManyMarketplaceApps": [
228
228,
{
"universalIdentifiers": [
1,
"[String!]"
]
}
],
"findMarketplaceAppDetail": [
232,
@@ -4593,6 +4593,11 @@ export type QueryFindApplicationRegistrationVariablesArgs = {
};
export type QueryFindManyMarketplaceAppsArgs = {
universalIdentifiers?: InputMaybe<Array<Scalars['String']['input']>>;
};
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<Array<Scalars['String']['input']> | 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<GetLogicFunctionSourceCodeQuery, GetLogicFunctionSourceCodeQueryVariables>;
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<InstallApplicationMutation, InstallApplicationMutationVariables>;
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<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>;
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<FindManyMarketplaceAppsQuery, FindManyMarketplaceAppsQueryVariables>;
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<FindManyMarketplaceAppsQuery, FindManyMarketplaceAppsQueryVariables>;
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<FindMarketplaceAppDetailQuery, FindMarketplaceAppDetailQueryVariables>;
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<FindMarketplaceAppManifestQuery, FindMarketplaceAppManifestQueryVariables>;
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<CreateManyNavigationMenuItemsMutation, CreateManyNavigationMenuItemsMutationVariables>;
@@ -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
}
}
@@ -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 ?? [],
@@ -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;
};
@@ -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 <InstallAppsAutoSkipEffect onError={handleAutoSkipError} />;
}
return (
<InstallAppsContent
apps={apps}
apps={availableApps}
selectedUniversalIdentifiers={selectedUniversalIdentifiers}
creditsRewardPerApp={onboardingConfig?.installAppsCreditsRewardPerApp}
isCompleting={isCompleting}
@@ -10,7 +10,7 @@ import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/Onboardin
import { type OnboardingInstallableApp } from '@/onboarding/types/OnboardingInstallableApp';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { IconCheck, IconPlus } from 'twenty-ui/icon';
import { IconButton, MainButton } from 'twenty-ui/input';
@@ -118,6 +118,8 @@ export const InstallAppsContent = ({
const { t } = useLingui();
const theme = useTheme();
const hasApps = isNonEmptyArray(apps);
return (
<StyledOnboardingStepPage>
<StyledOnboardingStepHeading>
@@ -129,10 +131,12 @@ export const InstallAppsContent = ({
</OnboardingStepAnimatedItem>
<OnboardingStepAnimatedItem index={1}>
<StyledOnboardingStepSubtitle>
{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`}
</StyledOnboardingStepSubtitle>
</OnboardingStepAnimatedItem>
{isDefined(creditsRewardPerApp) && (
{isDefined(creditsRewardPerApp) && hasApps && (
<OnboardingStepAnimatedItem index={2}>
<StyledOnboardingStepTagsRow>
<OnboardingCreditsRewardTag
@@ -144,63 +148,67 @@ export const InstallAppsContent = ({
)}
</StyledOnboardingStepHeading>
<OnboardingStepAnimatedItem index={3}>
<StyledCard>
{apps.map((app) => {
const labelText = t(app.label);
const isSelected = selectedUniversalIdentifiers.includes(
app.universalIdentifier,
);
{hasApps && (
<OnboardingStepAnimatedItem index={3}>
<StyledCard>
{apps.map((app) => {
const labelText = t(app.label);
const isSelected = selectedUniversalIdentifiers.includes(
app.universalIdentifier,
);
return (
<StyledAppRow key={app.universalIdentifier}>
<Avatar
avatarUrl={getAbsoluteImageUrl(app.logo)}
placeholder={labelText}
placeholderColorSeed={app.universalIdentifier}
size="lg"
type="squared"
/>
<StyledAppText>
<StyledAppLabel>{labelText}</StyledAppLabel>
<StyledAppDescription>
{t(app.description)}
</StyledAppDescription>
</StyledAppText>
<IconButton
size="small"
variant="secondary"
accent={isSelected ? 'blue' : 'default'}
ariaLabel={
isSelected
? t`Deselect ${labelText}`
: t`Select ${labelText}`
}
onClick={() => onToggleApp(app.universalIdentifier)}
>
<AnimatedIconCrossfade
isActive={isSelected}
ActiveIcon={IconCheck}
InactiveIcon={IconPlus}
size={theme.icon.size.md}
return (
<StyledAppRow key={app.universalIdentifier}>
<Avatar
avatarUrl={getAbsoluteImageUrl(app.logo)}
placeholder={labelText}
placeholderColorSeed={app.universalIdentifier}
size="lg"
type="squared"
/>
</IconButton>
</StyledAppRow>
);
})}
</StyledCard>
</OnboardingStepAnimatedItem>
<StyledAppText>
<StyledAppLabel>{labelText}</StyledAppLabel>
<StyledAppDescription>
{t(app.description)}
</StyledAppDescription>
</StyledAppText>
<IconButton
size="small"
variant="secondary"
accent={isSelected ? 'blue' : 'default'}
ariaLabel={
isSelected
? t`Deselect ${labelText}`
: t`Select ${labelText}`
}
onClick={() => onToggleApp(app.universalIdentifier)}
>
<AnimatedIconCrossfade
isActive={isSelected}
ActiveIcon={IconCheck}
InactiveIcon={IconPlus}
size={theme.icon.size.md}
/>
</IconButton>
</StyledAppRow>
);
})}
</StyledCard>
</OnboardingStepAnimatedItem>
)}
<OnboardingStepAnimatedItem index={4}>
<StyledFooter>
<StyledInstallButton>
<MainButton
title={t`Install`}
onClick={onInstall}
disabled={isCompleting}
fullWidth
/>
</StyledInstallButton>
{hasApps && (
<StyledInstallButton>
<MainButton
title={t`Install`}
onClick={onInstall}
disabled={isCompleting}
fullWidth
/>
</StyledInstallButton>
)}
<OnboardingSkipButton onClick={onSkip} disabled={isCompleting} />
</StyledFooter>
</OnboardingStepAnimatedItem>
@@ -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<typeof buildMarketplaceApp>[],
) => [
currentUserHandler,
graphql.query(getOperationName(FIND_MANY_MARKETPLACE_APPS) ?? '', () => {
return HttpResponse.json({
data: { findManyMarketplaceApps: marketplaceApps },
});
}),
graphqlMocks.handlers,
];
const meta: Meta<PageDecoratorArgs> = {
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');
},
};
@@ -26,7 +26,7 @@ export class MarketplacePublicResolver {
@Args('isVetted', { type: () => Boolean, defaultValue: true })
isVetted: boolean,
): Promise<MarketplaceAppDTO[]> {
return this.marketplaceQueryService.findManyMarketplaceApps(isVetted);
return this.marketplaceQueryService.findManyMarketplaceApps({ isVetted });
}
@Query(() => MarketplaceAppDetailDTO, { name: 'publicMarketplaceAppDetail' })
@@ -22,16 +22,27 @@ export class MarketplaceQueryService {
private readonly coreEntityCacheService: CoreEntityCacheService,
) {}
async findManyMarketplaceApps(
isVetted?: boolean,
): Promise<MarketplaceAppDTO[]> {
async findManyMarketplaceApps({
universalIdentifiers,
isVetted,
}: {
universalIdentifiers?: string[];
isVetted?: boolean;
} = {}): Promise<MarketplaceAppDTO[]> {
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;
@@ -28,8 +28,17 @@ export class MarketplaceResolver {
) {}
@Query(() => [MarketplaceAppDTO])
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
return this.marketplaceQueryService.findManyMarketplaceApps();
async findManyMarketplaceApps(
@Args({
name: 'universalIdentifiers',
type: () => [String],
nullable: true,
})
universalIdentifiers?: string[],
): Promise<MarketplaceAppDTO[]> {
return this.marketplaceQueryService.findManyMarketplaceApps({
universalIdentifiers,
});
}
@Query(() => MarketplaceAppDetailDTO)