[Apps] Apps marketplace (first draft) (#17562)

https://github.com/user-attachments/assets/c4e63edb-6e98-44bc-841f-ee110ae712d4

How it works
- `manifest.json` files are now committed when apps are published to our
repo
- to display available apps, from the server, we read into our github
repo, using a pod-scoped cache
- feature flagged
- app installation will be behind permission gate MARKETPLACE_APPS

Limitations and what is yet to develop
- content and settings tabs
- installed apps tab
- app installation
- test and potentially fix reading from .manifest.json once [Reshape
manifest
structure](https://github.com/twentyhq/core-team-issues/issues/2183) is
done. additional work is expected on assets notably. (couldnt properly
do it here as manifest.json will only be committed after this pr)
- we only read in community/ folder for now - we may want tochange that
- the cache is rather artisanal for now and scoped by pod - we may want
to change that
This commit is contained in:
Marie
2026-01-30 18:32:32 +01:00
committed by GitHub
parent 36cf388c81
commit 0001c2e7d0
98 changed files with 1713 additions and 280 deletions
@@ -78,7 +78,9 @@ generated
# dev
/dist/
.twenty
.twenty/*
!.twenty/output/
# production
/build
+1
View File
@@ -15,6 +15,7 @@ module.exports = {
'./src/modules/favorites/graphql/**/*.{ts,tsx}',
'./src/modules/spreadsheet-import/graphql/**/*.{ts,tsx}',
'./src/modules/command-menu/graphql/**/*.{ts,tsx}',
'./src/modules/marketplace/graphql/**/*.{ts,tsx}',
'./src/modules/prefetch/graphql/**/*.{ts,tsx}',
'./src/modules/subscription/graphql/**/*.{ts,tsx}',
@@ -1414,6 +1414,7 @@ export enum FeatureFlagKey {
IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
@@ -1978,6 +1979,23 @@ export type LoginTokenOutput = {
loginToken: AuthToken;
};
export type MarketplaceApp = {
__typename?: 'MarketplaceApp';
aboutDescription: Scalars['String'];
author: Scalars['String'];
category: Scalars['String'];
description: Scalars['String'];
icon: Scalars['String'];
id: Scalars['String'];
logo?: Maybe<Scalars['String']>;
name: Scalars['String'];
providers: Array<Scalars['String']>;
screenshots: Array<Scalars['String']>;
termsUrl?: Maybe<Scalars['String']>;
version: Scalars['String'];
websiteUrl?: Maybe<Scalars['String']>;
};
export enum MessageChannelVisibility {
METADATA = 'METADATA',
SHARE_EVERYTHING = 'SHARE_EVERYTHING',
@@ -2106,6 +2124,7 @@ export type Mutation = {
initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput;
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput;
installApplication: Scalars['Boolean'];
installMarketplaceApp: Scalars['Boolean'];
removeQueryFromEventStream: Scalars['Boolean'];
removeRoleFromAgent: Scalars['Boolean'];
renewToken: AuthTokens;
@@ -3415,6 +3434,7 @@ export enum PermissionFlagType {
IMPERSONATE = 'IMPERSONATE',
IMPORT_CSV = 'IMPORT_CSV',
LAYOUTS = 'LAYOUTS',
MARKETPLACE_APPS = 'MARKETPLACE_APPS',
PROFILE_INFORMATION = 'PROFILE_INFORMATION',
ROLES = 'ROLES',
SECURITY = 'SECURITY',
@@ -3538,6 +3558,7 @@ export type Query = {
findManyAgents: Array<Agent>;
findManyApplications: Array<Application>;
findManyLogicFunctions: Array<LogicFunction>;
findManyMarketplaceApps: Array<MarketplaceApp>;
findManyPublicDomains: Array<PublicDomain>;
findOneAgent: Agent;
findOneApplication: Application;
File diff suppressed because one or more lines are too long
@@ -7,8 +7,10 @@ import { type FileUIPart } from 'ai';
import { useRecoilState } from 'recoil';
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useUploadFileMutation } from '~/generated-metadata/graphql';
import { FileFolder } from '~/generated/graphql';
import {
FileFolder,
useUploadFileMutation,
} from '~/generated-metadata/graphql';
export const useAIChatFileUpload = () => {
const coreClient = useApolloCoreClient();
@@ -159,6 +159,14 @@ const SettingsApplicationDetails = lazy(() =>
),
);
const SettingsAvailableApplicationDetails = lazy(() =>
import(
'~/pages/settings/applications/SettingsAvailableApplicationDetails'
).then((module) => ({
default: module.SettingsAvailableApplicationDetails,
})),
);
const SettingsAgentForm = lazy(() =>
import('~/pages/settings/ai/SettingsAgentForm').then((module) => ({
default: module.SettingsAgentForm,
@@ -581,6 +589,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApplicationDetail}
element={<SettingsApplicationDetails />}
/>
<Route
path={SettingsPath.AvailableApplicationDetail}
element={<SettingsAvailableApplicationDetails />}
/>
<Route
path={SettingsPath.ApplicationLogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
@@ -0,0 +1,7 @@
import gql from 'graphql-tag';
export const INSTALL_MARKETPLACE_APP = gql`
mutation InstallMarketplaceApp() {
installMarketplaceApp()
}
`;
@@ -0,0 +1,21 @@
import gql from 'graphql-tag';
export const FIND_MANY_MARKETPLACE_APPS = gql`
query FindManyMarketplaceApps {
findManyMarketplaceApps {
id
name
description
icon
version
author
category
logo
screenshots
aboutDescription
providers
websiteUrl
termsUrl
}
}
`;
@@ -0,0 +1,9 @@
export const useInstallMarketplaceApp = () => {
const install = async () => {
return true;
};
return {
install,
};
};
@@ -2,16 +2,19 @@ import styled from '@emotion/styled';
type StyledCardContentProps = {
disabled?: boolean;
alignItems?: 'center' | 'flex-start';
fullHeight?: boolean;
};
export const StyledSettingsOptionCardContent = styled.div<StyledCardContentProps>`
align-items: center;
export const StyledSettingsCardContent = styled.div<StyledCardContentProps>`
align-items: ${({ alignItems }) => alignItems ?? 'center'};
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
background-color: ${({ theme }) => theme.background.secondary};
height: ${({ fullHeight }) => (fullHeight ? '100%' : 'auto')};
padding: ${({ theme }) => theme.spacing(4)};
`;
export const StyledSettingsOptionCardIcon = styled.div`
export const StyledSettingsCardIcon = styled.div`
align-items: center;
border: 2px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
@@ -23,15 +26,17 @@ export const StyledSettingsOptionCardIcon = styled.div`
min-width: ${({ theme }) => theme.icon.size.md};
`;
export const StyledSettingsOptionCardTitle = styled.div`
export const StyledSettingsCardTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
export const StyledSettingsOptionCardDescription = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
export const StyledSettingsCardDescription = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
overflow: hidden;
line-height: 1.5;
a {
position: relative;
@@ -39,3 +44,9 @@ export const StyledSettingsOptionCardDescription = styled.div`
pointer-events: auto;
}
`;
export const StyledSettingsCardThirdLine = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
@@ -1,9 +1,9 @@
import {
StyledSettingsOptionCardContent,
StyledSettingsOptionCardDescription,
StyledSettingsOptionCardIcon,
StyledSettingsOptionCardTitle,
} from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase';
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTitle,
} from '@/settings/components/SettingsOptions/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer';
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/display';
@@ -28,19 +28,19 @@ export const SettingsOptionCardContentButton = ({
Button,
}: SettingsOptionCardContentButtonProps) => {
return (
<StyledSettingsOptionCardContent disabled={disabled}>
<StyledSettingsCardContent disabled={disabled}>
{Icon && (
<StyledSettingsOptionCardIcon>
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsOptionCardIcon>
</StyledSettingsCardIcon>
)}
<div>
<StyledSettingsOptionCardTitle>{title}</StyledSettingsOptionCardTitle>
<StyledSettingsOptionCardDescription>
<StyledSettingsCardTitle>{title}</StyledSettingsCardTitle>
<StyledSettingsCardDescription>
{description}
</StyledSettingsOptionCardDescription>
</StyledSettingsCardDescription>
</div>
{Button && <StyledButtonContainer>{Button}</StyledButtonContainer>}
</StyledSettingsOptionCardContent>
</StyledSettingsCardContent>
);
};
@@ -1,10 +1,10 @@
import { SettingsCounter } from '@/settings/components/SettingsCounter';
import {
StyledSettingsOptionCardContent,
StyledSettingsOptionCardDescription,
StyledSettingsOptionCardIcon,
StyledSettingsOptionCardTitle,
} from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase';
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTitle,
} from '@/settings/components/SettingsOptions/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer';
import { type IconComponent } from 'twenty-ui/display';
@@ -32,18 +32,18 @@ export const SettingsOptionCardContentCounter = ({
showButtons = true,
}: SettingsOptionCardContentCounterProps) => {
return (
<StyledSettingsOptionCardContent disabled={disabled}>
<StyledSettingsCardContent disabled={disabled}>
{Icon && (
<StyledSettingsOptionCardIcon>
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsOptionCardIcon>
</StyledSettingsCardIcon>
)}
<div>
<StyledSettingsOptionCardTitle>{title}</StyledSettingsOptionCardTitle>
<StyledSettingsCardTitle>{title}</StyledSettingsCardTitle>
{description && (
<StyledSettingsOptionCardDescription>
<StyledSettingsCardDescription>
{description}
</StyledSettingsOptionCardDescription>
</StyledSettingsCardDescription>
)}
</div>
<SettingsCounter
@@ -54,6 +54,6 @@ export const SettingsOptionCardContentCounter = ({
disabled={disabled}
showButtons={showButtons}
/>
</StyledSettingsOptionCardContent>
</StyledSettingsCardContent>
);
};
@@ -1,9 +1,9 @@
import {
StyledSettingsOptionCardContent,
StyledSettingsOptionCardDescription,
StyledSettingsOptionCardIcon,
StyledSettingsOptionCardTitle,
} from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase';
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTitle,
} from '@/settings/components/SettingsOptions/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer';
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/display';
@@ -30,19 +30,19 @@ export const SettingsOptionCardContentSelect = ({
children,
}: SettingsOptionCardContentSelectProps) => {
return (
<StyledSettingsOptionCardContent disabled={disabled}>
<StyledSettingsCardContent disabled={disabled}>
{Icon && (
<StyledSettingsOptionCardIcon>
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsOptionCardIcon>
</StyledSettingsCardIcon>
)}
<div>
<StyledSettingsOptionCardTitle>{title}</StyledSettingsOptionCardTitle>
<StyledSettingsOptionCardDescription>
<StyledSettingsCardTitle>{title}</StyledSettingsCardTitle>
<StyledSettingsCardDescription>
{description}
</StyledSettingsOptionCardDescription>
</StyledSettingsCardDescription>
</div>
<StyledSelectContainer>{children}</StyledSelectContainer>
</StyledSettingsOptionCardContent>
</StyledSettingsCardContent>
);
};
@@ -1,10 +1,10 @@
import { Separator } from '@/settings/components/Separator';
import {
StyledSettingsOptionCardContent,
StyledSettingsOptionCardDescription,
StyledSettingsOptionCardIcon,
StyledSettingsOptionCardTitle,
} from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase';
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTitle,
} from '@/settings/components/SettingsOptions/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@@ -12,9 +12,7 @@ import { useId } from 'react';
import { type IconComponent } from 'twenty-ui/display';
import { Toggle } from 'twenty-ui/input';
const StyledSettingsOptionCardToggleContent = styled(
StyledSettingsOptionCardContent,
)`
const StyledSettingsCardToggleContent = styled(StyledSettingsCardContent)`
cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
position: relative;
pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
@@ -24,7 +22,7 @@ const StyledSettingsOptionCardToggleContent = styled(
}
`;
const StyledSettingsOptionCardToggleButton = styled(Toggle)<{
const StyledSettingsCardToggleButton = styled(Toggle)<{
toggleCentered?: boolean;
}>`
align-self: ${({ toggleCentered }) =>
@@ -32,7 +30,7 @@ const StyledSettingsOptionCardToggleButton = styled(Toggle)<{
margin-left: auto;
`;
const StyledSettingsOptionCardToggleCover = styled.span`
const StyledSettingsCardToggleCover = styled.span`
cursor: pointer;
inset: 0;
position: absolute;
@@ -66,24 +64,24 @@ export const SettingsOptionCardContentToggle = ({
return (
<>
<StyledSettingsOptionCardToggleContent disabled={disabled}>
<StyledSettingsCardToggleContent disabled={disabled}>
{Icon && (
<StyledSettingsOptionCardIcon>
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsOptionCardIcon>
</StyledSettingsCardIcon>
)}
<div>
<StyledSettingsOptionCardTitle>
<StyledSettingsCardTitle>
<label htmlFor={toggleId}>
{title}
<StyledSettingsOptionCardToggleCover />
<StyledSettingsCardToggleCover />
</label>
</StyledSettingsOptionCardTitle>
<StyledSettingsOptionCardDescription>
</StyledSettingsCardTitle>
<StyledSettingsCardDescription>
{description}
</StyledSettingsOptionCardDescription>
</StyledSettingsCardDescription>
</div>
<StyledSettingsOptionCardToggleButton
<StyledSettingsCardToggleButton
id={toggleId}
value={checked}
onChange={onChange}
@@ -92,7 +90,7 @@ export const SettingsOptionCardContentToggle = ({
color={advancedMode ? theme.color.yellow : theme.color.blue}
toggleCentered={toggleCentered}
/>
</StyledSettingsOptionCardToggleContent>
</StyledSettingsCardToggleContent>
{divider && <Separator />}
</>
);
@@ -10,11 +10,11 @@ import {
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import {
StyledSettingsOptionCardContent,
StyledSettingsOptionCardDescription,
StyledSettingsOptionCardIcon,
StyledSettingsOptionCardTitle,
} from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase';
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTitle,
} from '@/settings/components/SettingsOptions/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
@@ -161,25 +161,23 @@ export const SettingsAIRouterSettings = () => {
{noModelsAvailable ? (
<Card rounded>
<StyledSettingsOptionCardContent>
<StyledSettingsCardContent>
<StyledErrorMessage>
{t`No models available. Please configure AI models in your workspace settings.`}
</StyledErrorMessage>
</StyledSettingsOptionCardContent>
</StyledSettingsCardContent>
</Card>
) : (
<Card rounded>
<StyledSettingsOptionCardContent>
<StyledSettingsOptionCardIcon>
<StyledSettingsCardContent>
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={IconBolt} />
</StyledSettingsOptionCardIcon>
</StyledSettingsCardIcon>
<div>
<StyledSettingsOptionCardTitle>
{t`Fast Model`}
</StyledSettingsOptionCardTitle>
<StyledSettingsOptionCardDescription>
<StyledSettingsCardTitle>{t`Fast Model`}</StyledSettingsCardTitle>
<StyledSettingsCardDescription>
{t`Quick model for routing decisions`}
</StyledSettingsOptionCardDescription>
</StyledSettingsCardDescription>
</div>
<StyledSelectContainer>
<Select
@@ -190,19 +188,19 @@ export const SettingsAIRouterSettings = () => {
selectSizeVariant="small"
/>
</StyledSelectContainer>
</StyledSettingsOptionCardContent>
</StyledSettingsCardContent>
<StyledSettingsOptionCardContent>
<StyledSettingsOptionCardIcon>
<StyledSettingsCardContent>
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={IconBrain} />
</StyledSettingsOptionCardIcon>
</StyledSettingsCardIcon>
<div>
<StyledSettingsOptionCardTitle>
<StyledSettingsCardTitle>
{t`Smart Model`}
</StyledSettingsOptionCardTitle>
<StyledSettingsOptionCardDescription>
</StyledSettingsCardTitle>
<StyledSettingsCardDescription>
{t`Advanced model for complex planning`}
</StyledSettingsOptionCardDescription>
</StyledSettingsCardDescription>
</div>
<StyledSelectContainer>
<Select
@@ -213,7 +211,7 @@ export const SettingsAIRouterSettings = () => {
selectSizeVariant="small"
/>
</StyledSelectContainer>
</StyledSettingsOptionCardContent>
</StyledSettingsCardContent>
</Card>
)}
</Section>
@@ -1,55 +1,78 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconApps, IconCode, IconDownload } from 'twenty-ui/display';
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
import { type FeatureFlagKey } from '~/generated/graphql';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
import {
H2Title,
CommandBlock,
IconCopy,
IconFileInfo,
} from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useLingui } from '@lingui/react/macro';
import { Button } from 'twenty-ui/input';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { SettingsApplicationsAvailableTab } from '~/pages/settings/applications/tabs/SettingsApplicationsAvailableTab';
import { SettingsApplicationsCreateTab } from '~/pages/settings/applications/tabs/SettingsApplicationsCreateTab';
import { SettingsApplicationsInstalledTab } from '~/pages/settings/applications/tabs/SettingsApplicationsInstalledTab';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
const APPLICATIONS_TAB_LIST_ID = 'applications-tab-list';
export const SettingsApplications = () => {
const { t } = useLingui();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const isMarketplaceEnabled = useIsFeatureEnabled(
'IS_MARKETPLACE_ENABLED' as FeatureFlagKey,
);
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
APPLICATIONS_TAB_LIST_ID,
);
const { data } = useFindManyApplicationsQuery();
const { copyToClipboard } = useCopyToClipboard();
const applications = data?.findManyApplications ?? [];
const commands = [
// eslint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// eslint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
if (!isMarketplaceEnabled) {
return (
<SubMenuTopBarContainer
title={t`Applications`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`Applications` },
]}
>
<SettingsPageContainer>
{applications.length > 0 && (
<SettingsApplicationsTable applications={applications} />
)}
<SettingsApplicationsCreateTab />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
}
const tabs = [
{ id: 'available', title: t`Available`, Icon: IconDownload },
{ id: 'installed', title: t`Installed`, Icon: IconApps },
{ id: 'create', title: t`Create an app`, Icon: IconCode },
];
const button = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
const renderActiveTabContent = () => {
switch (activeTabId) {
case 'available':
return <SettingsApplicationsAvailableTab />;
case 'installed':
return <SettingsApplicationsInstalledTab />;
case 'create':
return <SettingsApplicationsCreateTab />;
default:
return <SettingsApplicationsAvailableTab />;
}
};
return (
<SubMenuTopBarContainer
@@ -63,31 +86,12 @@ export const SettingsApplications = () => {
]}
>
<SettingsPageContainer>
{applications.length > 0 && (
<SettingsApplicationsTable applications={applications} />
)}
<Section>
<H2Title
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
/>
<CommandBlock commands={commands} button={button} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
title={t`Read documentation`}
onClick={() =>
window.open(
getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: '/developers/extend/capabilities/apps',
}),
'_blank',
)
}
/>
</StyledButtonContainer>
</Section>
<TabList
tabs={tabs}
componentInstanceId={APPLICATIONS_TAB_LIST_ID}
behaveAsLinks={false}
/>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -0,0 +1,467 @@
import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
IconApps,
IconBox,
IconColumns,
IconCommand,
IconDownload,
IconFileText,
IconInfoCircle,
IconLayoutGrid,
IconLock,
IconSettings,
IconWorld,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { PermissionFlagType } from '~/generated/graphql';
import { useMarketplaceApps } from '~/pages/settings/applications/hooks/useMarketplaceApps';
const AVAILABLE_APPLICATION_DETAIL_ID = 'available-application-detail';
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledHeaderLeft = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
`;
const StyledLogo = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
flex-shrink: 0;
height: 48px;
justify-content: center;
overflow: hidden;
width: 48px;
`;
const StyledLogoImage = styled.img`
height: 32px;
object-fit: contain;
width: 32px;
`;
const StyledLogoPlaceholder = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.color.blue};
border-radius: ${({ theme }) => theme.border.radius.xs};
color: ${({ theme }) => theme.font.color.inverted};
display: flex;
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 32px;
justify-content: center;
width: 32px;
`;
const StyledHeaderInfo = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledAppName = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledAppDescription = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledContentContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(8)};
`;
const StyledMainContent = styled.div`
flex: 1;
min-width: 0;
`;
const StyledSidebar = styled.div`
flex-shrink: 0;
width: 180px;
`;
const StyledSidebarSection = styled.div`
padding: ${({ theme }) => theme.spacing(3)} 0;
&:first-of-type {
padding-top: 0;
}
`;
const StyledSidebarLabel = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSidebarValue = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledContentItem = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
&:last-of-type {
margin-bottom: 0;
}
`;
const StyledLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
text-decoration: none;
&:hover {
text-decoration: underline;
}
&:last-of-type {
margin-bottom: 0;
}
`;
const StyledScreenshotsContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
height: 300px;
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(4)};
overflow: hidden;
`;
const StyledScreenshotImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledScreenshotThumbnails = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(6)};
`;
const StyledThumbnail = styled.div<{ isSelected?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid
${({ theme, isSelected }) =>
isSelected ? theme.color.blue : theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: pointer;
display: flex;
flex: 1;
height: 60px;
justify-content: center;
overflow: hidden;
&:hover {
border-color: ${({ theme }) => theme.color.blue};
}
`;
const StyledThumbnailImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledSectionTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: 0 0 ${({ theme }) => theme.spacing(3)} 0;
`;
const StyledAboutText = styled.p`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
line-height: 1.6;
margin: 0 0 ${({ theme }) => theme.spacing(6)} 0;
white-space: pre-line;
`;
const StyledProvidersList = styled.ul`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
list-style-type: disc;
margin: 0;
padding-left: ${({ theme }) => theme.spacing(5)};
`;
const StyledProviderItem = styled.li`
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsAvailableApplicationDetails = () => {
const { availableApplicationId = '' } = useParams<{
availableApplicationId: string;
}>();
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
const { data: marketplaceApps } = useMarketplaceApps();
const { install } = useInstallMarketplaceApp();
const canInstallMarketplaceApps = useHasPermissionFlag(
PermissionFlagType.MARKETPLACE_APPS,
);
const application = useMemo(() => {
return marketplaceApps?.find((app) => app.id === availableApplicationId);
}, [availableApplicationId, marketplaceApps]);
const handleInstall = async () => {
if (isDefined(application)) {
await install();
}
};
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
AVAILABLE_APPLICATION_DETAIL_ID,
);
const tabs = [
{ id: 'about', title: t`About`, Icon: IconInfoCircle },
{ id: 'content', title: t`Content`, Icon: IconBox },
{ id: 'permissions', title: t`Permissions`, Icon: IconLock },
{ id: 'settings', title: t`Settings`, Icon: IconSettings },
];
const getInitials = (name: string) => {
return name.charAt(0).toUpperCase();
};
const hasScreenshots =
application?.screenshots && application.screenshots.length > 0;
const renderActiveTabContent = () => {
if (!application) return null;
switch (activeTabId) {
case 'about':
return (
<>
{hasScreenshots && (
<>
<StyledScreenshotsContainer>
<StyledScreenshotImage
src={application.screenshots[selectedScreenshotIndex]}
alt={`${application.name} screenshot ${selectedScreenshotIndex + 1}`}
/>
</StyledScreenshotsContainer>
<StyledScreenshotThumbnails>
{application.screenshots
.slice(0, 6)
.map((screenshot, index) => (
<StyledThumbnail
key={index}
isSelected={index === selectedScreenshotIndex}
onClick={() => setSelectedScreenshotIndex(index)}
>
<StyledThumbnailImage
src={screenshot}
alt={`${application.name} thumbnail ${index + 1}`}
/>
</StyledThumbnail>
))}
</StyledScreenshotThumbnails>
</>
)}
<StyledContentContainer>
<StyledMainContent>
<Section>
<StyledSectionTitle>{t`About`}</StyledSectionTitle>
<StyledAboutText>
{application.aboutDescription}
</StyledAboutText>
<StyledSectionTitle>{t`Providers`}</StyledSectionTitle>
<StyledProvidersList>
{application.providers.map((provider) => (
<StyledProviderItem key={provider}>
{provider}
</StyledProviderItem>
))}
</StyledProvidersList>
</Section>
</StyledMainContent>
<StyledSidebar>
<StyledSidebarSection>
<StyledSidebarLabel>{t`Created by`}</StyledSidebarLabel>
<StyledSidebarValue>{application.author}</StyledSidebarValue>
</StyledSidebarSection>
<StyledSidebarSection>
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
<StyledSidebarValue>
{application.category}
</StyledSidebarValue>
</StyledSidebarSection>
<StyledSidebarSection>
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
<StyledContentItem>
<IconLayoutGrid size={16} />
{application.content.objects} {t`objects`}
</StyledContentItem>
<StyledContentItem>
<IconColumns size={16} />
{application.content.fields} {t`fields`}
</StyledContentItem>
<StyledContentItem>
<IconApps size={16} />
{application.content.frontComponents} {t`front components`}
</StyledContentItem>
<StyledContentItem>
<IconCommand size={16} />
{application.content.functions} {t`functions`}
</StyledContentItem>
</StyledSidebarSection>
<StyledSidebarSection>
<StyledSidebarLabel>{t`Latest`}</StyledSidebarLabel>
<StyledSidebarValue>{application.version}</StyledSidebarValue>
</StyledSidebarSection>
<StyledSidebarSection>
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
<StyledLink
href={application.websiteUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconWorld size={16} />
{t`Website`}
</StyledLink>
<StyledLink
href={application.termsUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconFileText size={16} />
{t`Terms / Privacy`}
</StyledLink>
</StyledSidebarSection>
</StyledSidebar>
</StyledContentContainer>
</>
);
case 'content':
return <div>{t`Content tab`}</div>;
case 'permissions':
return <div>{t`Permissions tab`}</div>;
case 'settings':
return <div>{t`Settings tab`}</div>;
default:
return null;
}
};
if (!application) {
return null;
}
return (
<SubMenuTopBarContainer
title={application.name}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: application.name },
]}
>
<SettingsPageContainer>
<StyledHeader>
<StyledHeaderLeft>
<StyledLogo>
{application.logoPath ? (
<StyledLogoImage
src={application.logoPath}
alt={application.name}
/>
) : (
<StyledLogoPlaceholder>
{getInitials(application.name)}
</StyledLogoPlaceholder>
)}
</StyledLogo>
<StyledHeaderInfo>
<StyledAppName>{application.name}</StyledAppName>
<StyledAppDescription>
{application.description}
</StyledAppDescription>
</StyledHeaderInfo>
</StyledHeaderLeft>
{canInstallMarketplaceApps && (
<Button
Icon={IconDownload}
title={t`Install`}
variant="primary"
accent="blue"
onClick={handleInstall}
/>
)}
</StyledHeader>
<TabList
tabs={tabs}
componentInstanceId={AVAILABLE_APPLICATION_DETAIL_ID}
behaveAsLinks={false}
/>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,63 @@
import {
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardThirdLine,
StyledSettingsCardTitle,
} from '@/settings/components/SettingsOptions/SettingsCardContentBase';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Link } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { Card } from 'twenty-ui/layout';
import { type AvailableApplication } from '~/pages/settings/applications/types/availableApplication';
type SettingsAvailableApplicationCardProps = {
application: AvailableApplication;
};
const StyledLink = styled(Link)`
display: flex;
height: 100%;
text-decoration: none;
`;
const StyledDescription = styled(StyledSettingsCardDescription)`
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
`;
export const SettingsAvailableApplicationCard = ({
application,
}: SettingsAvailableApplicationCardProps) => {
return (
<StyledLink
to={getSettingsPath(SettingsPath.AvailableApplicationDetail, {
availableApplicationId: application.id,
})}
>
<Card rounded fullWidth>
<StyledSettingsCardContent alignItems="flex-start" fullHeight>
<Avatar
avatarUrl={application.logoPath || null}
placeholder={application.name}
placeholderColorSeed={application.name}
size="lg"
type="squared"
/>
<div>
<StyledSettingsCardTitle>
{application.name}
</StyledSettingsCardTitle>
<StyledDescription>{application.description}</StyledDescription>
<StyledSettingsCardThirdLine>
{t`by {author}`} {application.author}
</StyledSettingsCardThirdLine>
</div>
</StyledSettingsCardContent>
</Card>
</StyledLink>
);
};
@@ -0,0 +1,34 @@
import { useFindManyMarketplaceAppsQuery } from '~/generated/graphql';
import { type AvailableApplication } from '~/pages/settings/applications/types/availableApplication';
export const useMarketplaceApps = () => {
const { data, loading, error } = useFindManyMarketplaceAppsQuery();
const marketplaceApps: AvailableApplication[] =
data?.findManyMarketplaceApps.map((app) => ({
id: app.id,
name: app.name,
description: app.description,
author: app.author,
logoPath: app.logo ?? '',
category: app.category,
aboutDescription: app.aboutDescription,
providers: app.providers,
screenshots: app.screenshots,
content: {
objects: 0,
fields: 0,
functions: 0,
frontComponents: 0,
},
version: app.version,
websiteUrl: app.websiteUrl ?? '',
termsUrl: app.termsUrl ?? '',
})) ?? [];
return {
data: marketplaceApps,
isLoading: loading,
error,
};
};
@@ -0,0 +1,81 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { SettingsAvailableApplicationCard } from '~/pages/settings/applications/components/SettingsAvailableApplicationCard';
import { useMarketplaceApps } from '~/pages/settings/applications/hooks/useMarketplaceApps';
import { type AvailableApplication } from '~/pages/settings/applications/types/availableApplication';
const StyledSearchInputContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledCardsGrid = styled.div`
display: grid;
gap: ${({ theme }) => theme.spacing(3)};
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 800px) {
grid-template-columns: minmax(0, 1fr);
}
`;
const StyledEmptyState = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
padding: ${({ theme }) => theme.spacing(4)};
text-align: center;
`;
export const SettingsApplicationsAvailableTab = () => {
const { t } = useLingui();
const [searchTerm, setSearchTerm] = useState('');
const { data: marketplaceApps, isLoading } = useMarketplaceApps();
const applications = marketplaceApps;
const filteredApplications = useMemo(() => {
return applications.filter(
(application: AvailableApplication) =>
application.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
application.description
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
application.author.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [applications, searchTerm]);
if (isLoading) {
return (
<Section>
<StyledEmptyState>{t`Loading applications...`}</StyledEmptyState>
</Section>
);
}
return (
<Section>
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search an application`}
value={searchTerm}
onChange={setSearchTerm}
/>
</StyledSearchInputContainer>
{filteredApplications.length === 0 ? (
<StyledEmptyState>{t`No applications available`}</StyledEmptyState>
) : (
<StyledCardsGrid>
{filteredApplications.map((application: AvailableApplication) => (
<SettingsAvailableApplicationCard
key={application.id}
application={application}
/>
))}
</StyledCardsGrid>
)}
</Section>
);
};
@@ -0,0 +1,68 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import {
CommandBlock,
H2Title,
IconCopy,
IconFileInfo,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
export const SettingsApplicationsCreateTab = () => {
const { t } = useLingui();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { copyToClipboard } = useCopyToClipboard();
const commands = [
// eslint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// eslint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
];
const button = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
return (
<Section>
<H2Title
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
/>
<CommandBlock commands={commands} button={button} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
title={t`Read documentation`}
onClick={() =>
window.open(
getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: '/developers/extend/capabilities/apps',
}),
'_blank',
)
}
/>
</StyledButtonContainer>
</Section>
);
};
@@ -0,0 +1,14 @@
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
export const SettingsApplicationsInstalledTab = () => {
const { data } = useFindManyApplicationsQuery();
const applications = data?.findManyApplications ?? [];
if (applications.length === 0) {
return null;
}
return <SettingsApplicationsTable applications={applications} />;
};
@@ -0,0 +1,20 @@
export type AvailableApplication = {
id: string;
name: string;
description: string;
author: string;
logoPath: string;
category: string;
aboutDescription: string;
providers: string[];
screenshots: string[];
content: {
objects: number;
fields: number;
functions: number;
frontComponents: number;
};
version: string;
websiteUrl: string;
termsUrl: string;
};
@@ -9,7 +9,6 @@ import {
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRow';
import { SettingsObjectInactiveMenuDropDown } from '@/settings/data-model/objects/components/SettingsObjectInactiveMenuDropDown';
import { getItemTagInfo } from '@/settings/data-model/utils/getItemTagInfo';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -21,18 +20,12 @@ import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/st
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import { type ReactNode, useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
IconArchive,
IconChevronRight,
IconFilter,
IconSearch,
IconSettings,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { IconArchive, IconChevronRight, IconSettings } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { GET_SETTINGS_OBJECT_TABLE_METADATA } from '~/pages/settings/data-model/constants/SettingsObjectTableMetadata';
import type { SettingsObjectTableItem } from '~/pages/settings/data-model/types/SettingsObjectTableItem';
@@ -42,18 +35,10 @@ const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
const StyledSearchInputContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
width: 100%;
`;
export const SettingsObjectTable = ({
objectMetadataItems,
withSearchBar = true,
@@ -146,53 +131,47 @@ export const SettingsObjectTable = ({
return (
<>
{withSearchBar && (
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="settings-objects-search"
LeftIcon={IconSearch}
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search for an object...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Dropdown
dropdownId="settings-objects-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
filterDropdown={(filterButton: ReactNode) => (
<Dropdown
dropdownId="settings-objects-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() =>
setShowDeactivated(!showDeactivated)
}
toggled={showDeactivated}
text={t`Deactivated`}
toggleSize="small"
/>
{isAdvancedModeEnabled && (
<MenuItemToggle
LeftIcon={IconSettings}
onToggleChange={() =>
setShowSystemObjects(!showSystemObjects)
}
toggled={showSystemObjects}
text={t`System objects`}
toggleSize="small"
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeactivated(!showDeactivated)}
toggled={showDeactivated}
text={t`Deactivated`}
toggleSize="small"
/>
{isAdvancedModeEnabled && (
<MenuItemToggle
LeftIcon={IconSettings}
onToggleChange={() =>
setShowSystemObjects(!showSystemObjects)
}
toggled={showSystemObjects}
text={t`System objects`}
toggleSize="small"
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
)}
/>
</StyledSearchAndFilterContainer>
</StyledSearchInputContainer>
)}
<Table>
@@ -6,7 +6,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity';
@@ -10,7 +10,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
@@ -9,7 +9,7 @@ import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
@@ -9,7 +9,7 @@ import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { updateStandardPageLayoutWidgetsWithWorkspaceFieldIds } from 'src/database/commands/upgrade-version-command/1-16/utils/update-standard-page-layout-widgets-with-workspace-field-ids.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -7,7 +7,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -10,7 +10,7 @@ import {
RunOnWorkspaceArgs,
WorkspacesMigrationCommandRunner,
} from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -7,7 +7,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -10,7 +10,7 @@ import {
RunOnWorkspaceArgs,
WorkspacesMigrationCommandRunner,
} from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
@@ -7,7 +7,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ALL_METADATA_ENTITY_BY_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant';
@@ -7,7 +7,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
@@ -7,7 +7,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -7,7 +7,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -7,7 +7,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -8,7 +8,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ALL_ENTITY_VIEW_NAME } from 'src/database/commands/upgrade-version-command/1-16/utils/compute-formatted-view-name.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -6,7 +6,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
@@ -1,22 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver';
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
import { MarketplaceResolver } from 'src/engine/core-modules/application/resolvers/marketplace.resolver';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionLayerModule } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { LogicFunctionLayerModule } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
@@ -46,6 +47,7 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
],
providers: [
ApplicationResolver,
MarketplaceResolver,
ApplicationSyncService,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
@@ -2,7 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { MarketplaceService } from 'src/engine/core-modules/application/services/marketplace.service';
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -15,7 +16,15 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceCacheModule,
],
exports: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
providers: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
exports: [
ApplicationService,
WorkspaceFlatApplicationMapCacheService,
MarketplaceService,
],
providers: [
ApplicationService,
WorkspaceFlatApplicationMapCacheService,
MarketplaceService,
],
})
export class ApplicationModule {}
@@ -0,0 +1,70 @@
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsArray,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
@ObjectType('MarketplaceApp')
export class MarketplaceAppDTO {
@IsString()
@IsNotEmpty()
@Field()
id: string;
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@Field()
@MaxLength(160)
description: string;
@IsString()
@Field()
icon: string;
@IsString()
@Field()
version: string;
@IsString()
@Field()
author: string;
@IsString()
@Field()
category: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
logo?: string;
@IsArray()
@Field(() => [String])
screenshots: string[];
@IsString()
@Field()
aboutDescription: string;
@IsArray()
@Field(() => [String])
providers: string[];
@IsOptional()
@IsString()
@Field({ nullable: true })
websiteUrl?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
termsUrl?: string;
}
@@ -16,17 +16,17 @@ import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
@@ -0,0 +1,29 @@
import { UseGuards } from '@nestjs/common';
import { Mutation, Query, Resolver } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/dtos/marketplace-app.dto';
import { MarketplaceService } from 'src/engine/core-modules/application/services/marketplace.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Resolver()
@UseGuards(UserAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
export class MarketplaceResolver {
constructor(private readonly marketplaceService: MarketplaceService) {}
@Query(() => [MarketplaceAppDTO])
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
return this.marketplaceService.findAllMarketplaceApps();
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
async installMarketplaceApp(): Promise<boolean> {
// TODO
return true;
}
}
@@ -5,10 +5,10 @@ import { parse } from 'path';
import {
Manifest,
FieldManifest,
ObjectManifest,
RoleManifest,
LogicFunctionManifest,
LogicFunctionTriggerManifest,
ObjectManifest,
RoleManifest,
ObjectFieldManifest,
RelationFieldManifest,
} from 'twenty-shared/application';
@@ -20,8 +20,8 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
@@ -35,15 +35,15 @@ import { ObjectPermissionService } from 'src/engine/metadata-modules/object-perm
import { PermissionFlagService } from 'src/engine/metadata-modules/permission-flag/permission-flag.service';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { LogicFunctionLayerService } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.service';
import { LogicFunctionV2Service } from 'src/engine/metadata-modules/logic-function/services/logic-function-v2.service';
import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label-or-throw.util';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import {
CronTriggerSettings,
DatabaseEventTriggerSettings,
HttpRouteTriggerSettings,
} from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { LogicFunctionV2Service } from 'src/engine/metadata-modules/logic-function/services/logic-function-v2.service';
import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label-or-throw.util';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class ApplicationSyncService {
@@ -0,0 +1,250 @@
import { Injectable, Logger } from '@nestjs/common';
// eslint-disable-next-line no-restricted-imports
import { lowerCase, upperFirst } from 'lodash';
import { type Manifest } from 'twenty-shared/application';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/dtos/marketplace-app.dto';
type GitHubContent = {
name: string;
path: string;
type: 'file' | 'dir';
download_url?: string;
};
const GITHUB_RAW_BASE_URL =
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-apps';
const GITHUB_API_BASE_URL =
'https://api.github.com/repos/twentyhq/twenty/contents/packages/twenty-apps';
const CACHE_TTL_MS = 60 * 60 * 1000;
@Injectable()
export class MarketplaceService {
private readonly logger = new Logger(MarketplaceService.name);
private cachedApps: MarketplaceAppDTO[] | null = null;
private cacheTimestamp: number | null = null;
async findAllMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
if (this.isCacheValid()) {
return this.cachedApps as MarketplaceAppDTO[];
}
const apps = await this.fetchAllMarketplaceApps();
this.cachedApps = apps;
this.cacheTimestamp = Date.now();
return apps;
}
private isCacheValid(): boolean {
if (this.cachedApps === null || this.cacheTimestamp === null) {
return false;
}
return Date.now() - this.cacheTimestamp < CACHE_TTL_MS;
}
private async fetchAllMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
const apps: MarketplaceAppDTO[] = [];
apps.push(this.loadMockApp());
try {
const appDirs = await this.getAppDirectoriesFromGitHub();
for (const appDir of appDirs) {
try {
const manifest = await this.loadAppManifestFromGithub(appDir);
if (manifest) {
apps.push(manifest);
} else {
// to remove after manifest are committed to the repo
const appName = appDir.replace(/^community\//, '');
const appLabel = upperFirst(lowerCase(appName));
apps.push({
id: appName,
name: appLabel,
description: '',
icon: '',
version: '',
author: 'Anonymous',
category: '',
logo: undefined,
screenshots: [],
aboutDescription: '',
providers: [],
websiteUrl: '',
termsUrl: '',
});
}
} catch (error) {
this.logger.warn(
`Failed to load manifest from ${appDir}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
} catch (error) {
this.logger.error(
`Failed to fetch marketplace apps from GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
return apps;
}
private loadMockApp(): MarketplaceAppDTO {
// SVG logo as data URL
const logoSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
const logoDataUrl = `data:image/svg+xml,${encodeURIComponent(logoSvg)}`;
return {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'Data Enrichment',
description: 'Enrich your data easily. Choose your provider.',
icon: 'IconSparkles',
version: '1.0.0',
author: 'Cosmos Labs',
category: 'Data',
logo: logoDataUrl,
screenshots: [
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+1',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+2',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+3',
],
aboutDescription:
'Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.',
providers: ['Clearbit', 'Apollo', 'Hunter.io'],
websiteUrl: 'https://google.com',
termsUrl: 'https://google.com',
};
}
private async getAppDirectoriesFromGitHub(): Promise<string[]> {
const directories: string[] = [];
const rootContents = await this.fetchGitHubDirectory('');
for (const entry of rootContents) {
// if (entry.type !== 'dir') continue;
// if (entry.name.startsWith('.')) continue;
// if (entry.name === 'node_modules') continue;
// if (entry.name === 'internal') continue;
if (entry.name === 'community') {
const communityContents = await this.fetchGitHubDirectory('community');
for (const communityEntry of communityContents) {
if (communityEntry.type !== 'dir') continue;
if (communityEntry.name.startsWith('.')) continue;
directories.push(`community/${communityEntry.name}`);
}
}
}
return directories;
}
private async fetchGitHubDirectory(path: string): Promise<GitHubContent[]> {
const url = path ? `${GITHUB_API_BASE_URL}/${path}` : GITHUB_API_BASE_URL;
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Twenty-Marketplace',
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
return response.json();
}
private async fetchGitHubFile(path: string): Promise<string | null> {
const url = `${GITHUB_RAW_BASE_URL}/${path}`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Twenty-Marketplace',
},
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`GitHub raw file error: ${response.status}`);
}
return response.text();
}
private async loadAppManifestFromGithub(
appPath: string,
): Promise<MarketplaceAppDTO | null> {
const manifestContent = await this.fetchGitHubFile(
`${appPath}/.twenty/output/manifest.json`,
);
if (!manifestContent) {
return null;
}
const manifest = JSON.parse(manifestContent) as Manifest;
const { application, packageJson } = manifest;
const marketplaceData = application.marketplaceData;
if (!marketplaceData?.author || !marketplaceData?.category) {
return null;
}
return {
id: application.universalIdentifier,
name: application.displayName ?? packageJson.name,
description: application.description ?? '',
icon: application.icon ?? 'IconApps',
version: packageJson.version,
author: marketplaceData.author,
category: marketplaceData.category,
logo: this.resolveAssetUrl(appPath, marketplaceData.logo),
screenshots: this.resolveAssetUrls(appPath, marketplaceData.screenshots),
aboutDescription: marketplaceData.aboutDescription ?? '',
providers: marketplaceData.providers ?? [],
websiteUrl: marketplaceData.websiteUrl,
termsUrl: marketplaceData.termsUrl,
};
}
private resolveAssetUrl(
appPath: string,
relativePath: string | undefined,
): string | undefined {
if (!relativePath) {
return undefined;
}
return `${GITHUB_RAW_BASE_URL}/${appPath}/${relativePath}`;
}
private resolveAssetUrls(
appPath: string,
relativePaths: string[] | undefined,
): string[] {
if (!relativePaths) {
return [];
}
return relativePaths
.map((path) => this.resolveAssetUrl(appPath, path))
.filter((url): url is string => url !== undefined);
}
}
@@ -10,7 +10,7 @@ import { v4 } from 'uuid';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import {
AuthException,
AuthExceptionCode,
@@ -4,6 +4,7 @@ export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED',
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED = 'IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744">
<!-- Database cylinder -->
<ellipse cx="38" cy="20" rx="28" ry="10"/>
<rect x="10" y="20" width="56" height="50"/>
<ellipse cx="38" cy="70" rx="28" ry="10"/>
<!-- Database stripes -->
<ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/>
<ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/>
<!-- Upload circle -->
<circle cx="72" cy="62" r="22" fill="#1a2744"/>
<circle cx="72" cy="62" r="18" fill="#fff"/>
<!-- Upload arrow -->
<path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 743 B

@@ -0,0 +1,37 @@
{
"application": {
"universalIdentifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"functionRoleUniversalIdentifier": "00000000-0000-0000-0000-000000000000",
"displayName": "Data Enrichment",
"description": "Enrich your data easily. Choose your provider.",
"icon": "IconSparkles",
"marketplaceData": {
"author": "Cosmos Labs",
"category": "Data",
"logo": "assets/logo.png",
"screenshots": [
"assets/screenshot-1.png",
"assets/screenshot-2.png",
"assets/screenshot-3.png"
],
"aboutDescription": "Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.",
"providers": ["Clearbit", "Apollo", "Hunter.io"],
"websiteUrl": "https://google.com",
"termsUrl": "https://google.com"
}
},
"entities": {
"objects": [],
"fields": [],
"logicFunctions": [],
"frontComponents": [],
"roles": []
},
"publicAssets": [],
"sources": {},
"packageJson": {
"name": "@twenty-apps/data-enrichment",
"version": "1.0.0"
},
"yarnLock": ""
}
@@ -4,7 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type Repository, type UpdateResult } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
@@ -24,7 +24,7 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { fromFlatApplicationToApplicationDto } from 'src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util';
import { BillingEntitlementDTO } from 'src/engine/core-modules/billing/dtos/billing-entitlement.dto';
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ILike, IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/create-agent.input';
import { type UpdateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/update-agent.input';
import { fromCreateAgentInputToFlatAgent } from 'src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import {
CommandMenuItemException,
CommandMenuItemExceptionCode,
@@ -5,7 +5,7 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { isDefined } from 'twenty-shared/utils';
import { type FindOneOptions, type Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { type DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -12,7 +12,7 @@ import { Repository } from 'typeorm';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { type LogicFunctionExecuteResult } from 'src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/logic-function/logic-function-executed';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import type { CreateLogicFunctionInput } from 'src/engine/metadata-modules/logic-function/dtos/create-logic-function.input';
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
@@ -5,7 +5,7 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils';
import { FindManyOptions, FindOneOptions, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatPageLayoutTabMaps } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab-maps.type';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatPageLayoutTabMaps } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab-maps.type';
@@ -4,7 +4,7 @@ import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { computeDiffBetweenObjects, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-tab/constants/flat-page-layout-tab-editable-properties.constant';
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatPageLayoutTabMaps } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab-maps.type';
@@ -4,7 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -7,7 +7,7 @@ import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { TOOL_PERMISSION_FLAGS } from 'src/engine/metadata-modules/permissions/constants/tool-permission-flags';
import {
PermissionsException,
@@ -119,6 +119,7 @@ export class PermissionsService {
[PermissionFlagType.IMPERSONATE]: false,
[PermissionFlagType.SSO_BYPASS]: false,
[PermissionFlagType.PROFILE_INFORMATION]: false,
[PermissionFlagType.MARKETPLACE_APPS]: false,
},
objectsPermissions: {},
}) as const satisfies UserWorkspacePermissions;
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type';
@@ -18,7 +18,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -6,7 +6,7 @@ import { ConfigService } from '@nestjs/config';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
@@ -3,7 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, type Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateViewFilterGroupInputToFlatViewFilterGroupToCreate } from 'src/engine/metadata-modules/flat-view-filter-group/utils/from-create-view-filter-group-input-to-flat-view-filter-group-to-create.util';
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateViewFilterInputToFlatViewFilterToCreate } from 'src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util';
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
@@ -3,7 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import {
@@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import {
ViewSortException,
@@ -5,7 +5,7 @@ import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateWebhookInputToFlatWebhookToCreate } from 'src/engine/metadata-modules/flat-webhook/utils/from-create-webhook-input-to-flat-webhook-to-create.util';
@@ -242,6 +242,7 @@ describe('WorkspaceEntityManager', () => {
IS_NAVIGATION_MENU_ITEM_ENABLED: false,
IS_FILES_FIELD_ENABLED: false,
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
},
userWorkspaceRoleMap: {},
@@ -1,6 +1,6 @@
import { type DataSource } from 'typeorm';
import { type ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { seedBillingCustomers } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util';
import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util';
import {
@@ -91,6 +91,11 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_MARKETPLACE_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId: workspaceId,
@@ -4,7 +4,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { DataSource } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service';
@@ -1,4 +1,4 @@
import type { ApplicationService } from 'src/engine/core-modules/application/application.service';
import type { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import type { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import type { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service';
import type { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service';
@@ -3,10 +3,10 @@ import { Injectable } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildApplicationAuthContext } from 'src/engine/core-modules/auth/utils/build-application-auth-context.util';
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { ADMIN_ROLE } from 'src/engine/metadata-modules/role/constants/admin-role';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
@@ -1,10 +1,22 @@
import { type ApplicationVariables } from '@/sdk';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
export type ApplicationMarketplaceData = {
author?: string;
category?: string;
logo?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
websiteUrl?: string;
termsUrl?: string;
};
export type ApplicationManifest = SyncableEntityOptions & {
defaultRoleUniversalIdentifier: string;
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: ApplicationVariables;
marketplaceData?: ApplicationMarketplaceData;
};
@@ -7,7 +7,10 @@
* |___/
*/
export type { ApplicationManifest } from './applicationType';
export type {
ApplicationMarketplaceData,
ApplicationManifest,
} from './applicationType';
export type { ApplicationVariables } from './applicationVariablesType';
export type { AssetManifest } from './assetManifestType';
export { ASSETS_DIR } from './constants/AssetDirectory';
@@ -10,6 +10,7 @@ export enum PermissionFlagType {
IMPERSONATE = 'IMPERSONATE',
SSO_BYPASS = 'SSO_BYPASS',
APPLICATIONS = 'APPLICATIONS',
MARKETPLACE_APPS = 'MARKETPLACE_APPS',
LAYOUTS = 'LAYOUTS',
BILLING = 'BILLING',
AI_SETTINGS = 'AI_SETTINGS',
@@ -36,6 +36,7 @@ export enum SettingsPath {
Applications = 'applications',
ApplicationDetail = 'applications/:applicationId',
ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId',
AvailableApplicationDetail = 'applications/available/:availableApplicationId',
LogicFunctions = 'functions',
NewLogicFunction = 'functions/new',
LogicFunctionDetail = 'functions/:logicFunctionId',
@@ -89,6 +89,8 @@ export {
IconCodeCircle,
IconCoins,
IconColorSwatch,
IconColumns,
IconCommand,
IconMessageCircle as IconComment,
IconCopy,
IconCopyPlus,
@@ -221,6 +223,7 @@ export {
IconLayersLinked,
IconLayout,
IconLayoutDashboard,
IconLayoutGrid,
IconLayoutKanban,
IconLayoutList,
IconLayoutNavbar,
+3
View File
@@ -156,6 +156,8 @@ export {
IconCodeCircle,
IconCoins,
IconColorSwatch,
IconColumns,
IconCommand,
IconComment,
IconCopy,
IconCopyPlus,
@@ -288,6 +290,7 @@ export {
IconLayersLinked,
IconLayout,
IconLayoutDashboard,
IconLayoutGrid,
IconLayoutKanban,
IconLayoutList,
IconLayoutNavbar,
@@ -0,0 +1,105 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconFilter, IconSearch } from '@ui/display';
import { IconButton } from '@ui/input/button/components/IconButton';
import { type ChangeEvent, type ReactNode, useState } from 'react';
export type SearchInputProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
filterDropdown?: (filterButton: ReactNode) => ReactNode;
autoFocus?: boolean;
disabled?: boolean;
className?: string;
};
const StyledWrapper = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledInputContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
height: 32px;
padding: 0 ${({ theme }) => theme.spacing(2)};
&:focus-within {
border-color: ${({ theme }) => theme.color.blue};
}
`;
const StyledIconContainer = styled.div<{ isFocused: boolean }>`
align-items: center;
color: ${({ theme, isFocused }) =>
isFocused ? theme.font.color.secondary : theme.font.color.light};
display: flex;
justify-content: center;
`;
const StyledInput = styled.input`
background: transparent;
border: none;
color: ${({ theme }) => theme.font.color.primary};
flex: 1;
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
outline: none;
width: 100%;
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
&:disabled {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
export const SearchInput = ({
value,
onChange,
placeholder,
filterDropdown,
autoFocus,
disabled,
className,
}: SearchInputProps) => {
const theme = useTheme();
const [isFocused, setIsFocused] = useState(false);
const filterButton = <IconButton Icon={IconFilter} variant="secondary" />;
return (
<StyledWrapper className={className}>
<StyledInputContainer>
<StyledIconContainer isFocused={isFocused}>
<IconSearch size={theme.icon.size.md} />
</StyledIconContainer>
<StyledInput
value={value}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange(event.target.value)
}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
autoFocus={autoFocus}
disabled={disabled}
/>
</StyledInputContainer>
{filterDropdown && filterDropdown(filterButton)}
</StyledWrapper>
);
};
+2
View File
@@ -99,6 +99,8 @@ export { IconListViewGrip } from './components/IconListViewGrip';
export type { RadioProps } from './components/Radio';
export { RadioSize, LabelPosition, Radio } from './components/Radio';
export { RadioGroup } from './components/RadioGroup';
export type { SearchInputProps } from './components/SearchInput';
export { SearchInput } from './components/SearchInput';
export type { ToggleSize, ToggleProps } from './components/Toggle';
export { Toggle } from './components/Toggle';
export type { ColorScheme } from './types/ColorScheme';