Add npm packages app settings list (#19324)

solves
https://discord.com/channels/1130383047699738754/1488499318762635344
This commit is contained in:
martmull
2026-04-04 09:20:11 +02:00
committed by GitHub
parent 804e0539d9
commit 80c5cfc2ec
10 changed files with 196 additions and 55 deletions
@@ -1724,6 +1724,7 @@ enum FeatureFlagKey {
IS_AI_ENABLED
IS_COMMAND_MENU_ITEM_ENABLED
IS_MARKETPLACE_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAILING_DOMAIN_ENABLED
@@ -1424,7 +1424,7 @@ export interface PublicFeatureFlag {
__typename: 'PublicFeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export interface ClientConfigMaintenanceMode {
startAt: Scalars['DateTime']
@@ -9092,6 +9092,7 @@ export const enumFeatureFlagKey = {
IS_AI_ENABLED: 'IS_AI_ENABLED' as const,
IS_COMMAND_MENU_ITEM_ENABLED: 'IS_COMMAND_MENU_ITEM_ENABLED' as const,
IS_MARKETPLACE_ENABLED: 'IS_MARKETPLACE_ENABLED' as const,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' as const,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' as const,
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
IS_EMAILING_DOMAIN_ENABLED: 'IS_EMAILING_DOMAIN_ENABLED' as const,
@@ -1729,6 +1729,7 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
@@ -11,7 +11,7 @@ import { getSettingsPath } from 'twenty-shared/utils';
import { IconApps, IconCode, IconDownload } from 'twenty-ui/display';
import { useQuery } from '@apollo/client/react';
import {
type FeatureFlagKey,
FeatureFlagKey,
PermissionFlagType,
FindManyApplicationsDocument,
} from '~/generated-metadata/graphql';
@@ -33,6 +33,10 @@ export const SettingsApplications = () => {
'IS_MARKETPLACE_ENABLED' as FeatureFlagKey,
);
const isMarketplaceSettingTabVisible = useIsFeatureEnabled(
FeatureFlagKey.IS_MARKETPLACE_SETTING_TAB_VISIBLE,
);
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
APPLICATIONS_TAB_LIST_ID,
@@ -65,7 +69,9 @@ export const SettingsApplications = () => {
}
const tabs = [
{ id: 'marketplace', title: t`Marketplace`, Icon: IconDownload },
...(isMarketplaceSettingTabVisible
? [{ id: 'marketplace', title: t`Marketplace`, Icon: IconDownload }]
: []),
{ id: 'installed', title: t`Installed`, Icon: IconApps },
...(hasDeveloperAccess
? [{ id: 'developer', title: t`Developer`, Icon: IconCode }]
@@ -81,7 +87,11 @@ export const SettingsApplications = () => {
case 'developer':
return <SettingsApplicationsDeveloperTab />;
default:
return <SettingsApplicationsAvailableTab />;
return isMarketplaceSettingTabVisible ? (
<SettingsApplicationsAvailableTab />
) : (
<SettingsApplicationsInstalledTab />
);
}
};
@@ -97,11 +107,7 @@ export const SettingsApplications = () => {
]}
>
<SettingsPageContainer>
<TabList
tabs={tabs}
componentInstanceId={APPLICATIONS_TAB_LIST_ID}
behaveAsLinks={false}
/>
<TabList tabs={tabs} componentInstanceId={APPLICATIONS_TAB_LIST_ID} />
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
@@ -22,8 +22,9 @@ const StyledTableContainer = styled.div`
margin-top: ${themeCssVariables.spacing[3]};
`;
const StyledTableHeaderRowContainer = styled.div`
margin-bottom: ${themeCssVariables.spacing[2]};
const StyledTableRowsContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
padding: ${themeCssVariables.spacing[2]} 0;
`;
const StyledSearchInputContainer = styled.div`
@@ -64,47 +65,47 @@ export const SettingsApplicationsTable = ({
</StyledSearchInputContainer>
<StyledTableContainer>
<Table>
<StyledTableHeaderRowContainer>
<TableRow
gridTemplateColumns={APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
>
<TableHeader> {t`Name`}</TableHeader>
<TableHeader> {t`Description`}</TableHeader>
<TableHeader> {''}</TableHeader>
<TableHeader />
</TableRow>
</StyledTableHeaderRowContainer>
{filteredApplications.map((application) => {
const isNpmApp =
application.applicationRegistration?.sourceType ===
ApplicationRegistrationSourceType.NPM;
<TableRow
gridTemplateColumns={APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
>
<TableHeader> {t`Name`}</TableHeader>
<TableHeader> {t`Description`}</TableHeader>
<TableHeader> {''}</TableHeader>
<TableHeader />
</TableRow>
<StyledTableRowsContainer>
{filteredApplications.map((application) => {
const isNpmApp =
application.applicationRegistration?.sourceType ===
ApplicationRegistrationSourceType.NPM;
const latestVersion =
application.applicationRegistration?.latestAvailableVersion;
const latestVersion =
application.applicationRegistration?.latestAvailableVersion;
const hasUpdate =
isNpmApp &&
isDefined(latestVersion) &&
isDefined(application.version) &&
isNewerSemver(latestVersion, application.version);
const hasUpdate =
isNpmApp &&
isDefined(latestVersion) &&
isDefined(application.version) &&
isNewerSemver(latestVersion, application.version);
return (
<SettingsApplicationTableRow
key={application.id}
application={application}
hasUpdate={hasUpdate}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
})}
/>
);
})}
return (
<SettingsApplicationTableRow
key={application.id}
application={application}
hasUpdate={hasUpdate}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
})}
/>
);
})}
</StyledTableRowsContainer>
</Table>
</StyledTableContainer>
</Section>
@@ -1,34 +1,68 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import {
StyledActionTableCell,
StyledNameTableCell,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext, useMemo, useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import {
Avatar,
CommandBlock,
H2Title,
IconApps,
IconChevronRight,
IconCopy,
IconFileInfo,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Button, SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import {
type ApplicationRegistrationFragmentFragment,
ApplicationRegistrationSourceType,
FeatureFlagKey,
FindManyApplicationRegistrationsDocument,
} from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useMarketplaceApps } from '~/modules/marketplace/hooks/useMarketplaceApps';
const StyledButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
margin: ${themeCssVariables.spacing[2]} 0;
`;
const StyledSearchInputContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledEmptyState = styled.div`
color: ${themeCssVariables.font.color.tertiary};
padding: ${themeCssVariables.spacing[8]};
text-align: center;
`;
const StyledTableRowsContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
padding: ${themeCssVariables.spacing[2]} 0;
`;
const NPM_PACKAGES_GRID_COLUMNS = '200px 1fr 36px';
const SOURCE_TYPE_BADGE_CONFIG: Record<
ApplicationRegistrationSourceType,
{ label: string; color: 'gray' | 'blue' | 'green' }
@@ -53,12 +87,34 @@ const SOURCE_TYPE_BADGE_CONFIG: Record<
export const SettingsApplicationsDeveloperTab = () => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { copyToClipboard } = useCopyToClipboard();
const { data, loading } = useQuery(FindManyApplicationRegistrationsDocument);
const isMarketplaceSettingTabVisible = useIsFeatureEnabled(
FeatureFlagKey.IS_MARKETPLACE_SETTING_TAB_VISIBLE,
);
const [npmSearchTerm, setNpmSearchTerm] = useState('');
const { data: marketplaceApps } = useMarketplaceApps();
const filteredMarketplaceApps = useMemo(() => {
if (!npmSearchTerm) {
return marketplaceApps;
}
const lowerSearch = npmSearchTerm.toLowerCase();
return marketplaceApps.filter(
(application) =>
application.name.toLowerCase().includes(lowerSearch) ||
application.description.toLowerCase().includes(lowerSearch),
);
}, [marketplaceApps, npmSearchTerm]);
const registrations: ApplicationRegistrationFragmentFragment[] =
data?.findManyApplicationRegistrations ?? [];
@@ -123,8 +179,8 @@ export const SettingsApplicationsDeveloperTab = () => {
<>
<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} />
<StyledButtonContainer>
@@ -146,7 +202,7 @@ export const SettingsApplicationsDeveloperTab = () => {
<Section>
<H2Title
title={t`Your Apps`}
title={t`Your apps`}
description={t`All applications registered on this workspace`}
/>
{registrations.length > 0 ? (
@@ -164,6 +220,74 @@ export const SettingsApplicationsDeveloperTab = () => {
)
)}
</Section>
{!isMarketplaceSettingTabVisible && (
<Section>
<H2Title
title={t`NPM packages`}
description={t`Apps made by other developers published on npm`}
/>
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search an application`}
value={npmSearchTerm}
onChange={setNpmSearchTerm}
/>
</StyledSearchInputContainer>
{filteredMarketplaceApps.length === 0 ? (
<StyledEmptyState>{t`No application found`}</StyledEmptyState>
) : (
<Table>
<TableRow gridAutoColumns={NPM_PACKAGES_GRID_COLUMNS}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Description`}</TableHeader>
<TableHeader />
</TableRow>
<StyledTableRowsContainer>
{filteredMarketplaceApps.map((application) => (
<TableRow
key={application.id}
gridAutoColumns={NPM_PACKAGES_GRID_COLUMNS}
to={getSettingsPath(
SettingsPath.AvailableApplicationDetail,
{
availableApplicationId: application.id,
},
)}
>
<StyledNameTableCell>
<Avatar
avatarUrl={application.logo || null}
placeholder={application.name}
placeholderColorSeed={application.name}
size="md"
type="squared"
/>
<OverflowingTextWithTooltip text={application.name} />
</StyledNameTableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<OverflowingTextWithTooltip
text={application.description}
/>
</TableCell>
<StyledActionTableCell>
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
</StyledActionTableCell>
</TableRow>
))}
</StyledTableRowsContainer>
</Table>
)}
</Section>
)}
</>
);
};
@@ -234,6 +234,7 @@ describe('WorkspaceEntityManager', () => {
IS_JSON_FILTER_ENABLED: false,
IS_AI_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: false,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAILING_DOMAIN_ENABLED: false,
@@ -50,6 +50,11 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_MARKETPLACE_SETTING_TAB_VISIBLE,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
workspaceId: workspaceId,
@@ -4,6 +4,7 @@ export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
@@ -5,7 +5,7 @@ import { themeCssVariables } from '@ui/theme-constants';
const StyledContainer = styled.div`
border-radius: ${themeCssVariables.border.radius.sm};
border: 1px solid ${themeCssVariables.border.color.medium};
background: ${themeCssVariables.background.transparent.secondary};
background: ${themeCssVariables.background.secondary};
display: flex;
justify-content: space-between;
padding: ${themeCssVariables.spacing[3]};