Improve apps settings UI and remove unused tarball upload code (#18549)

## Summary
- Improves the Developer Tab in Settings > Applications: adds
source-type badges (Dev / Npm / Internal), better empty state with `yarn
twenty app:dev` CLI command, renames sections to "Create & Develop" and
"Your Apps"
- Simplifies the Distribution Tab: only shows marketplace section for
npm-sourced apps, removes the manual `isListed` toggle (now managed
automatically by the catalog sync cron)
- Removes dead frontend code: `installApplication` mutation (unused —
installs go through `installMarketplaceApp`), `uploadAppTarball`
mutation/hook, and `SettingsUploadTarballModal`

## Test plan
- [ ] Navigate to Settings > Applications > Developer tab: verify badges
show correctly, empty state shows CLI command
- [ ] Create/view a LOCAL app registration: verify Distribution tab does
NOT show marketplace section
- [ ] Create/view an NPM app registration: verify Distribution tab shows
marketplace section with Featured toggle
- [ ] Verify no references to upload tarball or install application
remain in the UI


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-11 11:40:00 +01:00
committed by GitHub
parent 1d95670252
commit 7fb8cc1c39
6 changed files with 114 additions and 329 deletions
@@ -1,7 +0,0 @@
import gql from 'graphql-tag';
export const INSTALL_APPLICATION = gql`
mutation InstallApplication($appRegistrationId: String!, $version: String) {
installApplication(appRegistrationId: $appRegistrationId, version: $version)
}
`;
@@ -1,11 +0,0 @@
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
}
}
`;
@@ -1,61 +0,0 @@
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 };
};
@@ -1,132 +0,0 @@
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,11 +1,7 @@
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 { 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 { useQuery } from '@apollo/client';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
@@ -35,7 +31,6 @@ export const SettingsApplicationRegistrationDistributionTab = ({
registration: ApplicationRegistrationData;
}) => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const applicationRegistrationId = registration.id;
@@ -47,32 +42,6 @@ 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,
{
@@ -111,41 +80,31 @@ export const SettingsApplicationRegistrationDistributionTab = ({
return (
<>
<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
{isNpmSource && (
<Section>
<H2Title
title={t`Marketplace Listing`}
description={t`This app is listed on the marketplace because it is published to npm.`}
/>
<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>
<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>
)}
{hasStats && (
<Section>
@@ -2,12 +2,11 @@ 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 { useQuery } from '@apollo/client';
import { useNavigate } from 'react-router-dom';
import { useContext } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
@@ -17,73 +16,128 @@ 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 { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { Tag } from 'twenty-ui/components';
import {
SettingsUploadTarballModal,
UPLOAD_TARBALL_MODAL_ID,
} from '~/pages/settings/applications/components/SettingsUploadTarballModal';
type ApplicationRegistrationFragmentFragment,
ApplicationRegistrationSourceType,
} from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledButtonContainer = styled.div`
margin: ${themeCssVariables.spacing[2]} 0;
`;
const StyledButtonGroupContainer = styled.div`
const StyledRowRightContainer = styled.div`
align-items: center;
display: flex;
justify-content: flex-end;
margin-bottom: ${themeCssVariables.spacing[4]};
gap: ${themeCssVariables.spacing[2]};
`;
type ApplicationRegistration = {
id: string;
name: string;
description: string | null;
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',
},
};
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: ApplicationRegistration[] =
const registrations: ApplicationRegistrationFragmentFragment[] =
data?.findManyApplicationRegistrations ?? [];
const commands = [
const createCommands = [
// 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 copyButton = (
const createCopyButton = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
copyToClipboard(
createCommands.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 an application`}
description={t`You can either create a private app or share it to others`}
title={t`Create & Develop`}
description={t`Scaffold a new app, then use the CLI to develop, publish, and distribute`}
/>
<CommandBlock commands={commands} button={copyButton} />
<CommandBlock commands={createCommands} button={createCopyButton} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
@@ -100,44 +154,27 @@ export const SettingsApplicationsDeveloperTab = () => {
/>
</StyledButtonContainer>
</Section>
<Section>
<H2Title
title={t`My Apps`}
description={t`Apps you've created, registered, or published`}
title={t`Your Apps`}
description={t`All applications registered on this workspace`}
/>
{registrations.length > 0 && (
{registrations.length > 0 ? (
<SettingsListCard
items={registrations}
getItemLabel={(registration) => registration.name}
isLoading={loading}
RowIcon={IconApps}
onRowClick={(registration) => {
navigate(
getSettingsPath(SettingsPath.ApplicationRegistrationDetail, {
applicationRegistrationId: registration.id,
}),
);
}}
RowRightComponent={() => (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
to={getRegistrationLink}
RowRightComponent={RowRightWithBadge}
/>
) : (
!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 />
</>
);
};