Extract frontend changes to separate PR (#18549)

Made-with: Cursor
This commit is contained in:
Félix Malfait
2026-03-10 20:21:00 +01:00
parent 6d266e3e7b
commit 62f6fb0ba5
10 changed files with 362 additions and 148 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
import gql from 'graphql-tag';
export const INSTALL_APPLICATION = gql`
mutation InstallApplication($appRegistrationId: String!, $version: String) {
installApplication(appRegistrationId: $appRegistrationId, version: $version)
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const UPLOAD_APP_TARBALL = gql`
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
@@ -0,0 +1,61 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { UPLOAD_APP_TARBALL } from '~/modules/marketplace/graphql/mutations/uploadAppTarball';
type UploadResult =
| {
success: true;
registrationId: string;
universalIdentifier: string;
}
| {
success: false;
};
export const useUploadAppTarball = () => {
const [uploadAppTarball] = useMutation(UPLOAD_APP_TARBALL);
const { enqueueErrorSnackBar } = useSnackBar();
const [isUploading, setIsUploading] = useState(false);
const upload = async (file: File): Promise<UploadResult> => {
setIsUploading(true);
try {
const result = await uploadAppTarball({
variables: { file },
});
const registration = result.data?.uploadAppTarball;
if (
!isDefined(registration?.id) ||
!isDefined(registration?.universalIdentifier)
) {
enqueueErrorSnackBar({ message: t`Upload failed.` });
return { success: false };
}
return {
success: true,
registrationId: registration.id,
universalIdentifier: registration.universalIdentifier,
};
} catch (error) {
const graphqlMessage = error instanceof Error ? error.message : undefined;
enqueueErrorSnackBar({
message: graphqlMessage ?? t`Failed to upload tarball.`,
});
return { success: false };
} finally {
setIsUploading(false);
}
};
return { upload, isUploading };
};
@@ -15,10 +15,7 @@ import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
ApplicationRegistrationSourceType,
type ApplicationRegistrationFragmentFragment,
} from '~/generated-metadata/graphql';
import { type ApplicationRegistrationFragmentFragment } from '~/generated-metadata/graphql';
const StyledTableContainer = styled.div`
margin-top: ${themeCssVariables.spacing[3]};
@@ -68,7 +65,7 @@ export const SettingsAdminApps = () => {
<TableRow gridTemplateColumns={TABLE_GRID}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Source`}</TableHeader>
<TableHeader>{t`Type`}</TableHeader>
<TableHeader>{t`Listed`}</TableHeader>
<TableHeader>{t`Featured`}</TableHeader>
</TableRow>
</StyledTableHeaderRowContainer>
@@ -89,16 +86,8 @@ export const SettingsAdminApps = () => {
</TableCell>
<TableCell>
<Status
color={
registration.sourceType ===
ApplicationRegistrationSourceType.NPM
? 'green'
: registration.sourceType ===
ApplicationRegistrationSourceType.TARBALL
? 'blue'
: 'gray'
}
text={registration.sourceType}
color={registration.isListed ? 'green' : 'gray'}
text={registration.isListed ? t`Yes` : t`No`}
/>
</TableCell>
<TableCell>
@@ -16,9 +16,8 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
latestAvailableVersion
websiteUrl
termsUrl
isListed
isFeatured
provenanceRepositoryUrl
isProvenanceVerified
ownerWorkspaceId
createdAt
updatedAt
@@ -0,0 +1,132 @@
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useApolloClient, useMutation } from '@apollo/client';
import { useLingui } from '@lingui/react/macro';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import { INSTALL_APPLICATION } from '~/modules/marketplace/graphql/mutations/installApplication';
import { useUploadAppTarball } from '~/modules/marketplace/hooks/useUploadAppTarball';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
export const UPLOAD_TARBALL_MODAL_ID = 'upload-tarball-modal';
const StyledFileInput = styled.input`
display: none;
`;
export const SettingsUploadTarballModal = () => {
const { t: tFn } = useLingui();
const { closeModal } = useModal();
const { upload, isUploading } = useUploadAppTarball();
const [installApplication] = useMutation(INSTALL_APPLICATION);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const apolloClient = useApolloClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!isDefined(file)) {
return;
}
try {
const uploadResult = await upload(file);
if (!uploadResult.success) {
return;
}
const registrationId = uploadResult.registrationId;
if (isDefined(registrationId)) {
try {
await installApplication({
variables: { appRegistrationId: registrationId },
});
enqueueSuccessSnackBar({
message: t`Application installed successfully.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Tarball uploaded but installation failed.`,
});
}
}
await apolloClient.refetchQueries({
include: [FIND_MANY_APPLICATION_REGISTRATIONS],
});
closeModal(UPLOAD_TARBALL_MODAL_ID);
} finally {
if (isDefined(fileInputRef.current)) {
fileInputRef.current.value = '';
}
}
};
const handleCancel = () => {
closeModal(UPLOAD_TARBALL_MODAL_ID);
};
return (
<StyledAppModal
modalId={UPLOAD_TARBALL_MODAL_ID}
isClosable={true}
padding="large"
dataGloballyPreventClickOutside
>
<StyledAppModalTitle>
<H1Title
title={tFn`Upload tarball`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{tFn`Select a .tar.gz application package to upload and install.`}
</StyledAppModalSection>
<StyledFileInput
ref={fileInputRef}
type="file"
accept=".tar.gz,.tgz"
onChange={handleFileChange}
/>
<StyledAppModalButton
onClick={handleCancel}
variant="secondary"
title={tFn`Cancel`}
fullWidth
/>
<StyledAppModalButton
onClick={handleSelectFile}
variant="secondary"
accent="blue"
title={tFn`Choose file`}
disabled={isUploading}
fullWidth
/>
</StyledAppModal>
);
};
@@ -1,7 +1,11 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { UPDATE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistration';
import { FIND_APPLICATION_REGISTRATION_STATS } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationStats';
import { useQuery } from '@apollo/client';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { FIND_ONE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/queries/findOneApplicationRegistration';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation, useQuery } from '@apollo/client';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
@@ -31,6 +35,7 @@ export const SettingsApplicationRegistrationDistributionTab = ({
registration: ApplicationRegistrationData;
}) => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const applicationRegistrationId = registration.id;
@@ -42,6 +47,32 @@ export const SettingsApplicationRegistrationDistributionTab = ({
skip: !applicationRegistrationId,
});
const [updateRegistration] = useMutation(UPDATE_APPLICATION_REGISTRATION, {
refetchQueries: [
FIND_ONE_APPLICATION_REGISTRATION,
FIND_MANY_APPLICATION_REGISTRATIONS,
],
});
const handleToggleListed = async () => {
try {
await updateRegistration({
variables: {
input: {
id: applicationRegistrationId,
update: {
isListed: !registration.isListed,
},
},
},
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating marketplace listing`,
});
}
};
const marketplacePageUrl = getSettingsPath(
SettingsPath.AvailableApplicationDetail,
{
@@ -80,31 +111,41 @@ export const SettingsApplicationRegistrationDistributionTab = ({
return (
<>
{isNpmSource && (
<Section>
<H2Title
title={t`Marketplace Listing`}
description={t`This app is listed on the marketplace because it is published to npm.`}
<Section>
<H2Title
title={t`Marketplace Listing`}
description={t`Control visibility on the marketplace. Unlisted apps are still accessible via direct link.`}
/>
<Card rounded>
<SettingsOptionCardContentToggle
title={t`Listed on marketplace`}
description={
isNpmSource
? t`Managed by the marketplace catalog sync for npm packages`
: t`When enabled, this app appears in the marketplace browse page`
}
checked={registration.isListed}
onChange={handleToggleListed}
disabled={isNpmSource}
divider
/>
<Card rounded>
<SettingsOptionCardContentToggle
title={t`Featured`}
description={t`Featured apps are curated. Open a PR to request featured status.`}
checked={registration.isFeatured}
onChange={() => {}}
disabled
/>
</Card>
<StyledButtonGroup>
<Button
Icon={IconExternalLink}
title={t`View marketplace page`}
variant="secondary"
to={marketplacePageUrl}
/>
</StyledButtonGroup>
</Section>
)}
<SettingsOptionCardContentToggle
title={t`Featured`}
description={t`Featured apps are curated. Open a PR to request featured status.`}
checked={registration.isFeatured}
onChange={() => {}}
disabled
/>
</Card>
<StyledButtonGroup>
<Button
Icon={IconExternalLink}
title={t`View marketplace page`}
variant="secondary"
to={marketplacePageUrl}
/>
</StyledButtonGroup>
</Section>
{hasStats && (
<Section>
@@ -2,11 +2,12 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useQuery } from '@apollo/client';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useQuery } from '@apollo/client';
import { useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
@@ -16,128 +17,73 @@ import {
IconChevronRight,
IconCopy,
IconFileInfo,
IconUpload,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { Tag } from 'twenty-ui/components';
import {
type ApplicationRegistrationFragmentFragment,
ApplicationRegistrationSourceType,
} from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import {
SettingsUploadTarballModal,
UPLOAD_TARBALL_MODAL_ID,
} from '~/pages/settings/applications/components/SettingsUploadTarballModal';
const StyledButtonContainer = styled.div`
margin: ${themeCssVariables.spacing[2]} 0;
`;
const StyledRowRightContainer = styled.div`
align-items: center;
const StyledButtonGroupContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
justify-content: flex-end;
margin-bottom: ${themeCssVariables.spacing[4]};
`;
const SOURCE_TYPE_BADGE_CONFIG: Record<
ApplicationRegistrationSourceType,
{ label: string; color: 'gray' | 'blue' | 'green' }
> = {
[ApplicationRegistrationSourceType.LOCAL]: {
label: 'Dev',
color: 'gray',
},
[ApplicationRegistrationSourceType.NPM]: {
label: 'Npm',
color: 'blue',
},
[ApplicationRegistrationSourceType.TARBALL]: {
label: 'Internal',
color: 'green',
},
type ApplicationRegistration = {
id: string;
name: string;
description: string | null;
};
export const SettingsApplicationsDeveloperTab = () => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const navigate = useNavigate();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { openModal } = useModal();
const { copyToClipboard } = useCopyToClipboard();
const { data, loading } = useQuery(FIND_MANY_APPLICATION_REGISTRATIONS);
const registrations: ApplicationRegistrationFragmentFragment[] =
const registrations: ApplicationRegistration[] =
data?.findManyApplicationRegistrations ?? [];
const createCommands = [
const commands = [
// oxlint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// oxlint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
];
const createCopyButton = (
const copyButton = (
<Button
onClick={() => {
copyToClipboard(
createCommands.join('\n'),
t`Commands copied to clipboard`,
);
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
const getRegistrationLink = (
registration: ApplicationRegistrationFragmentFragment,
) =>
getSettingsPath(SettingsPath.ApplicationRegistrationDetail, {
applicationRegistrationId: registration.id,
});
const syncCommands = [
// oxlint-disable-next-line lingui/no-unlocalized-strings
'yarn twenty app:dev',
];
const syncCopyButton = (
<Button
onClick={() => {
copyToClipboard(
syncCommands.join('\n'),
t`Command copied to clipboard`,
);
}}
ariaLabel={t`Copy command`}
Icon={IconCopy}
/>
);
const RowRightWithBadge = ({
item,
}: {
item: ApplicationRegistrationFragmentFragment;
}) => {
const badgeConfig = SOURCE_TYPE_BADGE_CONFIG[item.sourceType];
return (
<StyledRowRightContainer>
<Tag text={badgeConfig.label} color={badgeConfig.color} preventShrink />
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledRowRightContainer>
);
};
return (
<>
<Section>
<H2Title
title={t`Create & Develop`}
description={t`Scaffold a new app, then use the CLI to develop, publish, and distribute`}
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
/>
<CommandBlock commands={createCommands} button={createCopyButton} />
<CommandBlock commands={commands} button={copyButton} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
@@ -146,7 +92,7 @@ export const SettingsApplicationsDeveloperTab = () => {
window.open(
getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: '/developers/extend/apps/getting-started',
path: '/developers/extend/capabilities/apps',
}),
'_blank',
)
@@ -154,27 +100,44 @@ export const SettingsApplicationsDeveloperTab = () => {
/>
</StyledButtonContainer>
</Section>
<Section>
<H2Title
title={t`Your Apps`}
description={t`All applications registered on this workspace`}
title={t`My Apps`}
description={t`Apps you've created, registered, or published`}
/>
{registrations.length > 0 ? (
{registrations.length > 0 && (
<SettingsListCard
items={registrations}
getItemLabel={(registration) => registration.name}
isLoading={loading}
RowIcon={IconApps}
to={getRegistrationLink}
RowRightComponent={RowRightWithBadge}
onRowClick={(registration) => {
navigate(
getSettingsPath(SettingsPath.ApplicationRegistrationDetail, {
applicationRegistrationId: registration.id,
}),
);
}}
RowRightComponent={() => (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
/>
) : (
!loading && (
<CommandBlock commands={syncCommands} button={syncCopyButton} />
)
)}
</Section>
<StyledButtonGroupContainer>
<Button
Icon={IconUpload}
title={t`Upload tarball`}
size="small"
variant="secondary"
onClick={() => openModal(UPLOAD_TARBALL_MODAL_ID)}
/>
</StyledButtonGroupContainer>
<SettingsUploadTarballModal />
</>
);
};
@@ -7,6 +7,7 @@ export type ApplicationRegistrationData = {
universalIdentifier: string;
sourceType: ApplicationRegistrationSourceType;
sourcePackage?: string | null;
isListed: boolean;
isFeatured: boolean;
oAuthClientId: string;
oAuthScopes?: string[] | null;