From 8f362186cef8731aa31ade797083e161c4f4b63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 28 Apr 2026 16:08:15 +0200 Subject: [PATCH] Redesign application content tab + logic function settings; add Layout detail pages (#20056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Iterative redesign of two related areas in settings, plus a new `pages/settings/layout/` folder for read-only entity detail pages. ### Application content tab - **Grouped into three sections** — Data / Layout / Logic — each with one H2 + multiple `TableSection`-wrapped sub-tables (mirrors the role-permissions pattern). Replaces six per-category table/row components with one uniform `` + `ApplicationContentRow` shape (net **−~700 lines** across the refactor). - **All 10 row categories now clickable** for installed apps: - Objects / Fields / Logic functions / Front components → existing detail pages - Agents → existing `AiAgentDetail` - Skills → existing `AiSkillDetail` (looked up by `Skill.applicationId + name`) - Roles → existing `RoleDetail` (looked up by `Role.universalIdentifier`) - Views / Page layouts / Navigation menu items → **new** detail pages (see below) - **Lifecycle hooks visible** — `pre-install` / `post-install` logic functions are surfaced in the Trigger column instead of appearing as empty/misconfigured. ### Logic function settings (Triggers + Test tabs) - Triggers tab is now editable (HTTP / Cron / Database event / AI tool) with a `` wrapper that owns the toggle, header, and read-only short-circuit. - HTTP section gets a Live URL field with copy-to-clipboard. - Each section shows a **Sample input** preview (the JSON the function will receive) using the same payload builders the Test tab uses. - Test tab: **Simulate trigger** buttons that prefill the JSON input from the configured trigger's schema. Replaces an unclickable ` updateField('httpMethod', newMethod)} + dropdownOffset={{ y: 4 }} + dropdownWidth={GenericDropdownContentWidth.ExtraLarge} + /> + updateField('path', newPath)} + readOnly={readonly} + fullWidth + /> + {}} + readOnly + fullWidth + RightIcon={IconCopy} + onRightIconClick={() => + copyToClipboard(fullUrl, t`URL copied to clipboard`) + } + /> + + updateField('isAuthRequired', checked)} + disabled={readonly} + toggleSize="small" + color={theme.color.blue} + /> + {t`Require authentication`} + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionToolTriggerSection.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionToolTriggerSection.tsx new file mode 100644 index 0000000000..9be33dcec9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionToolTriggerSection.tsx @@ -0,0 +1,48 @@ +import { SettingsLogicFunctionTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection'; +import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { SettingsToolParameterTable } from '~/pages/settings/ai/components/SettingsToolParameterTable'; + +type ToolInputSchema = { + properties?: Record; + required?: string[]; +}; + +type SettingsLogicFunctionToolTriggerSectionProps = { + isTool: boolean; + toolInputSchema?: object; + onChange: (value: boolean) => void; + readonly: boolean; +}; + +export const SettingsLogicFunctionToolTriggerSection = ({ + isTool, + toolInputSchema, + onChange, + readonly, +}: SettingsLogicFunctionToolTriggerSectionProps) => { + const { t } = useLingui(); + + const schema = (toolInputSchema as ToolInputSchema | undefined) ?? {}; + const schemaProperties = isDefined(schema.properties) + ? (schema.properties as Record< + string, + { type?: string; description?: string; format?: string } + >) + : {}; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerPayloadFormat.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerPayloadFormat.tsx new file mode 100644 index 0000000000..e07b863aa8 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerPayloadFormat.tsx @@ -0,0 +1,45 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { CodeEditor } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[2]}; + margin-top: ${themeCssVariables.spacing[4]}; +`; + +const StyledLabel = styled.span` + color: ${themeCssVariables.font.color.light}; + font-size: ${themeCssVariables.font.size.xs}; + font-weight: ${themeCssVariables.font.weight.semiBold}; +`; + +const StyledHint = styled.span` + color: ${themeCssVariables.font.color.tertiary}; + font-size: ${themeCssVariables.font.size.sm}; +`; + +export const SettingsLogicFunctionTriggerPayloadFormat = ({ + payload, + hint, +}: { + payload: object; + hint?: string; +}) => { + const { t } = useLingui(); + + return ( + + {t`Sample input`} + + {hint !== undefined && {hint}} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection.tsx new file mode 100644 index 0000000000..98ab349a2f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection.tsx @@ -0,0 +1,58 @@ +import { styled } from '@linaria/react'; +import { useContext, type ReactNode } from 'react'; +import { H2Title } from 'twenty-ui/display'; +import { Toggle } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledHeader = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[3]}; + justify-content: space-between; + margin-bottom: ${themeCssVariables.spacing[4]}; +`; + +type SettingsLogicFunctionTriggerSectionProps = { + title: string; + description: string; + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + readonly: boolean; + children?: ReactNode; +}; + +// Common scaffolding for the four trigger toggles on a logic function. Hides +// the section entirely when an installed app function doesn't use this trigger +// (read-only + disabled = nothing to show). +export const SettingsLogicFunctionTriggerSection = ({ + title, + description, + enabled, + onEnabledChange, + readonly, + children, +}: SettingsLogicFunctionTriggerSectionProps) => { + const { theme } = useContext(ThemeContext); + + if (readonly && !enabled) { + return null; + } + + return ( +
+ + + {!readonly && ( + + )} + + {enabled && children} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/logic-functions/utils/__tests__/getTriggerSamplePayload.test.ts b/packages/twenty-front/src/modules/settings/logic-functions/utils/__tests__/getTriggerSamplePayload.test.ts new file mode 100644 index 0000000000..155ff9cb0f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/logic-functions/utils/__tests__/getTriggerSamplePayload.test.ts @@ -0,0 +1,97 @@ +import { + buildDatabaseEventPayload, + buildHttpPayload, + buildToolPayloadFromSchema, +} from '@/settings/logic-functions/utils/getTriggerSamplePayload'; +import { HTTPMethod } from 'twenty-shared/types'; + +describe('buildToolPayloadFromSchema', () => { + it('returns an empty object when no schema is provided', () => { + expect(buildToolPayloadFromSchema()).toEqual({}); + expect(buildToolPayloadFromSchema({})).toEqual({}); + }); + + it('uses defaults when present', () => { + expect( + buildToolPayloadFromSchema({ + properties: { + first: { type: 'string', default: 'hello' }, + second: { type: 'number', default: 42 }, + }, + }), + ).toEqual({ first: 'hello', second: 42 }); + }); + + it('falls back to type-specific sample values', () => { + expect( + buildToolPayloadFromSchema({ + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + c: { type: 'integer' }, + d: { type: 'boolean' }, + e: { type: 'array' }, + f: { type: 'object' }, + g: {}, + }, + }), + ).toEqual({ a: '', b: 0, c: 0, d: false, e: [], f: {}, g: null }); + }); +}); + +describe('buildHttpPayload', () => { + it('omits the body for GET requests', () => { + expect( + buildHttpPayload({ + path: '/hello', + httpMethod: HTTPMethod.GET, + isAuthRequired: false, + }), + ).toMatchObject({ + body: null, + requestContext: { http: { method: HTTPMethod.GET, path: '/s/hello' } }, + }); + }); + + it('includes an empty body for non-GET requests', () => { + expect( + buildHttpPayload({ + path: '/hello', + httpMethod: HTTPMethod.POST, + isAuthRequired: false, + }), + ).toMatchObject({ + body: {}, + requestContext: { http: { method: HTTPMethod.POST, path: '/s/hello' } }, + }); + }); +}); + +describe('buildDatabaseEventPayload', () => { + it('returns null before for created events', () => { + expect( + buildDatabaseEventPayload({ eventName: 'person.created' }), + ).toMatchObject({ + name: 'person.created', + objectMetadata: { nameSingular: 'person' }, + properties: { after: {}, before: null, updatedFields: [] }, + }); + }); + + it('returns an empty before for non-created events', () => { + expect( + buildDatabaseEventPayload({ eventName: 'person.updated' }), + ).toMatchObject({ + properties: { after: {}, before: {}, updatedFields: [] }, + }); + }); + + it('threads updatedFields through', () => { + expect( + buildDatabaseEventPayload({ + eventName: 'person.updated', + updatedFields: ['name'], + }), + ).toMatchObject({ properties: { updatedFields: ['name'] } }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/logic-functions/utils/getTriggerSamplePayload.ts b/packages/twenty-front/src/modules/settings/logic-functions/utils/getTriggerSamplePayload.ts new file mode 100644 index 0000000000..8eca998b26 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/logic-functions/utils/getTriggerSamplePayload.ts @@ -0,0 +1,77 @@ +import { isDefined } from 'twenty-shared/utils'; +import { HTTPMethod } from 'twenty-shared/types'; +import { + type DatabaseEventTriggerSettings, + type HttpRouteTriggerSettings, +} from 'twenty-shared/application'; + +export type TriggerKind = 'http' | 'cron' | 'databaseEvent' | 'tool'; + +type ToolInputSchemaShape = { + properties?: Record; +}; + +const sampleValueForType = (type?: string): unknown => { + switch (type) { + case 'string': + return ''; + case 'number': + case 'integer': + return 0; + case 'boolean': + return false; + case 'array': + return []; + case 'object': + return {}; + default: + return null; + } +}; + +export const buildToolPayloadFromSchema = (schema?: object): object => { + const properties = (schema as ToolInputSchemaShape | undefined)?.properties; + if (!isDefined(properties)) { + return {}; + } + const payload: Record = {}; + for (const [key, prop] of Object.entries(properties)) { + payload[key] = isDefined(prop.default) + ? prop.default + : sampleValueForType(prop.type); + } + return payload; +}; + +export const buildHttpPayload = ( + settings: HttpRouteTriggerSettings, +): object => { + return { + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + body: settings.httpMethod === HTTPMethod.GET ? null : {}, + isBase64Encoded: false, + requestContext: { + http: { + method: settings.httpMethod, + path: `/s${settings.path}`, + }, + }, + }; +}; + +export const buildDatabaseEventPayload = ( + settings: DatabaseEventTriggerSettings, +): object => { + const [object, action] = settings.eventName.split('.'); + return { + name: settings.eventName, + objectMetadata: { nameSingular: object }, + properties: { + after: {}, + before: action === 'created' ? null : {}, + updatedFields: settings.updatedFields ?? [], + }, + }; +}; diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationFrontComponentDetail.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationFrontComponentDetail.tsx new file mode 100644 index 0000000000..dcee044fde --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationFrontComponentDetail.tsx @@ -0,0 +1,110 @@ +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; +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 { useQuery } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; +import { useParams } from 'react-router-dom'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { IconEye, IconSettings } from 'twenty-ui/display'; +import { FindOneApplicationDocument } from '~/generated-metadata/graphql'; +import { SettingsApplicationFrontComponentPreviewTab } from '~/pages/settings/applications/tabs/SettingsApplicationFrontComponentPreviewTab'; +import { SettingsApplicationFrontComponentSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationFrontComponentSettingsTab'; + +const FRONT_COMPONENT_DETAIL_ID = 'application-front-component-detail'; + +export const SettingsApplicationFrontComponentDetail = () => { + const { applicationId = '', frontComponentId = '' } = useParams<{ + applicationId: string; + frontComponentId: string; + }>(); + + const { data, loading } = useQuery(FindOneApplicationDocument, { + variables: { id: applicationId }, + skip: !applicationId, + }); + + const application = data?.findOneApplication; + const frontComponent = application?.frontComponents?.find( + (fc) => fc.id === frontComponentId, + ); + + const instanceId = `${FRONT_COMPONENT_DETAIL_ID}-${frontComponentId}`; + const activeTabId = useAtomComponentStateValue( + activeTabIdComponentState, + instanceId, + ); + + const tabs = [ + { id: 'preview', title: t`Preview`, Icon: IconEye }, + { id: 'settings', title: t`Settings`, Icon: IconSettings }, + ]; + + const applicationContentHref = getSettingsPath( + SettingsPath.ApplicationDetail, + { applicationId }, + undefined, + 'content', + ); + const breadcrumbLinks = [ + { + children: t`Workspace`, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: t`Applications`, + href: getSettingsPath(SettingsPath.Applications), + }, + { children: application?.name ?? '', href: applicationContentHref }, + { children: t`Front components`, href: applicationContentHref }, + { children: frontComponent?.name ?? '' }, + ]; + + const renderActiveTabContent = () => { + if (!isDefined(frontComponent)) { + return ; + } + + const resolvedTabId = activeTabId ?? 'preview'; + + switch (resolvedTabId) { + case 'preview': + return ( + + ); + case 'settings': + return ( + + ); + default: + return null; + } + }; + + return ( + + + + {loading ? : renderActiveTabContent()} + + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationContentSubtable.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationContentSubtable.tsx new file mode 100644 index 0000000000..5a7e40396e --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationContentSubtable.tsx @@ -0,0 +1,83 @@ +import { + StyledActionTableCell, + StyledNameTableCell, +} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { TableSection } from '@/ui/layout/table/components/TableSection'; +import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { + IconChevronRight, + OverflowingTextWithTooltip, + useIcons, +} from 'twenty-ui/display'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +export type ApplicationContentRow = { + key: string; + name: string; + icon?: string; + secondary?: string; + link?: string; +}; + +const GRID_TEMPLATE_COLUMNS = '1fr auto 32px'; + +export const SettingsApplicationContentSubtable = ({ + title, + rows, +}: { + title: string; + rows: ApplicationContentRow[]; +}) => { + const { theme } = useContext(ThemeContext); + const { getIcon } = useIcons(); + + if (rows.length === 0) { + return null; + } + + return ( + + {rows.map((row) => { + const Icon = getIcon(row.icon); + + return ( + + + {isDefined(Icon) && ( + + )} + + + + {isDefined(row.secondary) && ( + + )} + + + {isDefined(row.link) && ( + + )} + + + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDataTable.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDataTable.tsx deleted file mode 100644 index 14cc4cacb1..0000000000 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDataTable.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder'; -import { SETTINGS_OBJECT_TABLE_COLUMN_WIDTH } from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents'; -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 { TableSection } from '@/ui/layout/table/components/TableSection'; -import { styled } from '@linaria/react'; -import { t } from '@lingui/core/macro'; -import { useMemo, useState } from 'react'; -import { H2Title } from 'twenty-ui/display'; -import { SearchInput } from 'twenty-ui/input'; -import { Section } from 'twenty-ui/layout'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; - -import { SettingsApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTableRow'; -import { normalizeSearchText } from '~/utils/normalizeSearchText'; - -export type ApplicationDataTableRow = { - key: string; - labelPlural: string; - icon?: string; - fieldsCount: number; - link?: string; - tagItem: { - isCustom?: boolean; - isRemote?: boolean; - applicationId?: string | null; - }; -}; - -const MAIN_ROW_GRID_COLUMNS = `180px 1fr ${SETTINGS_OBJECT_TABLE_COLUMN_WIDTH} 36px`; - -const StyledEmptyHeaderContainer = styled.div` - > div { - min-width: 0; - } -`; - -const StyledSearchInputContainer = styled.div` - padding-bottom: ${themeCssVariables.spacing[2]}; -`; - -export const SettingsApplicationDataTable = ({ - objectRows, - fieldGroupRows, -}: { - objectRows: ApplicationDataTableRow[]; - fieldGroupRows: ApplicationDataTableRow[]; -}) => { - const [searchTerm, setSearchTerm] = useState(''); - - const filteredObjectRows = useMemo(() => { - const normalizedSearch = normalizeSearchText(searchTerm); - - if (normalizedSearch === '') { - return objectRows; - } - - return objectRows.filter((row) => - normalizeSearchText(row.labelPlural).includes(normalizedSearch), - ); - }, [objectRows, searchTerm]); - - const filteredFieldGroupRows = useMemo(() => { - const normalizedSearch = normalizeSearchText(searchTerm); - - if (normalizedSearch === '') { - return fieldGroupRows; - } - - return fieldGroupRows.filter((row) => - normalizeSearchText(row.labelPlural).includes(normalizedSearch), - ); - }, [fieldGroupRows, searchTerm]); - - const shouldDisplayObjects = filteredObjectRows.length > 0; - const shouldDisplayFields = filteredFieldGroupRows.length > 0; - const hasSearchTerm = searchTerm.trim().length > 0; - const hasNoResults = - hasSearchTerm && !shouldDisplayObjects && !shouldDisplayFields; - - if (objectRows.length === 0 && fieldGroupRows.length === 0) { - return null; - } - - return ( -
- - - - - {hasNoResults ? ( - {t`No object found`} - ) : ( - - - {t`Name`} - {t`App`} - {t`Fields`} - - - - - {shouldDisplayObjects && ( - - {filteredObjectRows.map((row) => ( - - ))} - - )} - {shouldDisplayFields && ( - - {filteredFieldGroupRows.map((row) => ( - - ))} - - )} -
- )} -
- ); -}; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDataTableRow.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDataTableRow.tsx deleted file mode 100644 index f07881d8b3..0000000000 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDataTableRow.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag'; -import { - StyledActionTableCell, - StyledNameTableCell, -} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents'; -import { TableCell } from '@/ui/layout/table/components/TableCell'; -import { TableRow } from '@/ui/layout/table/components/TableRow'; -import { styled } from '@linaria/react'; -import { useContext } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { IconChevronRight, useIcons } from 'twenty-ui/display'; -import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; - -import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable'; - -const MAIN_ROW_GRID_COLUMNS = '180px 1fr 98.7px 36px'; - -const StyledNameContainer = styled.div` - align-items: center; - display: flex; - flex: 1; - gap: ${themeCssVariables.spacing[1]}; - min-width: 0; -`; - -const StyledNameLabel = styled.div` - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -export const SettingsApplicationDataTableRow = ({ - row, -}: { - row: ApplicationDataTableRow; -}) => { - const { theme } = useContext(ThemeContext); - const { getIcon } = useIcons(); - - const Icon = getIcon(row.icon); - - return ( - - - {isDefined(Icon) && ( - - )} - - - {row.labelPlural} - - - - - - - {row.fieldsCount} - - {row.link && ( - - )} - - - ); -}; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationNameDescriptionTable.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationNameDescriptionTable.tsx deleted file mode 100644 index ca5a95d3b1..0000000000 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationNameDescriptionTable.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Table } from '@/ui/layout/table/components/Table'; -import { TableCell } from '@/ui/layout/table/components/TableCell'; -import { TableHeader } from '@/ui/layout/table/components/TableHeader'; -import { TableRow } from '@/ui/layout/table/components/TableRow'; -import { TableSection } from '@/ui/layout/table/components/TableSection'; -import { t } from '@lingui/core/macro'; -import { H2Title, OverflowingTextWithTooltip } from 'twenty-ui/display'; -import { Section } from 'twenty-ui/layout'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; - -export type ApplicationNameDescriptionTableRow = { - key: string; - name: string; - description?: string | null; -}; - -export const SettingsApplicationNameDescriptionTable = ({ - title, - description, - sectionTitle, - items, -}: { - title: string; - description: string; - sectionTitle: string; - items: ApplicationNameDescriptionTableRow[]; -}) => { - if (items.length === 0) { - return null; - } - - return ( -
- - - - {t`Name`} - {t`Description`} - - - {items.map((item) => ( - - - - - - - - - ))} - -
-
- ); -}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx index 835ff81eb8..5004a7e2ea 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx @@ -1,28 +1,32 @@ -import { - type LogicFunctionTableRow, - SettingsLogicFunctionsTable, -} from '@/settings/logic-functions/components/SettingsLogicFunctionsTable'; -import { useObjectAndFieldRows } from '@/settings/applications/hooks/useObjectAndFieldRows'; -import { t } from '@lingui/core/macro'; -import { useMemo } from 'react'; +import { getLogicFunctionTriggerLabel } from '@/logic-functions/utils/getLogicFunctionTriggerLabel'; +import { useComputeApplicationContentForLayoutAndLogic } from '@/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic'; +import { useComputeObjectAndFieldsContentForApplication } from '@/settings/applications/hooks/useComputeObjectAndFieldsContentForApplication'; +import { Table } from '@/ui/layout/table/components/Table'; +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; import { type Manifest } from 'twenty-shared/application'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { H2Title } from 'twenty-ui/display'; +import { SearchInput } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { type Application } from '~/generated-metadata/graphql'; -import { SettingsApplicationDataTable } from '~/pages/settings/applications/components/SettingsApplicationDataTable'; import { - type ApplicationNameDescriptionTableRow, - SettingsApplicationNameDescriptionTable, -} from '~/pages/settings/applications/components/SettingsApplicationNameDescriptionTable'; + type ApplicationContentRow, + SettingsApplicationContentSubtable, +} from '~/pages/settings/applications/components/SettingsApplicationContentSubtable'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; type InstalledApplicationForContentTab = Omit< Application, 'objects' | 'universalIdentifier' | 'frontComponents' > & { objects: { id: string }[]; - frontComponents?: { name: string; description?: string | null }[]; + frontComponents?: { + id: string; + name: string; + description?: string | null; + }[]; }; type SettingsApplicationDetailContentTabProps = { @@ -31,89 +35,200 @@ type SettingsApplicationDetailContentTabProps = { manifestContent?: Manifest; }; +const filterRows = (rows: ApplicationContentRow[], normalizedSearch: string) => + normalizedSearch === '' + ? rows + : rows.filter( + (row) => + normalizeSearchText(row.name).includes(normalizedSearch) || + (isDefined(row.secondary) && + normalizeSearchText(row.secondary).includes(normalizedSearch)), + ); + export const SettingsApplicationDetailContentTab = ({ applicationId, installedApplication, manifestContent, }: SettingsApplicationDetailContentTabProps) => { - const { objectRows, fieldGroupRows } = useObjectAndFieldRows({ - applicationId, + const { t } = useLingui(); + + const { objectRows, fieldRows } = + useComputeObjectAndFieldsContentForApplication({ + installedApplication, + manifestContent, + }); + + const { + pageLayoutRows, + viewRows, + navigationMenuItemRows, + agentRows, + skillRows, + roleRows, + } = useComputeApplicationContentForLayoutAndLogic({ installedApplication, manifestContent, }); - const logicFunctionRows = useMemo((): LogicFunctionTableRow[] => { - const computeTrigger = (lf: { - isTool?: boolean; - cronTriggerSettings?: unknown; - httpRouteTriggerSettings?: unknown; - databaseEventTriggerSettings?: { eventName?: string } | null; - }): string => { - if (lf.isTool) return 'Tool'; - if (lf.cronTriggerSettings) return 'Cron'; - if (lf.httpRouteTriggerSettings) return 'Route'; - if (lf.databaseEventTriggerSettings) - return lf.databaseEventTriggerSettings.eventName ?? ''; - return ''; - }; + const lifecycleOptions = { + postInstallUniversalIdentifier: + manifestContent?.application?.postInstallLogicFunction + ?.universalIdentifier, + preInstallUniversalIdentifier: + manifestContent?.application?.preInstallLogicFunction + ?.universalIdentifier, + }; - if (isDefined(installedApplication)) { - return (installedApplication.logicFunctions ?? []).map((lf) => ({ + const logicFunctionRows: ApplicationContentRow[] = isDefined( + installedApplication, + ) + ? (installedApplication.logicFunctions ?? []).map((lf) => ({ key: lf.id, name: lf.name, - trigger: computeTrigger(lf), + secondary: getLogicFunctionTriggerLabel(lf, lifecycleOptions), link: getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, { applicationId, logicFunctionId: lf.id, }), + })) + : (manifestContent?.logicFunctions ?? []).map((lf) => ({ + key: lf.universalIdentifier, + name: lf.name ?? lf.universalIdentifier, + secondary: getLogicFunctionTriggerLabel(lf, lifecycleOptions), })); - } - return (manifestContent?.logicFunctions ?? []).map((lf) => ({ - key: lf.universalIdentifier, - name: lf.name ?? lf.universalIdentifier, - trigger: computeTrigger(lf), - })); - }, [installedApplication, manifestContent?.logicFunctions, applicationId]); - - const frontComponentRows = - useMemo((): ApplicationNameDescriptionTableRow[] => { - if (isDefined(installedApplication)) { - return (installedApplication.frontComponents ?? []).map((fc) => ({ - key: fc.name, - name: fc.name, - description: fc.description, - })); - } - - return (manifestContent?.frontComponents ?? []).map((fc) => ({ + const frontComponentRows: ApplicationContentRow[] = isDefined( + installedApplication, + ) + ? (installedApplication.frontComponents ?? []).map((fc) => ({ + key: fc.id, + name: fc.name, + secondary: fc.description ?? undefined, + link: getSettingsPath(SettingsPath.ApplicationFrontComponentDetail, { + applicationId, + frontComponentId: fc.id, + }), + })) + : (manifestContent?.frontComponents ?? []).map((fc) => ({ key: fc.universalIdentifier, name: fc.name ?? fc.universalIdentifier, - description: fc.description, + secondary: fc.description ?? undefined, })); - }, [installedApplication, manifestContent?.frontComponents]); + + const [searchTerm, setSearchTerm] = useState(''); + const normalizedSearch = normalizeSearchText(searchTerm); + + const filtered = { + objects: filterRows(objectRows, normalizedSearch), + fields: filterRows(fieldRows, normalizedSearch), + pageLayouts: filterRows(pageLayoutRows, normalizedSearch), + views: filterRows(viewRows, normalizedSearch), + navigation: filterRows(navigationMenuItemRows, normalizedSearch), + frontComponents: filterRows(frontComponentRows, normalizedSearch), + logicFunctions: filterRows(logicFunctionRows, normalizedSearch), + agents: filterRows(agentRows, normalizedSearch), + skills: filterRows(skillRows, normalizedSearch), + roles: filterRows(roleRows, normalizedSearch), + }; + + const hasData = filtered.objects.length > 0 || filtered.fields.length > 0; + const hasLayout = + filtered.pageLayouts.length > 0 || + filtered.views.length > 0 || + filtered.navigation.length > 0 || + filtered.frontComponents.length > 0; + const hasLogic = + filtered.logicFunctions.length > 0 || + filtered.agents.length > 0 || + filtered.skills.length > 0 || + filtered.roles.length > 0; + + if (!hasData && !hasLayout && !hasLogic && normalizedSearch === '') { + return null; + } return ( <> - - {logicFunctionRows.length > 0 && ( +
+ +
+ + {hasData && ( +
+ + + + +
+
+ )} + + {hasLayout && ( +
+ + + + + + +
+
+ )} + + {hasLogic && (
- + + + + + +
)} - ); }; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationFrontComponentPreviewTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationFrontComponentPreviewTab.tsx new file mode 100644 index 0000000000..8cf18ae9e8 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationFrontComponentPreviewTab.tsx @@ -0,0 +1,75 @@ +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { Suspense, lazy } from 'react'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const FrontComponentRenderer = lazy(() => + import('@/front-components/components/FrontComponentRenderer').then( + (module) => ({ default: module.FrontComponentRenderer }), + ), +); + +const StyledPreviewFrame = styled.div` + background: ${themeCssVariables.background.primary}; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.md}; + display: flex; + height: 600px; + overflow: auto; + width: 100%; +`; + +const StyledHeadlessNotice = styled.div` + align-items: center; + color: ${themeCssVariables.font.color.secondary}; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[2]}; + height: 100%; + justify-content: center; + padding: ${themeCssVariables.spacing[6]}; + text-align: center; + width: 100%; +`; + +const StyledHeadlessTitle = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.medium}; +`; + +const StyledRendererContainer = styled.div` + flex: 1; + min-height: 0; + min-width: 0; +`; + +type SettingsApplicationFrontComponentPreviewTabProps = { + frontComponentId: string; + isHeadless: boolean; +}; + +export const SettingsApplicationFrontComponentPreviewTab = ({ + frontComponentId, + isHeadless, +}: SettingsApplicationFrontComponentPreviewTabProps) => { + return ( +
+ + {isHeadless ? ( + + {t`Headless component`} + {t`This component runs without a UI and renders nothing here.`} + + ) : ( + + + + + + )} + +
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationFrontComponentSettingsTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationFrontComponentSettingsTab.tsx new file mode 100644 index 0000000000..eef48a0a85 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationFrontComponentSettingsTab.tsx @@ -0,0 +1,139 @@ +import { Table } from '@/ui/layout/table/components/Table'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { TableSection } from '@/ui/layout/table/components/TableSection'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { type ReactNode } from 'react'; +import { H2Title } from 'twenty-ui/display'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +type SettingsApplicationFrontComponentSettingsTabProps = { + description?: string | null; + componentName: string; + universalIdentifier?: string | null; + builtComponentChecksum: string; + isHeadless: boolean; + usesSdkClient: boolean; + createdAt: string; + updatedAt: string; +}; + +const StyledDescription = styled.div` + color: ${themeCssVariables.font.color.secondary}; + font-size: ${themeCssVariables.font.size.md}; + line-height: 1.5; + white-space: pre-wrap; +`; + +const StyledMonoText = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-family: ${themeCssVariables.code.font.family}, monospace; + font-size: ${themeCssVariables.font.size.sm}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const formatDateTime = (isoString: string): string => { + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) { + return isoString; + } + return date.toLocaleString(); +}; + +const GRID_TEMPLATE = '220px 1fr'; + +export const SettingsApplicationFrontComponentSettingsTab = ({ + description, + componentName, + universalIdentifier, + builtComponentChecksum, + isHeadless, + usesSdkClient, + createdAt, + updatedAt, +}: SettingsApplicationFrontComponentSettingsTabProps) => { + const trimmedDescription = description?.trim(); + + const detailRows: { key: string; label: string; value: ReactNode }[] = [ + { + key: 'componentName', + label: t`Component name`, + value: {componentName}, + }, + { + key: 'universalIdentifier', + label: t`Universal identifier`, + value: ( + {universalIdentifier ?? t`Not set`} + ), + }, + { + key: 'isHeadless', + label: t`Headless`, + value: isHeadless ? t`Yes` : t`No`, + }, + { + key: 'usesSdkClient', + label: t`Uses SDK client`, + value: usesSdkClient ? t`Yes` : t`No`, + }, + { + key: 'builtComponentChecksum', + label: t`Build checksum`, + value: {builtComponentChecksum}, + }, + { + key: 'createdAt', + label: t`Created`, + value: formatDateTime(createdAt), + }, + { + key: 'updatedAt', + label: t`Updated`, + value: formatDateTime(updatedAt), + }, + ]; + + return ( + <> + {trimmedDescription !== undefined && trimmedDescription.length > 0 && ( +
+ + {trimmedDescription} +
+ )} +
+ + + + {t`Property`} + {t`Value`} + + + {detailRows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + +
+
+ + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationPermissionsTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationPermissionsTab.tsx index 9904b1d4cf..0338802bcc 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationPermissionsTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationPermissionsTab.tsx @@ -15,13 +15,10 @@ import { type ObjectManifest, type RoleManifest, } from 'twenty-shared/application'; -import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { isDefined } from 'twenty-shared/utils'; import { v4 as uuidv4 } from 'uuid'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier'; - type SettingsApplicationPermissionsTabProps = { defaultRoleId?: string | null; marketplaceAppDefaultRole?: RoleManifest; @@ -38,8 +35,16 @@ const resolvePermissionIds = ( const objectUniversalIdToIdMap: Record = {}; const fieldUniversalIdToIdMap: Record = {}; - const allObjectUniversalIds = new Set(); + const objectsByUid = new Map( + objectMetadataItems.map((item) => [item.universalIdentifier, item]), + ); + const fieldsByUid = new Map( + objectMetadataItems.flatMap((item) => + item.fields.map((field) => [field.universalIdentifier, field] as const), + ), + ); + const allObjectUniversalIds = new Set(); for (const permission of defaultRole.objectPermissions ?? []) { allObjectUniversalIds.add(permission.objectUniversalIdentifier); } @@ -48,51 +53,17 @@ const resolvePermissionIds = ( } for (const universalId of allObjectUniversalIds) { - const standardObjectName = findObjectNameByUniversalIdentifier(universalId); - - if (isDefined(standardObjectName)) { - const workspaceObject = objectMetadataItems.find( - (item) => item.nameSingular === standardObjectName, - ); - - if (isDefined(workspaceObject)) { - objectUniversalIdToIdMap[universalId] = workspaceObject.id; - } + const workspaceObject = objectsByUid.get(universalId); + if (isDefined(workspaceObject)) { + objectUniversalIdToIdMap[universalId] = workspaceObject.id; } } for (const permission of defaultRole.fieldPermissions ?? []) { - const objectName = findObjectNameByUniversalIdentifier( - permission.objectUniversalIdentifier, - ); - - if (isDefined(objectName)) { - const standardObject = - STANDARD_OBJECTS[objectName as keyof typeof STANDARD_OBJECTS]; - - if (isDefined(standardObject)) { - for (const [fieldName, fieldDef] of Object.entries( - standardObject.fields, - )) { - if ( - (fieldDef as { universalIdentifier: string }) - .universalIdentifier === permission.fieldUniversalIdentifier - ) { - const workspaceObject = objectMetadataItems.find( - (item) => item.nameSingular === objectName, - ); - const workspaceField = workspaceObject?.fields.find( - (field) => field.name === fieldName, - ); - - if (isDefined(workspaceField)) { - fieldUniversalIdToIdMap[permission.fieldUniversalIdentifier] = - workspaceField.id; - } - break; - } - } - } + const workspaceField = fieldsByUid.get(permission.fieldUniversalIdentifier); + if (isDefined(workspaceField)) { + fieldUniversalIdToIdMap[permission.fieldUniversalIdentifier] = + workspaceField.id; } } diff --git a/packages/twenty-front/src/pages/settings/applications/utils/findObjectNameByUniversalIdentifier.ts b/packages/twenty-front/src/pages/settings/applications/utils/findObjectNameByUniversalIdentifier.ts deleted file mode 100644 index e75336e38f..0000000000 --- a/packages/twenty-front/src/pages/settings/applications/utils/findObjectNameByUniversalIdentifier.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; - -export const findObjectNameByUniversalIdentifier = ( - universalIdentifier: string, -): string | undefined => { - for (const [objectName, objectConfig] of Object.entries(STANDARD_OBJECTS)) { - if (objectConfig.universalIdentifier === universalIdentifier) { - return objectName; - } - } - - return undefined; -}; diff --git a/packages/twenty-front/src/pages/settings/layout/SettingsLayoutPageLayoutDetail.tsx b/packages/twenty-front/src/pages/settings/layout/SettingsLayoutPageLayoutDetail.tsx new file mode 100644 index 0000000000..8f37e78432 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/layout/SettingsLayoutPageLayoutDetail.tsx @@ -0,0 +1,109 @@ +import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { t } from '@lingui/core/macro'; +import { useParams } from 'react-router-dom'; +import { isDefined } from 'twenty-shared/utils'; +import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest'; +import { + type DetailRow, + SettingsLayoutDetailScaffold, +} from '~/pages/settings/layout/components/SettingsLayoutDetailScaffold'; +import { SettingsLayoutItemTable } from '~/pages/settings/layout/components/SettingsLayoutItemTable'; + +export const SettingsLayoutPageLayoutDetail = () => { + const { applicationId = '', pageLayoutUniversalIdentifier = '' } = useParams<{ + applicationId: string; + pageLayoutUniversalIdentifier: string; + }>(); + + const { application, manifest, isLoading } = + useApplicationManifest(applicationId); + + const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector); + + const findObjectLabel = (uid: string | undefined) => + isDefined(uid) + ? objectMetadataItems.find((o) => o.universalIdentifier === uid) + ?.labelSingular + : undefined; + + const pageLayout = manifest?.pageLayouts?.find( + (pl) => pl.universalIdentifier === pageLayoutUniversalIdentifier, + ); + + const objectLabel = isDefined(pageLayout) + ? findObjectLabel(pageLayout.objectUniversalIdentifier) + : undefined; + + const detailRows: DetailRow[] = isDefined(pageLayout) + ? [ + { + key: 'universalIdentifier', + label: t`Universal identifier`, + value: pageLayout.universalIdentifier, + }, + { key: 'type', label: t`Type`, value: pageLayout.type ?? t`Default` }, + { + key: 'object', + label: t`Object`, + value: objectLabel ?? pageLayout.objectUniversalIdentifier, + }, + ] + : []; + + const sortedTabs = [...(pageLayout?.tabs ?? [])].sort( + (a, b) => a.position - b.position, + ); + + return ( + + {sortedTabs.map((tab, index) => { + const widgets = tab.widgets ?? []; + const tabNumber = index + 1; + + const descriptionParts: string[] = []; + if (isDefined(tab.layoutMode)) { + descriptionParts.push(t`Layout mode: ${tab.layoutMode}`); + } + if (widgets.length > 0) { + descriptionParts.push( + widgets.length === 1 ? t`1 widget` : t`${widgets.length} widgets`, + ); + } + const description = + descriptionParts.length > 0 + ? descriptionParts.join(' · ') + : t`Empty tab`; + + return ( + ({ + key: widget.universalIdentifier, + cells: [ + widget.title, + widget.type, + findObjectLabel(widget.objectUniversalIdentifier) ?? '—', + ], + }))} + /> + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/layout/SettingsLayoutViewDetail.tsx b/packages/twenty-front/src/pages/settings/layout/SettingsLayoutViewDetail.tsx new file mode 100644 index 0000000000..18113e0648 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/layout/SettingsLayoutViewDetail.tsx @@ -0,0 +1,144 @@ +import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector'; +import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { t } from '@lingui/core/macro'; +import { useParams } from 'react-router-dom'; +import { isDefined } from 'twenty-shared/utils'; +import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest'; +import { + type DetailRow, + SettingsLayoutDetailScaffold, +} from '~/pages/settings/layout/components/SettingsLayoutDetailScaffold'; +import { SettingsLayoutItemTable } from '~/pages/settings/layout/components/SettingsLayoutItemTable'; + +const formatFilterValue = (value: unknown): string => { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return JSON.stringify(value); +}; + +export const SettingsLayoutViewDetail = () => { + const { applicationId = '', viewUniversalIdentifier = '' } = useParams<{ + applicationId: string; + viewUniversalIdentifier: string; + }>(); + + const { application, manifest, isLoading } = + useApplicationManifest(applicationId); + + const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector); + const flattenedFieldMetadataItems = useAtomStateValue( + flattenedFieldMetadataItemsSelector, + ); + + const view = manifest?.views?.find( + (v) => v.universalIdentifier === viewUniversalIdentifier, + ); + + const objectLabel = isDefined(view) + ? objectMetadataItems.find( + (o) => o.universalIdentifier === view.objectUniversalIdentifier, + )?.labelSingular + : undefined; + + const resolveFieldLabel = (uid: string): string => + flattenedFieldMetadataItems.find((f) => f.universalIdentifier === uid) + ?.label ?? uid; + + const detailRows: DetailRow[] = isDefined(view) + ? [ + { + key: 'universalIdentifier', + label: t`Universal identifier`, + value: view.universalIdentifier, + }, + { key: 'type', label: t`Type`, value: view.type ?? t`Table` }, + { + key: 'object', + label: t`Object`, + value: objectLabel ?? view.objectUniversalIdentifier, + }, + { key: 'icon', label: t`Icon`, value: view.icon ?? t`Not set` }, + { + key: 'visibility', + label: t`Visibility`, + value: view.visibility ?? t`Default`, + }, + { + key: 'openRecordIn', + label: t`Open records in`, + value: view.openRecordIn ?? t`Default`, + }, + ] + : []; + + const sortedFields = [...(view?.fields ?? [])].sort( + (a, b) => a.position - b.position, + ); + + return ( + + ({ + key: field.universalIdentifier, + cells: [ + field.position, + resolveFieldLabel(field.fieldMetadataUniversalIdentifier), + field.isVisible === false ? t`Hidden` : t`Yes`, + field.size ?? '—', + ], + }))} + /> + ({ + key: filter.universalIdentifier, + cells: [ + resolveFieldLabel(filter.fieldMetadataUniversalIdentifier), + filter.operand, + formatFilterValue(filter.value), + ], + }))} + /> + ({ + key: sort.universalIdentifier, + cells: [ + resolveFieldLabel(sort.fieldMetadataUniversalIdentifier), + sort.direction, + ], + }))} + /> + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/layout/components/SettingsLayoutDetailScaffold.tsx b/packages/twenty-front/src/pages/settings/layout/components/SettingsLayoutDetailScaffold.tsx new file mode 100644 index 0000000000..04f14061c7 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/layout/components/SettingsLayoutDetailScaffold.tsx @@ -0,0 +1,116 @@ +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableBody } from '@/ui/layout/table/components/TableBody'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { type ReactNode } from 'react'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { H2Title } from 'twenty-ui/display'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +export type DetailRow = { key: string; label: string; value: ReactNode }; + +const StyledDescription = styled.div` + color: ${themeCssVariables.font.color.secondary}; + font-size: ${themeCssVariables.font.size.md}; + line-height: 1.5; + white-space: pre-wrap; +`; + +const GRID_TEMPLATE = '220px 1fr'; + +export const SettingsLayoutDetailScaffold = ({ + applicationId, + applicationName, + entityName, + entityTypeLabel, + categoryLabel, + description, + detailRows, + isLoading, + children, +}: { + applicationId: string; + applicationName: string | undefined; + entityName: string; + entityTypeLabel: string; + categoryLabel: string; + description?: string | null; + detailRows: DetailRow[]; + isLoading: boolean; + children?: ReactNode; +}) => { + const trimmedDescription = description?.trim(); + + const applicationContentHref = getSettingsPath( + SettingsPath.ApplicationDetail, + { applicationId }, + undefined, + 'content', + ); + + const breadcrumbLinks = [ + { children: t`Workspace`, href: getSettingsPath(SettingsPath.Workspace) }, + { + children: t`Applications`, + href: getSettingsPath(SettingsPath.Applications), + }, + { children: applicationName ?? '', href: applicationContentHref }, + { children: categoryLabel, href: applicationContentHref }, + { children: entityName }, + ]; + + return ( + + + {isLoading ? ( + + ) : ( + <> + {isDefined(trimmedDescription) && trimmedDescription.length > 0 && ( +
+ + {trimmedDescription} +
+ )} +
+ + + + {t`Property`} + {t`Value`} + + + {detailRows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + +
+
+ {children} + + )} +
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/layout/components/SettingsLayoutItemTable.tsx b/packages/twenty-front/src/pages/settings/layout/components/SettingsLayoutItemTable.tsx new file mode 100644 index 0000000000..ec355c504d --- /dev/null +++ b/packages/twenty-front/src/pages/settings/layout/components/SettingsLayoutItemTable.tsx @@ -0,0 +1,71 @@ +import { Table } from '@/ui/layout/table/components/Table'; +import { TableBody } from '@/ui/layout/table/components/TableBody'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { type ReactNode } from 'react'; +import { H2Title } from 'twenty-ui/display'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +type Column = { + key: string; + label: string; + align?: 'left' | 'right' | 'center'; + width?: string; +}; + +type Row = { + key: string; + cells: ReactNode[]; +}; + +export const SettingsLayoutItemTable = ({ + title, + description, + columns, + rows, +}: { + title: string; + description?: string; + columns: Column[]; + rows: Row[]; +}) => { + if (rows.length === 0) { + return null; + } + + const gridTemplate = columns.map((c) => c.width ?? '1fr').join(' '); + + return ( +
+ + + + {columns.map((col) => ( + + {col.label} + + ))} + + + {rows.map((row) => ( + + {row.cells.map((cell, index) => ( + + {cell} + + ))} + + ))} + +
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/layout/hooks/__tests__/useApplicationManifest.test.tsx b/packages/twenty-front/src/pages/settings/layout/hooks/__tests__/useApplicationManifest.test.tsx new file mode 100644 index 0000000000..6710c9745a --- /dev/null +++ b/packages/twenty-front/src/pages/settings/layout/hooks/__tests__/useApplicationManifest.test.tsx @@ -0,0 +1,66 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { type MockedResponse } from '@apollo/client/testing'; +import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest'; +import { + FindMarketplaceAppDetailDocument, + FindOneApplicationDocument, +} from '~/generated-metadata/graphql'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; + +const APP_ID = 'app-1'; +const APP_UID = 'uid-1'; + +const findOneApplicationMock = ( + application: { id: string; universalIdentifier: string; name: string } | null, +): MockedResponse => ({ + request: { + query: FindOneApplicationDocument, + variables: { id: APP_ID }, + }, + result: { data: { findOneApplication: application } }, +}); + +const findMarketplaceAppDetailMock = ( + manifest: object | null, +): MockedResponse => ({ + request: { + query: FindMarketplaceAppDetailDocument, + variables: { universalIdentifier: APP_UID }, + }, + result: { + data: { + findMarketplaceAppDetail: manifest === null ? null : { manifest }, + }, + }, +}); + +describe('useApplicationManifest', () => { + it('skips both queries when applicationId is empty', () => { + const wrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] }); + const { result } = renderHook(() => useApplicationManifest(''), { + wrapper, + }); + expect(result.current.application).toBeUndefined(); + expect(result.current.manifest).toBeUndefined(); + }); + + it('exposes an undefined manifest when findMarketplaceAppDetail returns null', async () => { + const wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [ + findOneApplicationMock({ + id: APP_ID, + universalIdentifier: APP_UID, + name: 'My App', + }), + findMarketplaceAppDetailMock(null), + ], + }); + + const { result } = renderHook(() => useApplicationManifest(APP_ID), { + wrapper, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.manifest).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front/src/pages/settings/layout/hooks/useApplicationManifest.ts b/packages/twenty-front/src/pages/settings/layout/hooks/useApplicationManifest.ts new file mode 100644 index 0000000000..92c81e45dc --- /dev/null +++ b/packages/twenty-front/src/pages/settings/layout/hooks/useApplicationManifest.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@apollo/client/react'; +import { type Manifest } from 'twenty-shared/application'; +import { + FindMarketplaceAppDetailDocument, + FindOneApplicationDocument, +} from '~/generated-metadata/graphql'; + +// Loads the app + its marketplace manifest. Both queries are cache-first since +// the application detail page already issues them. +export const useApplicationManifest = (applicationId: string) => { + const { data: appData, loading: appLoading } = useQuery( + FindOneApplicationDocument, + { + variables: { id: applicationId }, + fetchPolicy: 'cache-first', + skip: !applicationId, + }, + ); + + const application = appData?.findOneApplication; + + const { data: detailData, loading: detailLoading } = useQuery( + FindMarketplaceAppDetailDocument, + { + variables: { + universalIdentifier: application?.universalIdentifier ?? '', + }, + fetchPolicy: 'cache-first', + skip: !application?.universalIdentifier, + }, + ); + + const manifest = detailData?.findMarketplaceAppDetail?.manifest as + | Manifest + | undefined; + + return { + application, + manifest, + isLoading: appLoading || detailLoading, + }; +}; diff --git a/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx b/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx index 3088242e38..53c48f489a 100644 --- a/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx +++ b/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx @@ -89,28 +89,27 @@ export const SettingsLogicFunctionDetail = () => { const isTestTab = activeTabId === 'test'; const breadcrumbLinks = isDefined(applicationId) - ? [ - { - children: t`Workspace`, - href: getSettingsPath(SettingsPath.Workspace), - }, - { - children: t`Applications`, - href: getSettingsPath(SettingsPath.Applications), - }, - { - children: `${applicationName}`, - href: getSettingsPath( - SettingsPath.ApplicationDetail, - { - applicationId, - }, - undefined, - 'content', - ), - }, - { children: `${logicFunction?.name}` }, - ] + ? (() => { + const applicationContentHref = getSettingsPath( + SettingsPath.ApplicationDetail, + { applicationId }, + undefined, + 'content', + ); + return [ + { + children: t`Workspace`, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: t`Applications`, + href: getSettingsPath(SettingsPath.Applications), + }, + { children: applicationName ?? '', href: applicationContentHref }, + { children: t`Logic functions`, href: applicationContentHref }, + { children: logicFunction?.name ?? '' }, + ]; + })() : [ { children: t`Workspace`, @@ -120,7 +119,8 @@ export const SettingsLogicFunctionDetail = () => { children: t`AI`, href: getSettingsPath(SettingsPath.AI), }, - { children: `${logicFunction?.name}` }, + { children: t`Logic functions` }, + { children: logicFunction?.name ?? '' }, ]; const files = [ @@ -154,8 +154,13 @@ export const SettingsLogicFunctionDetail = () => { isTesting={isExecuting} /> )} - {isTriggersTab && logicFunction && ( - + {isTriggersTab && ( + )} {isSettingsTab && ( { )} diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts index e487fcdd37..026656971b 100644 --- a/packages/twenty-shared/src/types/SettingsPath.ts +++ b/packages/twenty-shared/src/types/SettingsPath.ts @@ -43,6 +43,9 @@ export enum SettingsPath { Applications = 'applications', ApplicationDetail = 'applications/:applicationId', ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId', + ApplicationFrontComponentDetail = 'applications/:applicationId/frontComponents/:frontComponentId', + ApplicationViewDetail = 'applications/:applicationId/views/:viewUniversalIdentifier', + ApplicationPageLayoutDetail = 'applications/:applicationId/pageLayouts/:pageLayoutUniversalIdentifier', AvailableApplicationDetail = 'applications/available/:availableApplicationId', ApplicationRegistrationDetail = 'applications/registrations/:applicationRegistrationId', ApplicationRegistrationConfigVariableDetails = 'applications/registrations/:applicationRegistrationId/config-variables/:variableKey', diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 89b78f0aaf..46c9f52c94 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -228,8 +228,11 @@ export { IconHome, IconHourglassHigh, IconHours24, + IconHttpDelete, IconHttpGet, + IconHttpPatch, IconHttpPost, + IconHttpPut, IconId, IconInbox, IconInfoCircle, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index 75927e638c..a9da5341ff 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -307,8 +307,11 @@ export { IconHome, IconHourglassHigh, IconHours24, + IconHttpDelete, IconHttpGet, + IconHttpPatch, IconHttpPost, + IconHttpPut, IconId, IconInbox, IconInfoCircle,