diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 6a4d98291e..4b95d1ae5e 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1779,6 +1779,7 @@ enum FeatureFlagKey { IS_EMAIL_GROUP_ENABLED IS_JUNCTION_RELATIONS_ENABLED IS_REST_METADATA_API_NEW_FORMAT_DIRECT + IS_SETTINGS_DISCOVERY_HERO_ENABLED } type WorkspaceUrls { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index dc602df02b..446464d971 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1411,7 +1411,7 @@ export interface FeatureFlag { __typename: 'FeatureFlag' } -export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' +export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' export interface WorkspaceUrls { customUrl?: Scalars['String'] @@ -8732,7 +8732,8 @@ export const enumFeatureFlagKey = { IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const, IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const, IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const, - IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const + IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const, + IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const } export const enumIdentityProviderType = { diff --git a/packages/twenty-front/public/images/ai/ai-tools-cover-dark.png b/packages/twenty-front/public/images/ai/ai-tools-cover-dark.png index 6ab502be13..24b3ae918a 100644 Binary files a/packages/twenty-front/public/images/ai/ai-tools-cover-dark.png and b/packages/twenty-front/public/images/ai/ai-tools-cover-dark.png differ diff --git a/packages/twenty-front/public/images/ai/ai-tools-cover-light.png b/packages/twenty-front/public/images/ai/ai-tools-cover-light.png index b29ed89a50..dc68ef81af 100644 Binary files a/packages/twenty-front/public/images/ai/ai-tools-cover-light.png and b/packages/twenty-front/public/images/ai/ai-tools-cover-light.png differ diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index 8926f0f4d0..e7f04a0980 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -289,6 +289,7 @@ export enum FeatureFlagKey { IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', + IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED' } diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index fb584c997b..ce5eea32df 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1641,6 +1641,7 @@ export enum FeatureFlagKey { IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', + IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED' } diff --git a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts index 6e0c2892e3..fe71592a34 100644 --- a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts +++ b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts @@ -24,9 +24,6 @@ export const useEnterLayoutCustomizationMode = () => { const { navigateSidePanel } = useNavigateSidePanel(); const { enqueueWarningSnackBar } = useSnackBar(); - // Returns whether customization mode is active afterward, so callers that - // navigate on entry can skip navigation when entry was blocked (e.g. a - // dashboard is mid-edit). const enterLayoutCustomizationMode = useCallback((): boolean => { const isLayoutCustomizationModeAlreadyEnabled = store.get( isLayoutCustomizationModeEnabledState.atom, diff --git a/packages/twenty-front/src/modules/settings/components/SettingsDiscoveryHeroCard.tsx b/packages/twenty-front/src/modules/settings/components/SettingsDiscoveryHeroCard.tsx index c6958ad81e..15196d35b4 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsDiscoveryHeroCard.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsDiscoveryHeroCard.tsx @@ -4,11 +4,13 @@ import { } from '@/settings/components/SettingsCustomizeVideoModal'; import { HeroPlayButton } from '@/ui/layout/hero/components/HeroPlayButton'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { useContext } from 'react'; import { Card } from 'twenty-ui/layout'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; const COVER_HEIGHT = 150; @@ -56,6 +58,9 @@ export const SettingsDiscoveryHeroCard = ({ const { t } = useLingui(); const { colorScheme } = useContext(ThemeContext); const { openModal } = useModal(); + const isDiscoveryVideoEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED, + ); const modalInstanceId = `${instanceIdPrefix}-modal`; const tabsInstanceId = `${instanceIdPrefix}-tabs`; @@ -67,19 +72,23 @@ export const SettingsDiscoveryHeroCard = ({ - - openModal(modalInstanceId)} - ariaLabel={playButtonAriaLabel ?? t`Watch demo`} - /> - + {isDiscoveryVideoEnabled && ( + + openModal(modalInstanceId)} + ariaLabel={playButtonAriaLabel ?? t`Watch demo`} + /> + + )} - + {isDiscoveryVideoEnabled && ( + + )} ); }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsStatsGrid.tsx b/packages/twenty-front/src/modules/settings/components/SettingsStatsGrid.tsx index d00667107a..9332be1d03 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsStatsGrid.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsStatsGrid.tsx @@ -6,15 +6,10 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; export type SettingsStatRow = { Icon: IconComponent; label: string; - // String so callers can render a placeholder (e.g. "—") while async counts - // are still loading. Layout stats just pass `count.toString()`. value: string; }; type SettingsStatsGridProps = { - // Each inner array is one column rendered top-to-bottom; columns are - // separated by a vertical divider. Pass [[a, b], [c, d]] for a 2x2 layout - // or [[a, b, c]] for a single column. columns: SettingsStatRow[][]; }; diff --git a/packages/twenty-front/src/modules/settings/data-model/assets/cover-dark.png b/packages/twenty-front/src/modules/settings/data-model/assets/cover-dark.png index 5da09efbdf..dcaf412186 100644 Binary files a/packages/twenty-front/src/modules/settings/data-model/assets/cover-dark.png and b/packages/twenty-front/src/modules/settings/data-model/assets/cover-dark.png differ diff --git a/packages/twenty-front/src/modules/settings/data-model/assets/cover-light.png b/packages/twenty-front/src/modules/settings/data-model/assets/cover-light.png index 6d8aebd11b..f5bfd36760 100644 Binary files a/packages/twenty-front/src/modules/settings/data-model/assets/cover-light.png and b/packages/twenty-front/src/modules/settings/data-model/assets/cover-light.png differ diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents.tsx index 777fa6671b..32adfb3215 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents.tsx @@ -3,12 +3,8 @@ import { styled } from '@linaria/react'; import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; import React from 'react'; -// Column width used by the Applications data tables (Instances column). export const SETTINGS_OBJECT_TABLE_COLUMN_WIDTH = '98.7px'; -// Relative grid: Name takes all remaining space (with a floor); App / Fields / -// Instances get fixed minimums so short text columns don't collapse; trailing -// 36 px holds the chevron / action cell. export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `minmax(180px, 1fr) 140px 80px 100px 36px`; export const SETTINGS_OBJECT_TABLE_ROW_MOBILE_MIN_WIDTH = '520px'; diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx index 6f6eb284c3..b9b9bb09d6 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx @@ -58,7 +58,6 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => { return; } - // Skip navigation when entry was blocked (e.g. a dashboard is mid-edit). if (!enterLayoutCustomizationMode()) { return; } diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx index fae608161a..e8c4316b02 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx @@ -1,5 +1,6 @@ import { styled } from '@linaria/react'; +import { WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS } from '@/settings/developers/constants/WebhookTableRowGridTemplateColumns'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; import { getUrlHostnameOrThrow, isValidUrl } from 'twenty-shared/utils'; @@ -11,8 +12,6 @@ import { useContext } from 'react'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { type Webhook } from '~/generated-metadata/graphql'; -export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px'; - const StyledIconChevronRightContainer = styled.span` align-items: center; color: ${themeCssVariables.font.color.tertiary}; diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsWebhooksTable.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsWebhooksTable.tsx index f2e4ab8640..7bf816fd85 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsWebhooksTable.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsWebhooksTable.tsx @@ -1,9 +1,7 @@ import { styled } from '@linaria/react'; -import { - SettingsDevelopersWebhookTableRow, - WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS, -} from '@/settings/developers/components/SettingsDevelopersWebhookTableRow'; +import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow'; +import { WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS } from '@/settings/developers/constants/WebhookTableRowGridTemplateColumns'; import { Table } from '@/ui/layout/table/components/Table'; import { TableBody } from '@/ui/layout/table/components/TableBody'; import { TableHeader } from '@/ui/layout/table/components/TableHeader'; diff --git a/packages/twenty-front/src/modules/settings/developers/constants/WebhookTableRowGridTemplateColumns.ts b/packages/twenty-front/src/modules/settings/developers/constants/WebhookTableRowGridTemplateColumns.ts new file mode 100644 index 0000000000..1e4abffdcf --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/constants/WebhookTableRowGridTemplateColumns.ts @@ -0,0 +1 @@ +export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px'; diff --git a/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png b/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png index 3e58c9f5fc..bebc3c2387 100644 Binary files a/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png and b/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png differ diff --git a/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png b/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png index b03a8d23d0..8698450c1e 100644 Binary files a/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png and b/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png differ diff --git a/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx b/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx index 57d55ef38a..2706c0d120 100644 --- a/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx +++ b/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx @@ -19,9 +19,6 @@ const playgroundSetupFormSchema = z.object({ type PlaygroundSetupFormValues = z.infer; -// Last column shrinks to the Launch button's content width so its right -// edge sits at the form's right edge. The two select columns share the -// remaining space equally. const StyledForm = styled.form` align-items: end; display: grid; diff --git a/packages/twenty-front/src/modules/settings/playground/components/__stories__/PlaygroundSetupForm.stories.tsx b/packages/twenty-front/src/modules/settings/playground/components/__stories__/PlaygroundSetupForm.stories.tsx index 48eaaa48fe..aacfbd9d99 100644 --- a/packages/twenty-front/src/modules/settings/playground/components/__stories__/PlaygroundSetupForm.stories.tsx +++ b/packages/twenty-front/src/modules/settings/playground/components/__stories__/PlaygroundSetupForm.stories.tsx @@ -1,11 +1,12 @@ import { PlaygroundSetupForm } from '@/settings/playground/components/PlaygroundSetupForm'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { type Meta, type StoryObj } from '@storybook/react-vite'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing'; const meta: Meta = { title: 'Modules/Settings/Playground/PlaygroundSetupForm', component: PlaygroundSetupForm, - decorators: [ComponentDecorator, RouterDecorator], + decorators: [ComponentDecorator, RouterDecorator, SnackBarDecorator], parameters: { docs: { description: { diff --git a/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts b/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts index 75e9478a37..9483a46b01 100644 --- a/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts +++ b/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts @@ -2,17 +2,13 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomStat import { isDefined } from 'twenty-shared/utils'; import { type AuthToken } from '~/generated-metadata/graphql'; -// In-memory only: a short-lived, full-permission bearer token. Keeping it out of -// localStorage bounds the exfiltration window to the current tab and leaves no -// usable credential at rest after the tab closes. +// In-memory only — never persist this bearer token to localStorage. export const playgroundApiKeyState = createAtomState({ key: 'playgroundApiKeyState', defaultValue: null, }); -// Usable only while it stays valid for at least `bufferMs` longer. Consumers pass -// no buffer (reject the moment it expires); the launcher passes a buffer so it -// re-mints before a near-expired token can fail mid-session. +// Returns true when the token is still valid `bufferMs` from now. export const isPlaygroundApiKeyFresh = ( token: AuthToken | null, bufferMs = 0, diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx index ca4e8bc530..1a68e76e93 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx @@ -20,9 +20,6 @@ type SubMenuTopBarContainerProps = { tag?: JSX.Element; }; -// Cards, forms, and tables inside the white panel are centered in a fixed -// max-width column so they don't sprawl on large displays. The white panel -// itself spans edge-to-edge; only the content is constrained. const SETTINGS_CONTENT_MAX_WIDTH = 760; const StyledContainer = styled.div` @@ -31,10 +28,8 @@ const StyledContainer = styled.div` width: 100%; `; -// flex: 1 + min-height: 0 keep the vertical-scroll chain intact: PagePanel's -// own overflow handling sits one level up and depends on its children -// participating in the flex height calculation rather than collapsing to -// content height. +// flex: 1 + min-height: 0 are required for PagePanel's overflow chain — the +// child must participate in the flex height calc rather than collapse to content. const StyledBodyContentWrapper = styled.div` display: flex; flex: 1; @@ -72,12 +67,6 @@ export const SubMenuTopBarContainer = ({ }> {actionButton} - {/* - MainContainerLayoutWithSidePanel is the same wrapper the App's record - pages use: it renders the page body on the left and SidePanelForDesktop - on the right. Hosting it here lets the AI chat side panel (and any - other side-panel page) open in settings exactly as it does in the App. - */} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection.tsx index 3d89770794..9006063354 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection.tsx @@ -5,24 +5,13 @@ import { type ReactNode } from 'react'; import { AnimatedExpandableContainer } from 'twenty-ui/layout'; type CollapsibleNavigationDrawerSectionProps = { - // Unique id used to persist the open/closed state in localStorage. Pass - // a namespaced value (e.g. 'settings/User') so unrelated sections in - // different drawers don't share state. + // Namespaced id (e.g. 'settings/User') used to persist open/closed state. sectionId: string; label: string; children: ReactNode; - // Optional wrapper around the section title (e.g. AdvancedSettingsWrapper - // for advanced-mode-only sections). Receives the title node and returns - // the wrapped node. wrapTitle?: (titleNode: ReactNode) => ReactNode; }; -// One-stop section component for any drawer that wants the main-app's -// collapsible section behavior: click the title to collapse / expand, -// animated height transition, persisted open state, chevron-on-hover. -// Use this instead of stitching together NavigationDrawerSection + -// NavigationDrawerSectionTitle + AnimatedExpandableContainer by hand at -// every call site. export const CollapsibleNavigationDrawerSection = ({ sectionId, label, diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent.tsx index 4496174a02..1cce1a9375 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent.tsx @@ -5,8 +5,6 @@ import { styled } from '@linaria/react'; import { useIsMobile } from 'twenty-ui/utilities'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -// Mobile keeps the touch-friendly horizontal padding; on desktop the container -// is edge-to-edge and the child supplies its own padding. const StyledFixedContainer = styled.div<{ isMobile?: boolean }>` padding-left: ${({ isMobile }) => isMobile ? themeCssVariables.spacing[5] : '0'}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx index ef6cae878d..114b09d63e 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx @@ -52,9 +52,6 @@ export type NavigationDrawerItemProps = { onClick?: () => void; Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element); iconColor?: string | null; - // Wrap the plain icon in a soft grey tile (no border) — used by the - // settings drawer so its icons read as a uniform group without picking - // up TintedIconTile's bordered colored treatment. withIconBackground?: boolean; active?: boolean; modifier?: NavigationDrawerItemModifier; @@ -206,10 +203,6 @@ const StyledIcon = styled.div` margin-right: ${themeCssVariables.spacing[2]}; `; -// Soft grey background-only tile (no border) used by the settings drawer. -// Sized one step larger than the icon so the icon sits with a couple of -// pixels of breathing room on every side. radius.md matches the rest of -// the App's small-card / tile language; radius.sm read as sharp squares. const StyledIconBackgroundTile = styled.div` align-items: center; background-color: ${themeCssVariables.background.tertiary}; diff --git a/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx b/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx index 395f2e8129..e971ece7b5 100644 --- a/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx +++ b/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx @@ -1,16 +1,11 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction'; import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; 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 { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { getSettingsPath } from 'twenty-shared/utils'; import { t } from '@lingui/core/macro'; import { @@ -30,6 +25,7 @@ import { SettingsAiModelsTab } from '~/pages/settings/ai/components/SettingsAiMo import { SettingsAiOverviewTab } from '~/pages/settings/ai/components/SettingsAiOverviewTab'; import { SettingsAiUsageTab } from '~/pages/settings/ai/components/SettingsAiUsageTab'; import { SETTINGS_AI_TABS } from '~/pages/settings/ai/constants/SettingsAiTabs'; +import { useCreateTool } from '~/pages/settings/ai/hooks/useCreateTool'; const AI_HERO_LIGHT = '/images/ai/ai-tools-cover-light.png'; const AI_HERO_DARK = '/images/ai/ai-tools-cover-dark.png'; @@ -37,55 +33,13 @@ const AI_HERO_DARK = '/images/ai/ai-tools-cover-dark.png'; const SETTINGS_AI_HERO_INSTANCE_ID_PREFIX = 'settings-ai-hero'; export const SettingsAI = () => { - const navigate = useNavigate(); - const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); - const { createLogicFunction } = usePersistLogicFunction(); - const [isCreatingTool, setIsCreatingTool] = useState(false); + const { handleCreateTool, isCreatingTool } = useCreateTool(); const activeTabId = useAtomComponentStateValue( activeTabIdComponentState, SETTINGS_AI_TABS.COMPONENT_INSTANCE_ID, ); - const handleCreateTool = async () => { - setIsCreatingTool(true); - try { - const result = await createLogicFunction({ - input: { - name: 'new-tool', - toolTriggerSettings: { - inputSchema: { type: 'object', properties: {} }, - }, - }, - }); - - if (result.status === 'successful' && isDefined(result.response?.data)) { - const newLogicFunction = result.response.data.createOneLogicFunction; - enqueueSuccessSnackBar({ message: t`Tool created` }); - - const applicationId = newLogicFunction.applicationId; - if (isDefined(applicationId)) { - navigate( - getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, { - applicationId, - logicFunctionId: newLogicFunction.id, - }), - ); - } else { - navigate( - getSettingsPath(SettingsPath.LogicFunctionDetail, { - logicFunctionId: newLogicFunction.id, - }), - ); - } - } else { - enqueueErrorSnackBar({ message: t`Failed to create tool` }); - } - } finally { - setIsCreatingTool(false); - } - }; - const tabs = [ { id: SETTINGS_AI_TABS.TABS_IDS.OVERVIEW, diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkillsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkillsTab.tsx index df8ecbc7b0..11aab8c375 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkillsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkillsTab.tsx @@ -104,7 +104,7 @@ export const SettingsAgentSkillsTab = () => { setShowDeactivated(!showDeactivated)} + onToggleChange={setShowDeactivated} toggled={showDeactivated} text={t`Deactivated`} toggleSize="small" diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx index 01e7e7e086..6182874fd6 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx @@ -1,80 +1,35 @@ -import { gql } from '@apollo/client'; -import { useQuery } from '@apollo/client/react'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; -import { type ReactNode, useMemo, useState } from 'react'; +import { type ReactNode, useState } from 'react'; -import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { ToolCategory } from 'twenty-shared/ai'; import { isDefined } from 'twenty-shared/utils'; import { H2Title, IconLock, IconPuzzle, IconTool } from 'twenty-ui/display'; import { SearchInput } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { MenuItemToggle } from 'twenty-ui/navigation'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { - type SettingsAgentToolItem, - SettingsAgentToolsTable, -} from '~/pages/settings/ai/components/SettingsAgentToolsTable'; +import { SettingsAgentToolsTable } from '~/pages/settings/ai/components/SettingsAgentToolsTable'; +import { useSettingsAgentToolsTable } from '~/pages/settings/ai/hooks/useSettingsAgentToolsTable'; +import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem'; import { normalizeSearchText } from '~/utils/normalizeSearchText'; -const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql` - query FindManyApplicationsForToolTable { - findManyApplications { - id - name - universalIdentifier - logo - } - } -`; - -const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql` - query FindManyMarketplaceAppsForToolTable { - findManyMarketplaceApps { - id - universalIdentifier - icon - logo - } - } -`; - const StyledSearchContainer = styled.div` padding-bottom: ${themeCssVariables.spacing[2]}; `; export const SettingsAgentToolsTab = () => { - const logicFunctions = useAtomStateValue(logicFunctionsSelector); - const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const { - toolIndex, - loading: toolIndexLoading, - error: toolIndexError, - } = useGetToolIndex(); - const { data: applicationsData } = useQuery<{ - findManyApplications: Array<{ - id: string; - name: string; - universalIdentifier: string; - logo?: string | null; - }>; - }>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE); - const { data: marketplaceAppsData } = useQuery<{ - findManyMarketplaceApps: Array<{ - id: string; - universalIdentifier: string; - icon: string; - logo?: string | null; - }>; - }>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE); const { t } = useLingui(); + const { + allTools, + applicationById, + marketplaceAppByUniversalIdentifier, + currentWorkspace, + isLoading, + } = useSettingsAgentToolsTable(); + const [searchTerm, setSearchTerm] = useState(''); const [showCustomTools, setShowCustomTools] = useState(true); const [showManagedTools, setShowManagedTools] = useState(true); @@ -89,42 +44,6 @@ export const SettingsAgentToolsTab = () => { const isCustom = (tool: SettingsAgentToolItem) => isDefined(tool.applicationId); - const allTools: SettingsAgentToolItem[] = useMemo( - () => [ - ...logicFunctions - .filter((fn) => isDefined(fn.toolTriggerSettings)) - .map((fn) => ({ - identifier: fn.id, - name: fn.name, - description: fn.description, - applicationId: fn.applicationId, - })), - ...toolIndex - .filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION) - .map((tool) => ({ - identifier: tool.name, - name: tool.name, - description: tool.description, - category: tool.category, - objectName: tool.objectName, - icon: tool.icon, - })), - ], - [logicFunctions, toolIndex], - ); - - const applicationById = new Map( - (applicationsData?.findManyApplications ?? []).map((application) => [ - application.id, - application, - ]), - ); - const marketplaceAppByUniversalIdentifier = new Map( - (marketplaceAppsData?.findManyMarketplaceApps ?? []).map( - (marketplaceApp) => [marketplaceApp.universalIdentifier, marketplaceApp], - ), - ); - const filteredTools = allTools .filter((tool) => { const searchNormalized = normalizeSearchText(searchTerm); @@ -149,8 +68,6 @@ export const SettingsAgentToolsTab = () => { }) .sort((a, b) => a.name.localeCompare(b.name)); - const isLoading = toolIndexLoading && !toolIndexError; - return (
{ - setShowCustomTools(!showCustomTools) - } + onToggleChange={setShowCustomTools} toggled={showCustomTools} text={t`Custom`} toggleSize="small" /> - setShowManagedTools(!showManagedTools) - } + onToggleChange={setShowManagedTools} toggled={showManagedTools} text={t`Managed`} toggleSize="small" /> - setShowStandardTools(!showStandardTools) - } + onToggleChange={setShowStandardTools} toggled={showStandardTools} text={t`Standard`} toggleSize="small" diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx index 6d1f7b7007..1ccdd81f10 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx @@ -1,53 +1,34 @@ import { useContext } from 'react'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; +import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; import { Table } from '@/ui/layout/table/components/Table'; import { TableHeader } from '@/ui/layout/table/components/TableHeader'; import { TableRow } from '@/ui/layout/table/components/TableRow'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; -import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application'; -import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { isDefined } from 'twenty-shared/utils'; import { IconChevronRight } from 'twenty-ui/display'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; - -import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; import { SettingsToolIcon } from '~/pages/settings/ai/components/SettingsToolIcon'; import { SettingsToolTableRow, TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS, } from '~/pages/settings/ai/components/SettingsToolTableRow'; - -export type SettingsAgentToolItem = { - identifier: string; - name: string; - description?: string | null; - category?: string; - objectName?: string | null; - icon?: string | null; - applicationId?: string | null; -}; - -type Application = { - id: string; - name: string; - universalIdentifier: string; - logo?: string | null; -}; - -type MarketplaceApp = { - id: string; - universalIdentifier: string; - icon: string; - logo?: string | null; -}; +import { type SettingsAgentToolApplication } from '~/pages/settings/ai/types/SettingsAgentToolApplication'; +import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem'; +import { type SettingsAgentToolMarketplaceApp } from '~/pages/settings/ai/types/SettingsAgentToolMarketplaceApp'; +import { getToolApplicationId } from '~/pages/settings/ai/utils/getToolApplicationId'; +import { getToolLink } from '~/pages/settings/ai/utils/getToolLink'; type SettingsAgentToolsTableProps = { tools: SettingsAgentToolItem[]; isLoading: boolean; - applicationById: Map; - marketplaceAppByUniversalIdentifier: Map; + applicationById: Map; + marketplaceAppByUniversalIdentifier: Map< + string, + SettingsAgentToolMarketplaceApp + >; currentWorkspace: CurrentWorkspace | null; }; @@ -55,28 +36,6 @@ const StyledTableHeaderRowContainer = styled.div` margin-bottom: ${themeCssVariables.spacing[2]}; `; -const getToolApplicationId = ( - tool: SettingsAgentToolItem, - currentWorkspace: CurrentWorkspace | null, -): string => { - if (isDefined(tool.applicationId)) { - return tool.applicationId; - } - - return ( - currentWorkspace?.installedApplications?.find( - (app) => - app.universalIdentifier === - TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, - )?.id ?? '' - ); -}; - -const getToolLink = (tool: SettingsAgentToolItem): string => - getSettingsPath(SettingsPath.AiToolDetail, { - toolIdentifier: tool.identifier, - }); - export const SettingsAgentToolsTable = ({ tools, isLoading, diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx index a0c2f4a937..5a11851fd2 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx @@ -10,17 +10,17 @@ import { getModelIcon } from '@/settings/ai/utils/getModelIcon'; import { SettingsCard } from '@/settings/components/SettingsCard'; import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Select } from '@/ui/input/components/Select'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; -import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useMutation, useQuery } from '@apollo/client/react'; +import { useQuery } from '@apollo/client/react'; import { t } from '@lingui/core/macro'; import { AUTO_SELECT_FAST_MODEL_ID, AUTO_SELECT_SMART_MODEL_ID, } from 'twenty-shared/constants'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { H2Title, IconBolt, @@ -32,12 +32,8 @@ import { SearchInput } from 'twenty-ui/input'; import { Card, Section } from 'twenty-ui/layout'; import { UndecoratedLink } from 'twenty-ui/navigation'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; -import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined } from 'twenty-shared/utils'; -import { - GetAiSystemPromptPreviewDocument, - UpdateWorkspaceDocument, -} from '~/generated-metadata/graphql'; +import { GetAiSystemPromptPreviewDocument } from '~/generated-metadata/graphql'; +import { useSettingsAiModelsActions } from '~/pages/settings/ai/hooks/useSettingsAiModelsActions'; import { formatNumber } from '~/utils/format/formatNumber'; const StyledCustomModelsContainer = styled.div` @@ -49,14 +45,19 @@ const StyledCustomModelsContainer = styled.div` export const SettingsAiModelsTab = () => { const { theme } = useContext(ThemeContext); - const { enqueueErrorSnackBar } = useSnackBar(); - const [currentWorkspace, setCurrentWorkspace] = useAtomState( - currentWorkspaceState, - ); - const [updateWorkspace] = useMutation(UpdateWorkspaceDocument); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); const [searchQuery, setSearchQuery] = useState(''); + const { data: previewData } = useQuery(GetAiSystemPromptPreviewDocument); const aiModels = useAtomStateValue(aiModelsState); + const { useRecommendedModels, realModels, enabledModels } = + useWorkspaceAiModelAvailability(); + const { + handleModelFieldChange, + handleUseRecommendedToggle, + handleModelToggle, + handleToggleAllVisibleModels, + } = useSettingsAiModelsActions(); const systemPromptTokenCount = previewData?.getAiSystemPromptPreview.estimatedTokenCount; @@ -67,9 +68,6 @@ export const SettingsAiModelsTab = () => { )} tokens)` : t`Read the system prompts to understand how the AI works`; - const { useRecommendedModels, realModels, enabledModels } = - useWorkspaceAiModelAvailability(); - const currentSmartModel = currentWorkspace?.smartModel; const currentFastModel = currentWorkspace?.fastModel; @@ -103,110 +101,11 @@ export const SettingsAiModelsTab = () => { }; }); - const handleModelFieldChange = async ( - field: 'smartModel' | 'fastModel', - value: string, - ) => { - if (!currentWorkspace?.id) return; - const previousValue = currentWorkspace[field]; - try { - setCurrentWorkspace({ ...currentWorkspace, [field]: value }); - await updateWorkspace({ variables: { input: { [field]: value } } }); - } catch { - setCurrentWorkspace({ ...currentWorkspace, [field]: previousValue }); - enqueueErrorSnackBar({ message: t`Failed to update model` }); - } - }; - const enabledModelIdSet = new Set(currentWorkspace?.enabledAiModelIds ?? []); - const handleUseRecommendedToggle = async (checked: boolean) => { - if (!currentWorkspace?.id) { - return; - } - - const previousValue = currentWorkspace.useRecommendedModels; - - let newEnabledIds = currentWorkspace.enabledAiModelIds ?? []; - - if (!checked && previousValue) { - const recommendedModelIds = realModels - .filter((model) => model.isRecommended) - .map((model) => model.modelId); - - newEnabledIds = recommendedModelIds; - } - - try { - setCurrentWorkspace({ - ...currentWorkspace, - useRecommendedModels: checked, - enabledAiModelIds: newEnabledIds, - }); - - await updateWorkspace({ - variables: { - input: { - useRecommendedModels: checked, - enabledAiModelIds: newEnabledIds, - }, - }, - }); - } catch { - setCurrentWorkspace({ - ...currentWorkspace, - useRecommendedModels: previousValue, - }); - - enqueueErrorSnackBar({ - message: t`Failed to update model selection mode`, - }); - } - }; - - const handleModelToggle = async ( - modelId: string, - isCurrentlyEnabled: boolean, - ) => { - if (!currentWorkspace?.id) { - return; - } - - const previousEnabled = currentWorkspace.enabledAiModelIds ?? []; - - const newEnabledIds = isCurrentlyEnabled - ? previousEnabled.filter((id) => id !== modelId) - : [...previousEnabled, modelId]; - - try { - setCurrentWorkspace({ - ...currentWorkspace, - enabledAiModelIds: newEnabledIds, - }); - - await updateWorkspace({ - variables: { - input: { - enabledAiModelIds: newEnabledIds, - }, - }, - }); - } catch { - setCurrentWorkspace({ - ...currentWorkspace, - enabledAiModelIds: previousEnabled, - }); - - enqueueErrorSnackBar({ - message: t`Failed to update model availability`, - }); - } - }; - const filteredModels = searchQuery.trim() ? realModels.filter((model) => { const query = searchQuery.toLowerCase(); - return ( model.label.toLowerCase().includes(query) || (model.modelFamily?.toLowerCase().includes(query) ?? false) || @@ -284,34 +183,12 @@ export const SettingsAiModelsTab = () => { models={filteredModels} isChecked={(model) => enabledModelIdSet.has(model.modelId)} onToggle={handleModelToggle} - onToggleAll={async (shouldCheckAll) => { - const previousIds = currentWorkspace?.enabledAiModelIds ?? []; - const visibleModelIds = new Set( - filteredModels.map((m) => m.modelId), - ); - - const newEnabledIds = shouldCheckAll - ? [...new Set([...previousIds, ...visibleModelIds])] - : previousIds.filter((id) => !visibleModelIds.has(id)); - - try { - setCurrentWorkspace({ - ...currentWorkspace!, - enabledAiModelIds: newEnabledIds, - }); - await updateWorkspace({ - variables: { input: { enabledAiModelIds: newEnabledIds } }, - }); - } catch { - setCurrentWorkspace({ - ...currentWorkspace!, - enabledAiModelIds: previousIds, - }); - enqueueErrorSnackBar({ - message: t`Failed to update model availability`, - }); - } - }} + onToggleAll={(shouldCheckAll) => + handleToggleAllVisibleModels( + shouldCheckAll, + new Set(filteredModels.map((m) => m.modelId)), + ) + } anchorPrefix="workspace-model-row" /> diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsToolIcon.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsToolIcon.tsx index 37d898b78b..251d2a029f 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsToolIcon.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsToolIcon.tsx @@ -22,7 +22,6 @@ type ApplicationInfo = { }; type MarketplaceAppInfo = { - icon: string; logo?: string | null; }; @@ -109,11 +108,6 @@ export const SettingsToolIcon = ({ ); } - if (isDefined(marketplaceApp)) { - const MarketplaceIcon = getIcon(marketplaceApp.icon); - return ; - } - if (isDefined(application)) { return ( { + const navigate = useNavigate(); + const { enqueueSuccessSnackBar } = useSnackBar(); + const { createLogicFunction } = usePersistLogicFunction(); + const [isCreatingTool, setIsCreatingTool] = useState(false); + + const handleCreateTool = async () => { + setIsCreatingTool(true); + try { + const result = await createLogicFunction({ + input: { + name: 'new-tool', + toolTriggerSettings: { + inputSchema: { type: 'object', properties: {} }, + }, + }, + }); + + // Failure path already surfaces its own snackbar from usePersistLogicFunction. + if (result.status !== 'successful' || !isDefined(result.response?.data)) { + return; + } + + const newLogicFunction = result.response.data.createOneLogicFunction; + enqueueSuccessSnackBar({ message: t`Tool created` }); + + const applicationId = newLogicFunction.applicationId; + if (isDefined(applicationId)) { + navigate( + getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, { + applicationId, + logicFunctionId: newLogicFunction.id, + }), + ); + } else { + navigate( + getSettingsPath(SettingsPath.LogicFunctionDetail, { + logicFunctionId: newLogicFunction.id, + }), + ); + } + } finally { + setIsCreatingTool(false); + } + }; + + return { handleCreateTool, isCreatingTool }; +}; diff --git a/packages/twenty-front/src/pages/settings/ai/hooks/useSettingsAgentToolsTable.ts b/packages/twenty-front/src/pages/settings/ai/hooks/useSettingsAgentToolsTable.ts new file mode 100644 index 0000000000..cb9bf3b04b --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/hooks/useSettingsAgentToolsTable.ts @@ -0,0 +1,81 @@ +import { useQuery } from '@apollo/client/react'; +import { useMemo } from 'react'; + +import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { ToolCategory } from 'twenty-shared/ai'; +import { isDefined } from 'twenty-shared/utils'; +import { FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE } from '~/pages/settings/ai/graphql/queries/findManyApplicationsForToolTable'; +import { FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE } from '~/pages/settings/ai/graphql/queries/findManyMarketplaceAppsForToolTable'; +import { type SettingsAgentToolApplication } from '~/pages/settings/ai/types/SettingsAgentToolApplication'; +import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem'; +import { type SettingsAgentToolMarketplaceApp } from '~/pages/settings/ai/types/SettingsAgentToolMarketplaceApp'; + +export const useSettingsAgentToolsTable = () => { + const logicFunctions = useAtomStateValue(logicFunctionsSelector); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + const { + toolIndex, + loading: toolIndexLoading, + error: toolIndexError, + } = useGetToolIndex(); + + const { data: applicationsData } = useQuery<{ + findManyApplications: SettingsAgentToolApplication[]; + }>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE); + + const { data: marketplaceAppsData } = useQuery<{ + findManyMarketplaceApps: SettingsAgentToolMarketplaceApp[]; + }>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE); + + const allTools: SettingsAgentToolItem[] = useMemo( + () => [ + ...logicFunctions + .filter((fn) => isDefined(fn.toolTriggerSettings)) + .map((fn) => ({ + identifier: fn.id, + name: fn.name, + description: fn.description, + applicationId: fn.applicationId, + })), + ...toolIndex + .filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION) + .map((tool) => ({ + identifier: tool.name, + name: tool.name, + description: tool.description, + category: tool.category, + objectName: tool.objectName, + icon: tool.icon, + })), + ], + [logicFunctions, toolIndex], + ); + + const applicationById = new Map( + (applicationsData?.findManyApplications ?? []).map((application) => [ + application.id, + application, + ]), + ); + + // MarketplaceApp.id IS the universal identifier — see + // marketplace-query.service.ts where `id: registration.universalIdentifier`. + const marketplaceAppByUniversalIdentifier = new Map( + (marketplaceAppsData?.findManyMarketplaceApps ?? []).map( + (marketplaceApp) => [marketplaceApp.id, marketplaceApp], + ), + ); + + const isLoading = toolIndexLoading && !toolIndexError; + + return { + allTools, + applicationById, + marketplaceAppByUniversalIdentifier, + currentWorkspace, + isLoading, + }; +}; diff --git a/packages/twenty-front/src/pages/settings/ai/hooks/useSettingsAiModelsActions.ts b/packages/twenty-front/src/pages/settings/ai/hooks/useSettingsAiModelsActions.ts new file mode 100644 index 0000000000..5ebedd5333 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/hooks/useSettingsAiModelsActions.ts @@ -0,0 +1,137 @@ +import { useMutation } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; + +import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql'; + +export const useSettingsAiModelsActions = () => { + const { enqueueErrorSnackBar } = useSnackBar(); + const [currentWorkspace, setCurrentWorkspace] = useAtomState( + currentWorkspaceState, + ); + const [updateWorkspace] = useMutation(UpdateWorkspaceDocument); + const { realModels } = useWorkspaceAiModelAvailability(); + + const handleModelFieldChange = async ( + field: 'smartModel' | 'fastModel', + value: string, + ) => { + if (!currentWorkspace?.id) return; + + const previousValue = currentWorkspace[field]; + try { + setCurrentWorkspace({ ...currentWorkspace, [field]: value }); + await updateWorkspace({ variables: { input: { [field]: value } } }); + } catch { + setCurrentWorkspace({ ...currentWorkspace, [field]: previousValue }); + enqueueErrorSnackBar({ message: t`Failed to update model` }); + } + }; + + const handleUseRecommendedToggle = async (checked: boolean) => { + if (!currentWorkspace?.id) return; + + const previousValue = currentWorkspace.useRecommendedModels; + let newEnabledIds = currentWorkspace.enabledAiModelIds ?? []; + + if (!checked && previousValue) { + newEnabledIds = realModels + .filter((model) => model.isRecommended) + .map((model) => model.modelId); + } + + try { + setCurrentWorkspace({ + ...currentWorkspace, + useRecommendedModels: checked, + enabledAiModelIds: newEnabledIds, + }); + await updateWorkspace({ + variables: { + input: { + useRecommendedModels: checked, + enabledAiModelIds: newEnabledIds, + }, + }, + }); + } catch { + setCurrentWorkspace({ + ...currentWorkspace, + useRecommendedModels: previousValue, + }); + enqueueErrorSnackBar({ + message: t`Failed to update model selection mode`, + }); + } + }; + + const handleModelToggle = async ( + modelId: string, + isCurrentlyEnabled: boolean, + ) => { + if (!currentWorkspace?.id) return; + + const previousEnabled = currentWorkspace.enabledAiModelIds ?? []; + const newEnabledIds = isCurrentlyEnabled + ? previousEnabled.filter((id) => id !== modelId) + : [...previousEnabled, modelId]; + + try { + setCurrentWorkspace({ + ...currentWorkspace, + enabledAiModelIds: newEnabledIds, + }); + await updateWorkspace({ + variables: { input: { enabledAiModelIds: newEnabledIds } }, + }); + } catch { + setCurrentWorkspace({ + ...currentWorkspace, + enabledAiModelIds: previousEnabled, + }); + enqueueErrorSnackBar({ + message: t`Failed to update model availability`, + }); + } + }; + + const handleToggleAllVisibleModels = async ( + shouldCheckAll: boolean, + visibleModelIds: Set, + ) => { + if (!currentWorkspace?.id) return; + + const previousIds = currentWorkspace.enabledAiModelIds ?? []; + const newEnabledIds = shouldCheckAll + ? [...new Set([...previousIds, ...visibleModelIds])] + : previousIds.filter((id) => !visibleModelIds.has(id)); + + try { + setCurrentWorkspace({ + ...currentWorkspace, + enabledAiModelIds: newEnabledIds, + }); + await updateWorkspace({ + variables: { input: { enabledAiModelIds: newEnabledIds } }, + }); + } catch { + setCurrentWorkspace({ + ...currentWorkspace, + enabledAiModelIds: previousIds, + }); + enqueueErrorSnackBar({ + message: t`Failed to update model availability`, + }); + } + }; + + return { + handleModelFieldChange, + handleUseRecommendedToggle, + handleModelToggle, + handleToggleAllVisibleModels, + }; +}; diff --git a/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolApplication.ts b/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolApplication.ts new file mode 100644 index 0000000000..48f8b55147 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolApplication.ts @@ -0,0 +1,6 @@ +export type SettingsAgentToolApplication = { + id: string; + name: string; + universalIdentifier: string; + logo?: string | null; +}; diff --git a/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolItem.ts b/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolItem.ts new file mode 100644 index 0000000000..e77b3a3191 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolItem.ts @@ -0,0 +1,9 @@ +export type SettingsAgentToolItem = { + identifier: string; + name: string; + description?: string | null; + category?: string; + objectName?: string | null; + icon?: string | null; + applicationId?: string | null; +}; diff --git a/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolMarketplaceApp.ts b/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolMarketplaceApp.ts new file mode 100644 index 0000000000..d17930accc --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolMarketplaceApp.ts @@ -0,0 +1,4 @@ +export type SettingsAgentToolMarketplaceApp = { + id: string; + logo?: string | null; +}; diff --git a/packages/twenty-front/src/pages/settings/ai/utils/getToolApplicationId.ts b/packages/twenty-front/src/pages/settings/ai/utils/getToolApplicationId.ts new file mode 100644 index 0000000000..a65ce590bb --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/utils/getToolApplicationId.ts @@ -0,0 +1,21 @@ +import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; +import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem'; +import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; + +export const getToolApplicationId = ( + tool: SettingsAgentToolItem, + currentWorkspace: CurrentWorkspace | null, +): string => { + if (isDefined(tool.applicationId)) { + return tool.applicationId; + } + + return ( + currentWorkspace?.installedApplications?.find( + (app) => + app.universalIdentifier === + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + )?.id ?? '' + ); +}; diff --git a/packages/twenty-front/src/pages/settings/ai/utils/getToolLink.ts b/packages/twenty-front/src/pages/settings/ai/utils/getToolLink.ts new file mode 100644 index 0000000000..5cc904d2a5 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/utils/getToolLink.ts @@ -0,0 +1,8 @@ +import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; + +export const getToolLink = (tool: SettingsAgentToolItem): string => + getSettingsPath(SettingsPath.AiToolDetail, { + toolIdentifier: tool.identifier, + }); diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplications.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplications.tsx index 4f71a894d4..d788ae11c2 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsApplications.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplications.tsx @@ -11,9 +11,8 @@ import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { IconApps, IconCode, IconDownload, IconPlug } from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; -// TODO: replace with apps-specific illustrations + recordings when designed. -import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png'; -import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png'; +import coverDark from '~/pages/settings/applications/assets/cover-dark.png'; +import coverLight from '~/pages/settings/applications/assets/cover-light.png'; import { FeatureFlagKey, PermissionFlagType, @@ -82,8 +81,8 @@ export const SettingsApplications = () => {
{ const { objectMetadataItems } = useFilteredObjectMetadataItems(); const isDDLLocked = useAtomStateValue(isDDLLockedState); - const heroTabs = useMemo( - () => [ - { - id: 'objects', - title: t`Objects`, - Icon: IconHierarchy2, - vimeoId: '926288174', - }, - { - id: 'fields', - title: t`Fields`, - Icon: IconList, - vimeoId: '927628219', - }, - { - id: 'relations', - title: t`Relations`, - Icon: IconLink, - vimeoId: '1185511827', - }, - ], - [t], - ); + const heroTabs = [ + { + id: 'objects', + title: t`Objects`, + Icon: IconHierarchy2, + vimeoId: '926288174', + }, + { + id: 'fields', + title: t`Fields`, + Icon: IconList, + vimeoId: '927628219', + }, + { + id: 'relations', + title: t`Relations`, + Icon: IconLink, + vimeoId: '1185511827', + }, + ]; return ( { const navigate = useNavigate(); const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode(); - // Match the in-app customization entry points: arm customization mode before - // landing in the app, otherwise the user just drops into the app normally. - // Skip navigation when entry was blocked (e.g. a dashboard is mid-edit) so we - // don't yank the user away after the warning. const handleCustomize = () => { if (enterLayoutCustomizationMode()) { navigate(AppPath.Index); } }; - // TODO: replace placeholder demo videos per tab - const heroTabs = useMemo( - () => [ - { - id: 'sidebar', - title: t`Sidebar`, - Icon: IconLayoutSidebarLeftExpand, - vimeoId: '1185511790', - }, - { - id: 'record-page', - title: t`Record page`, - Icon: IconAppWindow, - vimeoId: '1185511790', - }, - { - id: 'command-menu', - title: t`Command menu`, - Icon: IconCommand, - vimeoId: '1185416775', - }, - { - id: 'views', - title: t`Views`, - Icon: IconTable, - vimeoId: '1145648745', - }, - { - id: 'dashboards', - title: t`Dashboards`, - Icon: IconLayoutDashboard, - vimeoId: '1185511768', - }, - ], - [t], - ); + const heroTabs = [ + { + id: 'sidebar', + title: t`Sidebar`, + Icon: IconLayoutSidebarLeftExpand, + vimeoId: '1185511790', + }, + { + id: 'record-page', + title: t`Record page`, + Icon: IconAppWindow, + vimeoId: '1185511790', + }, + { + id: 'command-menu', + title: t`Command menu`, + Icon: IconCommand, + vimeoId: '1185416775', + }, + { + id: 'views', + title: t`Views`, + Icon: IconTable, + vimeoId: '1145648745', + }, + { + id: 'dashboards', + title: t`Dashboards`, + Icon: IconLayoutDashboard, + vimeoId: '1185511768', + }, + ]; return ( {
{
ApiKeyEntity) async createApiKey( diff --git a/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts b/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts index 0473835450..07ce27b6bf 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts @@ -55,8 +55,8 @@ export class ApiKeyController { return this.apiKeyService.findById(id, workspace.id); } - // Minting/altering a long-lived API key requires a first-person session - // (ACCESS); a derived PLAYGROUND token or an API key cannot escalate here. + // Minting an API key requires an ACCESS token — derived PLAYGROUND tokens + // and API keys must not escalate into a long-lived credential. @UseGuards(RequireAccessTokenGuard) @Post() async create( diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index c39d57a6ba..3c99e87555 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -944,11 +944,7 @@ describe('JwtAuthStrategy', () => { }); describe('PLAYGROUND token validation', () => { - // A PLAYGROUND token is access-shaped but must never impersonate. This is the - // same payload that, as an ACCESS token with isImpersonating set but without - // impersonation ids, throws 'Invalid or missing user workspace ID in - // impersonation token' above; as a PLAYGROUND token it must skip the - // impersonation path entirely and resolve to the first-person context. + // PLAYGROUND tokens are access-shaped but must never impersonate. it('ignores isImpersonating and resolves first-person', async () => { const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type.ts index ca03331ea2..f1f16a06af 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type.ts @@ -7,9 +7,8 @@ export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & { connectionProviderId: string; userId: string; userWorkspaceId: string; - // 'user' = the resulting credential is private to userWorkspaceId. - // 'workspace' = visible to anyone in the workspace. - // Named `visibility` to disambiguate from OAuth `scopes` on the row. + // 'user' scopes the credential to userWorkspaceId; 'workspace' makes it + // visible to anyone in the workspace. visibility: 'user' | 'workspace'; // If set, the callback updates this existing connectedAccount row instead // of creating a new one (used by the UI's "Reconnect" action). diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts index 15855ae52d..72a1f4380f 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts @@ -20,6 +20,14 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [ 'Enable many-to-many relations through junction tables configuration', }, }, + { + key: FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED, + metadata: { + label: 'Settings Discovery Hero', + description: + 'Show the per-page hero illustration + video walkthrough modal on settings pages', + }, + }, ...(process.env.CLOUDFLARE_API_KEY ? [ // { diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 848dfc3ce8..1e5e293499 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -237,6 +237,7 @@ describe('WorkspaceEntityManager', () => { IS_EMAIL_GROUP_ENABLED: false, IS_JUNCTION_RELATIONS_ENABLED: false, IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false, + IS_SETTINGS_DISCOVERY_HERO_ENABLED: false, }, userWorkspaceRoleMap: {}, eventEmitterService: { diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index eed5ea196c..aba4d5ec6c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -45,6 +45,11 @@ export const seedFeatureFlags = async ({ workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED, + workspaceId: workspaceId, + value: false, + }, ]) .execute(); }; diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts index e2e3cd854f..690c4a6a0c 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts @@ -33,9 +33,8 @@ describe('api key and webhooks permissions', () => { }); }); - // A non-session token (here API_KEY; PLAYGROUND shares the same access path) - // must not mint a durable API key, otherwise a short-lived token could - // escalate into a long-lived one. RequireAccessTokenGuard enforces this. + // Non-ACCESS tokens (API_KEY here, PLAYGROUND same path) must never mint + // an API key — enforced by RequireAccessTokenGuard. it('should reject a non-ACCESS token even with API key permission', async () => { const queryData = { query: ` diff --git a/packages/twenty-shared/src/types/FeatureFlagKey.ts b/packages/twenty-shared/src/types/FeatureFlagKey.ts index b73e6182cc..6ff204ea02 100644 --- a/packages/twenty-shared/src/types/FeatureFlagKey.ts +++ b/packages/twenty-shared/src/types/FeatureFlagKey.ts @@ -6,4 +6,5 @@ export enum FeatureFlagKey { IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', + IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', }