8f362186ce
## 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>
185 lines
6.0 KiB
TypeScript
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,
|
|
};
|
|
};
|