Files
twenty/packages/twenty-front/src/modules/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic.ts
T
Félix Malfait 8f362186ce Redesign application content tab + logic function settings; add Layout detail pages (#20056)
## 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 `<SettingsApplicationContentSubtable>` +
`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 `<SettingsLogicFunctionTriggerSection>` 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 `<Select>`
(which auto-disables when there's only one option — the typical case).
- Read-only behavior for installed-app functions: explicit `<Callout>`
notice when there's no trigger; trigger sections render as disabled
controls when there is one.
- Removed the empty Environment Variables section from the Settings tab
(it just told the user to go elsewhere).

### New `pages/settings/layout/` folder

Three new app-scoped detail pages so users can drill into entities the
GraphQL `Application` type doesn't expose by id (keyed by manifest
`universalIdentifier`):

- `ApplicationViewDetail` — type, object, visibility + Fields / Filters
/ Sorts subsections (field UIDs resolved to readable labels via
`useFieldLabelByUid`)
- `ApplicationPageLayoutDetail` — type, object + per-tab subsections
listing widgets
- `ApplicationNavigationMenuItemDetail` — type, destination (resolved),
icon, color, position

Each page reads from the marketplace manifest the parent app page
already loads (no extra queries). Folder set up so a future "Layout"
settings tab can grow here (analogous to the existing `data-model/`
folder under the Data tab).

### Other consistency fixes

- Breadcrumbs on every app-scoped entity detail page now include a
category crumb so users know what they're looking at: `Workspace /
Applications / Timely / Navigation menu items / Time entry`.
- Title fallback for nav menu items uses the resolved destination
(`"Time entry"`) instead of the raw enum (`"OBJECT"`).
- New shared utils: `getNavigationMenuItemDestination`,
`resolveManifestObjectLabel`, `getLogicFunctionTriggerLabel`,
`<MonoText>`.

## Backend changes

Only one minor schema-shape change (additive): added `applicationId` to
the `SkillFields` GraphQL fragment and `universalIdentifier` to the
`RoleFragment` so the new lookups have what they need. Generated
metadata schema patched in-tree to match — regenerate with `nx run
twenty-front:graphql:generate --configuration=metadata` if it drifts.

## Test plan

- [ ] Application content tab on an installed app shows the 3 grouped
sections; rows in each section are clickable
- [ ] Click an Object → existing object detail page
- [ ] Click a Field → existing field-edit page
- [ ] Click an Agent / Skill / Role → existing detail page
- [ ] Click a View / Page layout / Navigation menu item → new read-only
detail page; subsections (Fields/Filters/Sorts for views, per-tab
widgets for page layouts) populate correctly
- [ ] Breadcrumbs on every entity detail page have 5 crumbs ending in
`<Category> / <Entity name>`
- [ ] Logic function Triggers tab: toggle each trigger type on/off, see
the Sample input preview update; for installed apps, sections render as
read-only
- [ ] Test tab: each "Simulate trigger" button prefills the JSON editor
with the matching payload shape
- [ ] Functions list: a function configured as `post-install` shows
"Post-install" in the Trigger column

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
2026-04-28 16:08:15 +02:00

185 lines
6.0 KiB
TypeScript

import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { capitalize, getSettingsPath, isDefined } from 'twenty-shared/utils';
import { type Application } from '~/generated-metadata/graphql';
import { type ApplicationContentRow } from '~/pages/settings/applications/components/SettingsApplicationContentSubtable';
type InstalledApplicationForContent = Pick<Application, 'agents' | 'id'>;
export const useComputeApplicationContentForLayoutAndLogic = ({
installedApplication,
manifestContent,
}: {
installedApplication?: InstalledApplicationForContent;
manifestContent?: Manifest;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const installedAppId = installedApplication?.id;
// Workspace metadata covers standard + installed-app objects; the manifest
// fallback only matters when previewing an uninstalled marketplace app.
const resolveLabel = (uid: string | undefined | null) => {
if (!isDefined(uid)) return undefined;
return (
objectMetadataItems.find((o) => o.universalIdentifier === uid)
?.labelSingular ??
manifestContent?.objects.find((o) => o.universalIdentifier === uid)
?.labelSingular
);
};
const pageLayoutRows: ApplicationContentRow[] = (
manifestContent?.pageLayouts ?? []
).map((layout) => {
const objectLabel = resolveLabel(layout.objectUniversalIdentifier);
const tabCount = layout.tabs?.length ?? 0;
const parts: string[] = [];
if (isDefined(objectLabel)) parts.push(t`for ${objectLabel}`);
if (tabCount > 0) {
parts.push(tabCount === 1 ? t`1 tab` : t`${tabCount} tabs`);
}
return {
key: layout.universalIdentifier,
name: layout.name,
secondary: parts.length > 0 ? parts.join(' · ') : undefined,
link: isDefined(installedAppId)
? getSettingsPath(SettingsPath.ApplicationPageLayoutDetail, {
applicationId: installedAppId,
pageLayoutUniversalIdentifier: layout.universalIdentifier,
})
: undefined,
};
});
const viewRows: ApplicationContentRow[] = (manifestContent?.views ?? []).map(
(view) => {
const objectLabel = resolveLabel(view.objectUniversalIdentifier);
const formattedType = capitalize((view.type ?? 'TABLE').toLowerCase());
return {
key: view.universalIdentifier,
name: view.name,
icon: view.icon ?? undefined,
secondary: isDefined(objectLabel)
? t`${formattedType} of ${objectLabel}`
: formattedType,
link: isDefined(installedAppId)
? getSettingsPath(SettingsPath.ApplicationViewDetail, {
applicationId: installedAppId,
viewUniversalIdentifier: view.universalIdentifier,
})
: undefined,
};
},
);
const navigationMenuItemRows: ApplicationContentRow[] = (
manifestContent?.navigationMenuItems ?? []
).map((item) => {
const destination = (() => {
switch (item.type) {
case 'FOLDER':
return { label: t`Folder`, displayName: t`Folder` };
case 'LINK': {
const link = item.link ?? t`Link`;
return { label: link, displayName: link };
}
case 'OBJECT': {
const label = resolveLabel(item.targetObjectUniversalIdentifier);
return {
label: isDefined(label) ? t`${label} list` : t`Object`,
displayName: label,
};
}
case 'PAGE_LAYOUT': {
const layout = manifestContent?.pageLayouts?.find(
(pl) =>
pl.universalIdentifier === item.pageLayoutUniversalIdentifier,
);
return {
label: isDefined(layout)
? t`${layout.name} layout`
: t`Page layout`,
displayName: layout?.name,
};
}
case 'VIEW': {
const view = manifestContent?.views?.find(
(v) => v.universalIdentifier === item.viewUniversalIdentifier,
);
return {
label: isDefined(view) ? t`${view.name} view` : t`View`,
displayName: view?.name,
};
}
case 'RECORD':
return { label: t`Record`, displayName: t`Record` };
default:
return { label: undefined, displayName: undefined };
}
})();
const displayName =
isDefined(item.name) && item.name !== ''
? item.name
: (destination.displayName ?? item.type);
return {
key: item.universalIdentifier,
name: displayName,
icon: item.icon ?? undefined,
secondary: destination.label,
};
});
const agentRows: ApplicationContentRow[] = isDefined(installedApplication)
? (installedApplication.agents ?? []).map((agent) => ({
key: agent.id,
name: agent.label,
icon: agent.icon ?? undefined,
secondary: agent.description ?? undefined,
link: getSettingsPath(SettingsPath.AiAgentDetail, {
agentId: agent.id,
}),
}))
: (manifestContent?.agents ?? []).map((agent) => ({
key: agent.universalIdentifier,
name: agent.label,
icon: agent.icon ?? undefined,
secondary: agent.description ?? undefined,
}));
const skillRows: ApplicationContentRow[] = (
manifestContent?.skills ?? []
).map((skill) => ({
key: skill.universalIdentifier,
name: skill.label,
icon: skill.icon ?? undefined,
secondary: skill.description ?? undefined,
}));
const roleRows: ApplicationContentRow[] = (manifestContent?.roles ?? []).map(
(role) => ({
key: role.universalIdentifier,
name: role.label,
icon: role.icon ?? undefined,
secondary: role.description ?? undefined,
}),
);
return {
pageLayoutRows,
viewRows,
navigationMenuItemRows,
agentRows,
skillRows,
roleRows,
};
};