Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 506fa18295 Autocomplete errors surface as address save failures
https://sonarly.com/issue/27228?type=bug

UTF-8 characters in address fields trigger unhandled Google Places autocomplete errors that surface in the UI, making users believe the address save was rejected when the actual persistence pipeline fully supports UTF-8.
2026-04-16 17:10:10 +00:00
Paul RastoinandGitHub bf410ae438 upgrade:status command (#19584)
## Introduction
Introducing a new command in order to determine the curent twenty
instance and workspaces status as it's not stored in database but a
derivation of each current curors


## `upgrade:status` all healthy
<img width="1376" height="1202" alt="image"
src="https://github.com/user-attachments/assets/e90d6987-07d2-4b6b-b573-105249aca325"
/>

## `upgrade:status` Nearly use cases
<img width="1442" height="1304" alt="image"
src="https://github.com/user-attachments/assets/c336cb9d-eb9d-4c7d-9392-ec1ef54a7326"
/>

## `upgrade:status --failed-only`
<img width="1442" height="940" alt="image"
src="https://github.com/user-attachments/assets/93a3dfdb-0d2f-4a01-b185-118e5cf0a078"
/>

## `upgrade:status -w aa8fdcb1-8ee1-4012-98af-44a97caa7411 -w
20202020-1c25-4d02-bf25-6aeccf7ea419 -w
20202020-1c25-4d02-bf25-6aeccf7ea412`
<img width="1486" height="928" alt="image"
src="https://github.com/user-attachments/assets/ec1b1abc-46e8-4e36-9799-ab3a4b85e410"
/>
2026-04-16 16:05:02 +00:00
EtienneandGitHub aecbc89a3f Fix slow db query issue (#19770)
https://github.com/twentyhq/twenty/pull/19586#discussion_r3074136617
2026-04-16 15:37:58 +00:00
Thomas TrompetteandGitHub 446a3923f2 Add workspace id in job logs (#19764)
Cannot currently investigate spikes
2026-04-16 15:28:37 +00:00
4f4f723ed0 Fix MCP discovery: path-aware well-known URL and protocol version (#19766)
## Summary

Adding `https://api.twenty.com/mcp` as an MCP server in Claude fails
with `Couldn't reach the MCP server` before OAuth can start. Two
independent bugs cause this:

1. **Missing path-aware well-known route.** The latest MCP spec
instructs clients to probe `/.well-known/oauth-protected-resource/mcp`
before `/.well-known/oauth-protected-resource`. Only the root path was
registered, so the path-aware request fell through to
`ServeStaticModule` and returned the SPA's `index.html` with HTTP 200.
Strict clients (Claude.ai) tried to parse it as JSON and gave up. Fixed
by registering both paths on the same handler.
2. **Stale protocol version.** Server advertised `2024-11-05`, which
predates Streamable HTTP. We've implemented Streamable HTTP (SSE
response format was added in #19528), so bumped to `2025-06-18`.

Reproduction before the fix:

```
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://api.twenty.com/.well-known/oauth-protected-resource/mcp
200 text/html; charset=UTF-8
```

After the fix this returns `application/json` with the RFC 9728 metadata
document.

Note: this is separate from #19755 (host-aware resource URL for
multi-host deployments).

## Test plan

- [x] `npx jest oauth-discovery.controller` — 2/2 tests pass, including
one asserting both routes are registered
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [ ] After deploy, `curl
https://api.twenty.com/.well-known/oauth-protected-resource/mcp` returns
JSON (not HTML)
- [ ] Adding `https://api.twenty.com/mcp` in Claude reaches the OAuth
authorization screen

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-16 17:32:45 +02:00
Charles BochetandGitHub 4103efcb84 fix: replace slow deep-equal with fastDeepEqual to resolve CPU bottleneck (#19771)
## Summary

- Replaced the `deep-equal` npm package with the existing
`fastDeepEqual` from `twenty-shared/utils` across 5 files in the server
and shared packages
- `deep-equal` was causing severe CPU overhead in the record update hot
path (`executeMany` → `formatTwentyOrmEventToDatabaseBatchEvent` →
`objectRecordChangedValues` → `deepEqual`, called **per field per
record**)
- `fastDeepEqual` is ~100x faster for plain JSON database records since
it skips unnecessary prototype chain inspection and edge-case handling
- Removed the now-unnecessary `LARGE_JSON_FIELDS` branching in
`objectRecordChangedValues` since all fields now use the fast
implementation
2026-04-16 17:23:50 +02:00
d3df58046c chore(server): drop api-host branch in OAuth discovery (#19768)
## Summary

Follow-up to #19755. Simplifies `OAuthDiscoveryController` by dropping
the `authorization_endpoint → frontend base URL` branch that was there
to make `api.twenty.com/mcp` paste-able in MCP clients.

We've decided not to support pasting `api.twenty.com/mcp` — users can
paste `app.twenty.com/mcp`, `<workspace>.twenty.com/mcp`, or a custom
domain, all of which serve both frontend and API. On those hosts,
`authorization_endpoint` was already pointed at the same host as
`issuer`, which is what we want.

## Change

- Remove `isApiHost` helper and the `authorizeBase` branch — use
`issuer` for `authorization_endpoint`.
- Drop now-unused `TwentyConfigService` and `DomainServerConfigService`
injections.
- Drop duplicate `DomainServerConfigModule` import from
`application-oauth.module.ts` (the module is no longer needed).

Net diff: +1 / -22 across 2 files.

## Breaking change

MCP clients configured with `https://api.twenty.com/mcp` will stop
working. They should be reconfigured with the host matching the
workspace they're connecting to (`<workspace>.twenty.com/mcp`,
`app.twenty.com/mcp`, or a custom domain).

## Test plan

- [x] `yarn jest --testPathPatterns="mcp-auth.guard"` → 2/2 passing
(unchanged)
- [x] `tsc --noEmit` clean on modified files
- [ ] Manual verification on staging: `app.twenty.com/mcp` and
`<workspace>.twenty.com/mcp` OAuth flow still works end-to-end

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

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-16 17:01:55 +02:00
cb6953abe3 fix(server): make OAuth discovery and MCP auth metadata host-aware (#19755)
## Summary

OAuth discovery metadata (RFC 9728 protected-resource, RFC 8414
authorization-server) and the MCP `WWW-Authenticate` header were
hardcoded to `SERVER_URL`. This breaks MCP clients that paste any URL
other than `api.twenty.com/mcp` — the metadata declares `resource:
https://api.twenty.com/mcp`, which doesn't match the URL the client
connected to, so the client rejects it and the OAuth flow never starts.

Reproduced with Claude's MCP integration: pasting
`<workspace>.twenty.com/mcp`, `app.twenty.com/mcp`, or a custom domain
returned *"Couldn't reach the MCP server"* because discovery returned a
resource URL for a different host.

Related memory: MCP clients POST to the URL the user entered, not the
discovered resource URL — so every paste-able hostname has to advertise
`resource` for that same hostname.

## What the server now does

`WorkspaceDomainsService.getValidatedRequestBaseUrl(req)` resolves the
canonical base URL for the host the request came in on, validated
against the set of hosts we actually serve:

- `SERVER_URL` (e.g. `api.twenty.com`) — API host
- default base URL (e.g. `app.twenty.com`) — the `DEFAULT_SUBDOMAIN`
base
- `FRONTEND_URL` bare host
- any `<workspace>.twenty.com` subdomain (DB lookup)
- any workspace `customDomain` where `isCustomDomainEnabled = true`
- any registered `publicDomain`

An unrecognized / spoofed Host falls back to
`DomainServerConfigService.getBaseUrl()`. **We never reflect arbitrary
Host values into the response.**

Callers updated:

- `OAuthDiscoveryController.getProtectedResourceMetadata` — echoes the
validated host into `resource` and `authorization_servers`.
- `OAuthDiscoveryController.getAuthorizationServerMetadata` — uses the
validated host for `issuer` and `*_endpoint`, **except**
`authorization_endpoint`: when the request came in via `SERVER_URL`
(API-only, no `/authorize` route), we keep that one pointed at the
default frontend base URL.
- `McpAuthGuard` — sets `WWW-Authenticate: Bearer
resource_metadata=\"<validatedBase>/.well-known/oauth-protected-resource\"`
on 401s, so the MCP client's follow-up discovery fetch lands on the same
host it started on.

## Security

- Workspace identity is already bound to the JWT via per-workspace
signing secrets (`jwtWrapperService.generateAppSecret(tokenType,
workspaceId)`). Host-aware discovery does not weaken that.
- Custom domains are only accepted once `isCustomDomainEnabled = true`
(i.e. after DNS verification), so an attacker can't register a
custom-domain mapping on a workspace and have discovery reflect it
before it's been proven.
- Unknown / spoofed Hosts fall through to the default base URL.

## Drive-by

Fixed a duplicate `DomainServerConfigModule` import in
`application-oauth.module.ts` while adding `WorkspaceDomainsModule`.

## Companion infra change required for custom domains

Customer custom domains (`crm.acme.com/mcp`) also require an
ingress-level fix to exclude `/mcp`, `/oauth`, and `/.well-known` from
the `/s\$uri` rewrite applied when `X-Twenty-Public-Domain: true`.
Shipping that in a twenty-infra PR (will cross-link here).

## Test plan

- [x] 14 new tests in
`WorkspaceDomainsService.getValidatedRequestBaseUrl` covering: missing
Host, SERVER_URL, base URL, FRONTEND_URL, workspace subdomain, unknown
subdomain fallback, enabled custom domain, disabled custom domain,
public domain, completely unrecognized host, lowercase coercion,
malformed Host, single-workspace mode fallback, DB throwing → fallback
- [x] New `oauth-discovery.controller.spec.ts` covering both endpoints
across api / app / workspace-subdomain / custom-domain hosts, plus
`cli_client_id` propagation
- [x] Rewrote `mcp-auth.guard.spec.ts` to cover `WWW-Authenticate` for
all four host types (api, workspace subdomain, custom domain, spoofed
fallback)
- [x] `yarn jest
--testPathPatterns=\"workspace-domains.service|oauth-discovery.controller|mcp-auth.guard\"`
→ 41/41 passing
- [x] `tsc --noEmit` clean on all modified files
- [ ] Manual verification against staging: connect Claude to
`api.twenty.com/mcp`, `app.twenty.com/mcp`,
`<workspace>.twenty.com/mcp`, and a custom domain and confirm OAuth flow
completes on each

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

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-16 16:47:42 +02:00
9bc803d0c7 i18n - translations (#19765)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-04-16 16:17:27 +02:00
Thomas TrompetteandGitHub cd31d9e0de Add test tab to tool step (#19760)
So we can use those as variables.
When the function input is updated, invalidate the input using an
effect.

<img width="770" height="635" alt="Capture d’écran 2026-04-16 à 14 45
41"
src="https://github.com/user-attachments/assets/cbf0b3e4-baad-424d-8f08-06eb1028abdb"
/>
2026-04-16 14:00:21 +00:00
c0fef0be08 i18n - translations (#19762)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-04-16 15:14:16 +02:00
Abdul RahmanandGitHub 270069c3e3 Add search to Fields dropdown (#19750)
Issue link:
https://discord.com/channels/1130383047699738754/1489198502998315100


https://github.com/user-attachments/assets/7d0a859e-c33e-4f9e-bdb3-5867fc0dd80f
2026-04-16 12:59:01 +00:00
Paul RastoinandGitHub 2413af0ba9 [run-instance-commands] Preserve fast slow sequentiality (#19757)
# Introduction
The command was wrongly running all fast and then all slow ignoring
instance commands segment
Leading to 
```ts
1.23.0_AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand_1776090711153 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [InstanceCommandRunnerService] 1.23.0_DropWorkspaceVersionColumnFastInstanceCommand_1785000000000 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [InstanceCommandRunnerService] 1.22.0_BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand_1775758621018 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [RunInstanceCommandsCommand] Instance commands completed
```

No prod/self host impact as 1.23 hasn't been released yet
2026-04-16 12:14:47 +00:00
2b5b8a8b13 Link command menu items to specific page layout (#19706)
- Add a `pageLayoutId` foreign key to `CommandMenuItem`, allowing
command menu items to be scoped to a specific page layout instead of
being globally available
- Filter command menu items by the current page layout on the frontend.
Items with a `pageLayoutId` only appear when viewing that layout, while
items without one remain globally visible
- Create an effect to track the current page layout ID
- Include a seed example: a "Show Notification" command pinned to the
Star history standalone page layout

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-04-16 12:07:36 +00:00
7af82fb6a4 i18n - translations (#19758)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-04-16 13:48:47 +02:00
9beb1ca326 Support Select All for workflow manual triggers (#19734)
## Summary
- Move record fetching and payload building from the enrichment hook
into `TriggerWorkflowVersionEngineCommand`, following the same
component-based pattern as `DeleteRecordsCommand` and
`RestoreRecordsCommand`
- The enrichment hook now only stores workflow metadata (`trigger`,
`availabilityType`, `availabilityObjectMetadataId`); the component uses
`useLazyFetchAllRecords` for exclusion mode (Select All) with full
pagination
- `buildTriggerWorkflowVersionPayloads` is now a pure function accepting
`selectedRecords: ObjectRecord[]` instead of reading from the Jotai
store

Fixes the issue introduced by #19718 which blocked Select All with a
warning toast instead of implementing it.

## Test plan
- [ ] Select individual records → run workflow trigger from command menu
→ works as before
- [ ] Click Select All → run workflow trigger from command menu →
fetches all matching records and runs the workflow
- [ ] Select All with some records deselected → correctly excludes those
records
- [ ] Global workflows (no object context) → run without payload as
before
- [ ] Bulk record triggers → payload wraps records in `{namePlural:
[records]}`

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-16 11:33:03 +00:00
d54092b0e2 fix: add missing LayoutRenderingProvider in SettingsApplicationCustomTab (#19679)
## Summary

- `SettingsApplicationCustomTab` renders `FrontComponentRenderer` which
calls `useFrontComponentExecutionContext` →
`useLayoutRenderingContext()`, but the settings page never provided a
`LayoutRenderingProvider`
- Every other render site (side panel, command menu, record pages) wraps
`FrontComponentRenderer` with this provider — it was just missed here
- Opening the "Custom" tab in Settings → Applications crashes with:
`LayoutRenderingContext Context not found`
- Fix: wrap with `LayoutRenderingProvider` using `DASHBOARD` layout type
and no target record, matching the pattern used in
`SidePanelFrontComponentPage`

## Test plan

- [ ] Open Settings → Applications → any app with a custom settings tab
- [ ] Click the "Custom" tab — should render the front component without
crashing

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Charles Bochet <[email protected]>
2026-04-16 10:01:28 +00:00
60701c2cf9 i18n - translations (#19754)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-04-16 12:03:48 +02:00
151 changed files with 7509 additions and 7019 deletions
@@ -2651,6 +2651,7 @@ type CommandMenuItem {
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
pageLayoutId: UUID
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
@@ -4183,6 +4184,7 @@ input CreateCommandMenuItemInput {
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
payload: JSON
pageLayoutId: UUID
}
input UpdateCommandMenuItemInput {
@@ -4196,6 +4198,7 @@ input UpdateCommandMenuItemInput {
availabilityObjectMetadataId: UUID
engineComponentKey: EngineComponentKey
hotKeys: [String!]
pageLayoutId: UUID
}
input CreateFrontComponentInput {
@@ -2368,6 +2368,7 @@ export interface CommandMenuItem {
hotKeys?: Scalars['String'][]
conditionalAvailabilityExpression?: Scalars['String']
availabilityObjectMetadataId?: Scalars['UUID']
pageLayoutId?: Scalars['UUID']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
@@ -5706,6 +5707,7 @@ export interface CommandMenuItemGenqlSelection{
hotKeys?: boolean | number
conditionalAvailabilityExpression?: boolean | number
availabilityObjectMetadataId?: boolean | number
pageLayoutId?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
@@ -6742,9 +6744,9 @@ update: UpdateLogicFunctionFromSourceInputUpdates}
export interface UpdateLogicFunctionFromSourceInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),sourceHandlerCode?: (Scalars['String'] | null),toolInputSchema?: (Scalars['JSON'] | null),handlerName?: (Scalars['String'] | null),sourceHandlerPath?: (Scalars['String'] | null),isTool?: (Scalars['Boolean'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null)}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),payload?: (Scalars['JSON'] | null)}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),payload?: (Scalars['JSON'] | null),pageLayoutId?: (Scalars['UUID'] | null)}
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null)}
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null),pageLayoutId?: (Scalars['UUID'] | null)}
export interface CreateFrontComponentInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),sourceComponentPath: Scalars['String'],builtComponentPath: Scalars['String'],componentName: Scalars['String'],builtComponentChecksum: Scalars['String']}
@@ -5334,6 +5334,9 @@ export default {
"availabilityObjectMetadataId": [
3
],
"pageLayoutId": [
3
],
"applicationId": [
3
],
@@ -10666,6 +10669,9 @@ export default {
"payload": [
15
],
"pageLayoutId": [
3
],
"__typename": [
1
]
@@ -10701,6 +10707,9 @@ export default {
"hotKeys": [
1
],
"pageLayoutId": [
3
],
"__typename": [
1
]
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useRes
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useStore } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import {
@@ -44,6 +45,7 @@ import { AppBasePath, AppPath, SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AnalyticsType } from '~/generated-metadata/graphql';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { getPageLayoutIdForLocation } from '~/modules/app/utils/getPageLayoutIdForLocation';
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { getPageTitleFromPath } from '~/utils/title-utils';
@@ -146,10 +148,15 @@ export const PageChangeEffect = () => {
if (!previousLocation || previousLocation !== location.pathname) {
setPreviousLocation(location.pathname);
executeTasksOnAnyLocationChange();
} else {
return;
const newPageLayoutId = getPageLayoutIdForLocation({
location,
store,
});
store.set(currentPageLayoutIdState.atom, newPageLayoutId);
}
}, [location, previousLocation, executeTasksOnAnyLocationChange]);
}, [location, previousLocation, executeTasksOnAnyLocationChange, store]);
useEffect(() => {
initializeQueryParamState();
@@ -0,0 +1,59 @@
import { type getDefaultStore } from 'jotai';
import { type Location, matchPath } from 'react-router-dom';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
const DASHBOARD_NAME_SINGULAR = CoreObjectNameSingular.Dashboard;
export const getPageLayoutIdForLocation = ({
location,
store,
}: {
location: Location;
store: ReturnType<typeof getDefaultStore>;
}): string | null => {
const recordShowMatch = matchPath(AppPath.RecordShowPage, location.pathname);
if (isDefined(recordShowMatch?.params.objectNameSingular)) {
const objectNameSingular = recordShowMatch.params.objectNameSingular;
if (objectNameSingular === DASHBOARD_NAME_SINGULAR) {
return null;
}
const objectMetadataItem = store.get(
objectMetadataItemFamilySelector.selectorFamily({
objectName: objectNameSingular,
objectNameType: 'singular',
}),
);
if (!isDefined(objectMetadataItem)) {
return null;
}
const recordPageLayout = store.get(
recordPageLayoutByObjectMetadataIdFamilySelector.selectorFamily({
objectMetadataId: objectMetadataItem.id,
}),
);
return isDefined(recordPageLayout)
? recordPageLayout.id
: getDefaultRecordPageLayoutId({
targetObjectNameSingular: objectNameSingular,
});
}
const pageLayoutMatch = matchPath(AppPath.PageLayoutPage, location.pathname);
if (isDefined(pageLayoutMatch?.params.pageLayoutId)) {
return pageLayoutMatch.params.pageLayoutId;
}
return null;
};
@@ -5,7 +5,9 @@ import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/compon
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useMemo } from 'react';
@@ -22,6 +24,7 @@ export const StandalonePageCommandMenu = () => {
const isMobile = useIsMobile();
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
const { objectMetadataItems } = useObjectMetadataItems();
const commandMenuContextApi = useMemo<CommandMenuContextApi>(() => {
@@ -83,6 +86,7 @@ export const StandalonePageCommandMenu = () => {
item.availabilityType !==
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
)
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
@@ -92,7 +96,7 @@ export const StandalonePageCommandMenu = () => {
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi]);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
return (
<CommandMenuContext.Provider
@@ -4,7 +4,9 @@ import {
} from '@/command-menu-item/contexts/CommandMenuContext';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { type CommandMenuContextApi } from 'twenty-shared/types';
@@ -24,6 +26,7 @@ export const CommandMenuContextProviderContent = ({
commandMenuContextApi,
}: CommandMenuContextProviderContentProps) => {
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
const filteredCommandMenuItems = useMemo(() => {
const currentObjectMetadataItemId =
@@ -34,6 +37,7 @@ export const CommandMenuContextProviderContent = ({
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
@@ -43,7 +47,7 @@ export const CommandMenuContextProviderContent = ({
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi]);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
return (
<CommandMenuContext.Provider
@@ -3,10 +3,7 @@ import { Suspense, lazy } from 'react';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { CommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/CommandComponentInstanceContext';
import { isHeadlessFrontComponentCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessFrontComponentCommandContextApi';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
@@ -27,34 +24,18 @@ export const HeadlessFrontComponentRendererEngineCommand = () => {
);
}
const objectNameSingular = context.objectMetadataItem?.nameSingular;
const recordId =
context.selectedRecords.length === 1
? context.selectedRecords[0].id
: undefined;
// TODO: Remove layout rendering provider once we have refactored FrontComponentRenderer to have one headless renderer and a standard renderer
return (
<Suspense fallback={null}>
<LayoutRenderingProvider
value={{
targetRecordIdentifier:
isDefined(objectNameSingular) && isDefined(recordId)
? {
id: recordId,
targetObjectNameSingular: objectNameSingular,
}
: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInSidePanel: false,
}}
>
<FrontComponentRenderer
frontComponentId={context.frontComponentId}
commandMenuItemId={commandMenuItemId}
/>
</LayoutRenderingProvider>
<FrontComponentRenderer
frontComponentId={context.frontComponentId}
commandMenuItemId={commandMenuItemId}
recordId={recordId}
/>
</Suspense>
);
};
@@ -9,8 +9,6 @@ import {
} from '~/generated-metadata/graphql';
const mockFindOneWorkflowVersion = jest.fn();
const mockEnqueueWarningSnackBar = jest.fn();
const mockBuildTriggerWorkflowVersionPayloads = jest.fn();
jest.mock('@/object-record/hooks/useLazyFindOneRecord', () => ({
useLazyFindOneRecord: () => ({
@@ -18,20 +16,6 @@ jest.mock('@/object-record/hooks/useLazyFindOneRecord', () => ({
}),
}));
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
useSnackBar: () => ({
enqueueWarningSnackBar: mockEnqueueWarningSnackBar,
}),
}));
jest.mock(
'@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads',
() => ({
buildTriggerWorkflowVersionPayloads: (...args: unknown[]) =>
mockBuildTriggerWorkflowVersionPayloads(...args),
}),
);
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
@@ -58,7 +42,7 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
jest.clearAllMocks();
});
it('should return enriched context API with workflow info and payloads', async () => {
it('should return enriched context with workflow metadata', async () => {
const store = createStore();
const wrapper = getWrapper(store);
@@ -75,9 +59,6 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
},
);
const expectedPayloads = [{ recordId: 'rec-1' }];
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue(expectedPayloads);
const { result } = renderHook(
() =>
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
@@ -94,7 +75,8 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
{
headlessEngineCommandContextApi,
workflowVersionId: 'wf-version-1',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: 'obj-1',
},
);
});
@@ -103,7 +85,9 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
...headlessEngineCommandContextApi,
workflowId: 'workflow-1',
workflowVersionId: 'wf-version-1',
payloads: expectedPayloads,
trigger: { type: 'MANUAL' },
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: 'obj-1',
});
});
@@ -134,99 +118,4 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
expect(enrichedResult).toBeUndefined();
});
it('should return undefined for RECORD_SELECTION type when payloads are empty', async () => {
const store = createStore();
const wrapper = getWrapper(store);
const workflowVersionRecord = {
id: 'wf-version-1',
workflowId: 'workflow-1',
trigger: { type: 'MANUAL' },
__typename: 'WorkflowVersion' as const,
};
mockFindOneWorkflowVersion.mockImplementation(
async ({ onCompleted }: { onCompleted: (data: unknown) => void }) => {
onCompleted(workflowVersionRecord);
},
);
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue([]);
const { result } = renderHook(
() =>
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
{ wrapper },
);
let enrichedResult: unknown;
await act(async () => {
enrichedResult =
await result.current.enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(
{
headlessEngineCommandContextApi: buildBaseContextApi(),
workflowVersionId: 'wf-version-1',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
},
);
});
expect(enrichedResult).toBeUndefined();
});
it('should show warning snackbar when selected records exceed QUERY_MAX_RECORDS', async () => {
const store = createStore();
const wrapper = getWrapper(store);
const workflowVersionRecord = {
id: 'wf-version-1',
workflowId: 'workflow-1',
trigger: { type: 'MANUAL' },
__typename: 'WorkflowVersion' as const,
};
mockFindOneWorkflowVersion.mockImplementation(
async ({ onCompleted }: { onCompleted: (data: unknown) => void }) => {
onCompleted(workflowVersionRecord);
},
);
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue([
{ recordId: 'rec-1' },
]);
const selectedRecordIds = Array.from({ length: 201 }, (_, index) =>
String(index),
);
const headlessEngineCommandContextApi = buildBaseContextApi({
targetedRecordsRule: { mode: 'selection', selectedRecordIds },
});
const { result } = renderHook(
() =>
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
{ wrapper },
);
await act(async () => {
await result.current.enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(
{
headlessEngineCommandContextApi,
workflowVersionId: 'wf-version-1',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
},
);
});
expect(mockEnqueueWarningSnackBar).toHaveBeenCalledWith(
expect.objectContaining({
options: {
dedupeKey: 'workflow-manual-trigger-selection-limit',
},
}),
);
});
});
@@ -84,7 +84,8 @@ describe('useMountCommand', () => {
...baseContextApi,
workflowId: 'workflow-1',
workflowVersionId: 'wf-version-1',
payloads: [{ recordId: 'rec-1' }],
trigger: { type: 'MANUAL' },
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
};
mockEnrichFn.mockResolvedValue(enrichedState);
@@ -4,27 +4,18 @@ import {
type HeadlessCommandContextApi,
type HeadlessEngineCommandContextApi,
} from '@/command-menu-item/engine-command/types/HeadlessCommandContextApi';
import { buildTriggerWorkflowVersionPayloads } from '@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useLazyFindOneRecord } from '@/object-record/hooks/useLazyFindOneRecord';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
CommandMenuItemAvailabilityType as CommandMenuItemAvailabilityTypeEnum,
type CommandMenuItemAvailabilityType,
} from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
type WorkflowVersionRecord = Pick<
WorkflowVersion,
'id' | 'workflowId' | 'trigger' | '__typename'
>;
type BuildTriggerWorkflowVersionCommandStateParams = {
type EnrichParams = {
headlessEngineCommandContextApi: HeadlessEngineCommandContextApi;
workflowVersionId: string;
availabilityType: CommandMenuItemAvailabilityType;
@@ -33,9 +24,6 @@ type BuildTriggerWorkflowVersionCommandStateParams = {
export const useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation =
() => {
const store = useStore();
const { enqueueWarningSnackBar } = useSnackBar();
const { findOneRecord: findOneWorkflowVersion } =
useLazyFindOneRecord<WorkflowVersionRecord>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
@@ -65,76 +53,23 @@ export const useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInforma
workflowVersionId,
availabilityType,
availabilityObjectMetadataId,
}: BuildTriggerWorkflowVersionCommandStateParams): Promise<
HeadlessCommandContextApi | undefined
> => {
}: EnrichParams): Promise<HeadlessCommandContextApi | undefined> => {
const workflowVersion = await fetchWorkflowVersion(workflowVersionId);
if (!isDefined(workflowVersion)) {
return undefined;
}
if (
headlessEngineCommandContextApi.targetedRecordsRule.mode ===
'exclusion'
) {
enqueueWarningSnackBar({
message: t`Running workflows on all records is not yet supported. Please select records manually.`,
options: {
dedupeKey: 'workflow-manual-trigger-select-all-not-supported',
},
});
return undefined;
}
const selectedRecordIds =
headlessEngineCommandContextApi.targetedRecordsRule
.selectedRecordIds;
if (selectedRecordIds.length > QUERY_MAX_RECORDS) {
const selectedCountFormatted =
selectedRecordIds.length.toLocaleString();
const limitFormatted = QUERY_MAX_RECORDS.toLocaleString();
enqueueWarningSnackBar({
message: t`You selected ${selectedCountFormatted} records but manual triggers can run on at most ${limitFormatted} records at once. Only the first ${limitFormatted} records will be processed.`,
options: {
dedupeKey: 'workflow-manual-trigger-selection-limit',
},
});
}
const objectMetadataItems = store.get(
objectMetadataItemsSelector.atom,
);
const payloads = buildTriggerWorkflowVersionPayloads({
store,
trigger: workflowVersion.trigger,
availabilityType,
availabilityObjectMetadataId,
objectMetadataItems,
selectedRecordIds,
});
if (
availabilityType ===
CommandMenuItemAvailabilityTypeEnum.RECORD_SELECTION &&
!isNonEmptyArray(payloads)
) {
return undefined;
}
return {
...headlessEngineCommandContextApi,
workflowId: workflowVersion.workflowId,
workflowVersionId: workflowVersion.id,
payloads,
trigger: workflowVersion.trigger,
availabilityType,
availabilityObjectMetadataId,
};
},
[store, fetchWorkflowVersion, enqueueWarningSnackBar],
[fetchWorkflowVersion],
);
return {
@@ -1,23 +1,63 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { buildTriggerWorkflowVersionPayloads } from '@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads';
import { isHeadlessTriggerWorkflowVersionCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessTriggerWorkflowVersionCommandContextApi';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import {
CoreObjectNameSingular,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
export const TriggerWorkflowVersionEngineCommand = () => {
const mountedCommandState = useHeadlessCommandContextApi();
const store = useStore();
if (!isHeadlessTriggerWorkflowVersionCommandContextApi(mountedCommandState)) {
throw new Error(
'TriggerWorkflowVersionEngineCommand requires a workflow trigger context',
);
}
const noMatchFilter: RecordGqlOperationFilter = { id: { in: [] } };
const { fetchAllRecords } = useLazyFetchAllRecords({
objectNameSingular:
mountedCommandState.objectMetadataItem?.nameSingular ??
CoreObjectNameSingular.Person,
filter: mountedCommandState.graphqlFilter ?? noMatchFilter,
limit: DEFAULT_QUERY_PAGE_SIZE,
});
const { runWorkflowVersion } = useRunWorkflowVersion();
const execute = useCallback(async () => {
if (
!isHeadlessTriggerWorkflowVersionCommandContextApi(mountedCommandState)
) {
return;
let selectedRecords: ObjectRecord[];
if (mountedCommandState.targetedRecordsRule.mode === 'selection') {
selectedRecords = mountedCommandState.selectedRecords;
} else {
selectedRecords = await fetchAllRecords();
}
if (!isNonEmptyArray(mountedCommandState.payloads)) {
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const payloads = buildTriggerWorkflowVersionPayloads({
trigger: mountedCommandState.trigger,
availabilityType: mountedCommandState.availabilityType,
availabilityObjectMetadataId:
mountedCommandState.availabilityObjectMetadataId,
objectMetadataItems,
selectedRecords,
});
if (!isNonEmptyArray(payloads)) {
await runWorkflowVersion({
workflowId: mountedCommandState.workflowId,
workflowVersionId: mountedCommandState.workflowVersionId,
@@ -26,14 +66,14 @@ export const TriggerWorkflowVersionEngineCommand = () => {
return;
}
for (const payload of mountedCommandState.payloads) {
for (const payload of payloads) {
await runWorkflowVersion({
workflowId: mountedCommandState.workflowId,
workflowVersionId: mountedCommandState.workflowVersionId,
payload,
});
}
}, [runWorkflowVersion, mountedCommandState]);
}, [mountedCommandState, fetchAllRecords, runWorkflowVersion, store]);
return <HeadlessEngineCommandWrapperEffect execute={execute} />;
};
@@ -1,11 +1,13 @@
import { type ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
import {
type Nullable,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import {
type CommandMenuItemAvailabilityType,
type EngineComponentKey,
type CommandMenuItemPayload,
} from '~/generated-metadata/graphql';
@@ -31,7 +33,9 @@ export type HeadlessTriggerWorkflowVersionCommandContextApi =
HeadlessEngineCommandContextApi & {
workflowId: string;
workflowVersionId: string;
payloads: Record<string, any>[];
trigger: WorkflowTrigger | null;
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
};
export type HeadlessCommandContextApi =
@@ -1,6 +1,9 @@
import { type HeadlessCommandContextApi } from '@/command-menu-item/engine-command/types/HeadlessCommandContextApi';
import { isHeadlessTriggerWorkflowVersionCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessTriggerWorkflowVersionCommandContextApi';
import { EngineComponentKey } from '~/generated-metadata/graphql';
import {
CommandMenuItemAvailabilityType,
EngineComponentKey,
} from '~/generated-metadata/graphql';
const baseContextApi: HeadlessCommandContextApi = {
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
@@ -20,7 +23,8 @@ describe('isHeadlessTriggerWorkflowVersionCommandContextApi', () => {
...baseContextApi,
workflowId: 'wf-1',
workflowVersionId: 'wfv-1',
payloads: [],
trigger: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
};
expect(
@@ -1,10 +1,7 @@
import type { Store } from 'jotai/vanilla/store';
import { isBulkRecordsManualTrigger } from '@/command-menu-item/record/utils/isBulkRecordsManualTrigger';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import {
type CommandMenuItemAvailabilityType,
@@ -12,44 +9,31 @@ import {
} from '~/generated-metadata/graphql';
export const buildTriggerWorkflowVersionPayloads = ({
store,
trigger,
availabilityType,
availabilityObjectMetadataId,
objectMetadataItems,
selectedRecordIds,
selectedRecords,
}: {
store: Store;
trigger: WorkflowTrigger | null;
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
objectMetadataItems: EnrichedObjectMetadataItem[];
selectedRecordIds: string[];
selectedRecords: ObjectRecord[];
}): Record<string, any>[] => {
const payloads: Record<string, any>[] = [];
switch (availabilityType) {
case CommandMenuItemAvailabilityTypeEnum.RECORD_SELECTION: {
if (selectedRecordIds.length === 0) {
if (selectedRecords.length === 0) {
return payloads;
}
const limitedSelectedRecordIds = selectedRecordIds.slice(
0,
QUERY_MAX_RECORDS,
);
const objectMetadataItem = objectMetadataItems.find(
(metadata) => metadata.id === availabilityObjectMetadataId,
);
if (isDefined(trigger) && isBulkRecordsManualTrigger(trigger)) {
const selectedRecords = limitedSelectedRecordIds
.map((recordId) =>
store.get(recordStoreFamilyState.atomFamily(recordId)),
)
.filter(isDefined);
if (isDefined(objectMetadataItem)) {
payloads.push({
[objectMetadataItem.namePlural]: selectedRecords,
@@ -59,15 +43,7 @@ export const buildTriggerWorkflowVersionPayloads = ({
return payloads;
}
for (const selectedRecordId of limitedSelectedRecordIds) {
const selectedRecord = store.get(
recordStoreFamilyState.atomFamily(selectedRecordId),
);
if (!isDefined(selectedRecord)) {
continue;
}
for (const selectedRecord of selectedRecords) {
payloads.push(selectedRecord);
}
@@ -28,5 +28,6 @@ export const COMMAND_MENU_ITEM_FRAGMENT = gql`
conditionalAvailabilityExpression
availabilityType
availabilityObjectMetadataId
pageLayoutId
}
`;
@@ -0,0 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export const doesCommandMenuItemMatchPageLayoutId =
(pageLayoutId: string | null) => (item: CommandMenuItemFieldsFragment) =>
!isDefined(item.pageLayoutId) || item.pageLayoutId === pageLayoutId;
@@ -18,11 +18,13 @@ import { FindOneFrontComponentDocument } from '~/generated-metadata/graphql';
type FrontComponentRendererProps = {
frontComponentId: string;
commandMenuItemId?: string;
recordId?: string;
};
export const FrontComponentRenderer = ({
frontComponentId,
commandMenuItemId,
recordId,
}: FrontComponentRendererProps) => {
const { colorScheme } = useContext(ThemeContext);
const { enqueueErrorSnackBar } = useSnackBar();
@@ -33,7 +35,11 @@ export const FrontComponentRenderer = ({
);
const { executionContext, frontComponentHostCommunicationApi } =
useFrontComponentExecutionContext({ frontComponentId, commandMenuItemId });
useFrontComponentExecutionContext({
frontComponentId,
commandMenuItemId,
recordId,
});
const handleError = useCallback(
(error?: Error) => {
@@ -17,9 +17,6 @@ const mockCloseSidePanelMenu = jest.fn();
const mockSetCommandMenuItemProgress = jest.fn();
let mockCurrentUser: { id: string } | null = { id: 'user-123' };
let mockTargetRecordIdentifier: { id: string } | undefined = {
id: 'record-456',
};
jest.mock('~/hooks/useNavigateApp', () => ({
useNavigateApp: () => mockNavigateApp,
@@ -86,12 +83,6 @@ jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState', () => ({
useSetAtomFamilyState: () => mockSetCommandMenuItemProgress,
}));
jest.mock('@/ui/layout/contexts/LayoutRenderingContext', () => ({
useLayoutRenderingContext: () => ({
targetRecordIdentifier: mockTargetRecordIdentifier,
}),
}));
const FRONT_COMPONENT_ID = 'fc-test-id';
const COMMAND_MENU_ITEM_ID = 'cmd-item-1';
@@ -99,7 +90,6 @@ describe('useFrontComponentExecutionContext', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCurrentUser = { id: 'user-123' };
mockTargetRecordIdentifier = { id: 'record-456' };
});
describe('executionContext', () => {
@@ -107,6 +97,7 @@ describe('useFrontComponentExecutionContext', () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
recordId: 'record-456',
}),
);
@@ -129,9 +120,7 @@ describe('useFrontComponentExecutionContext', () => {
expect(result.current.executionContext.userId).toBeNull();
});
it('should return null recordId when no target record', () => {
mockTargetRecordIdentifier = undefined;
it('should return null recordId when no recordId provided', () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
@@ -14,7 +14,6 @@ import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
@@ -24,9 +23,11 @@ import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useFrontComponentExecutionContext = ({
frontComponentId,
commandMenuItemId,
recordId,
}: {
frontComponentId: string;
commandMenuItemId?: string;
recordId?: string;
}): {
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
@@ -123,12 +124,10 @@ export const useFrontComponentExecutionContext = ({
}
};
const { targetRecordIdentifier } = useLayoutRenderingContext();
const executionContext: FrontComponentExecutionContext = {
frontComponentId,
userId: currentUser?.id ?? null,
recordId: targetRecordIdentifier?.id ?? null,
recordId: recordId ?? null,
};
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
@@ -3,14 +3,20 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ViewFieldsSearchDropdownSection } from '@/views/components/ViewFieldsSearchDropdownSection';
import { ViewFieldsVisibleDropdownSection } from '@/views/components/ViewFieldsVisibleDropdownSection';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { IconChevronLeft, IconEyeOff } from 'twenty-ui/display';
import { MenuItemNavigate } from 'twenty-ui/navigation';
export const ObjectOptionsDropdownFieldsContent = () => {
const { t } = useLingui();
const [searchInput, setSearchInput] = useState('');
const { onContentChange, resetContent } = useObjectOptionsDropdown();
return (
@@ -25,15 +31,28 @@ export const ObjectOptionsDropdownFieldsContent = () => {
>
{t`Fields`}
</DropdownMenuHeader>
<ViewFieldsVisibleDropdownSection />
<DropdownMenuSearchInput
autoFocus
value={searchInput}
placeholder={t`Search fields`}
onChange={(event) => setSearchInput(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<MenuItemNavigate
onClick={() => onContentChange('hiddenFields')}
LeftIcon={IconEyeOff}
text={t`Hidden Fields`}
/>
</DropdownMenuItemsContainer>
{isNonEmptyString(searchInput) ? (
<ViewFieldsSearchDropdownSection searchInput={searchInput} />
) : (
<>
<ViewFieldsVisibleDropdownSection />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<MenuItemNavigate
onClick={() => onContentChange('hiddenFields')}
LeftIcon={IconEyeOff}
text={t`Hidden Fields`}
/>
</DropdownMenuItemsContainer>
</>
)}
</DropdownContent>
);
};
@@ -95,7 +95,9 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => {
store.set(contextStoreIsFullTabWidgetInEditModeState, value);
store.set(currentPageLayoutIdState.atom, value ? pageLayoutId : null);
if (value) {
store.set(currentPageLayoutIdState.atom, pageLayoutId);
}
},
[
isDashboardInEditModeState,
@@ -7,6 +7,7 @@ import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutIn
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay';
import { isWidgetConfigurationOfType } from '@/side-panel/pages/page-layout/utils/isWidgetConfigurationOfType';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
const StyledContainer = styled.div<{ isInEditMode: boolean }>`
height: 100%;
@@ -29,6 +30,7 @@ export const FrontComponentWidgetRenderer = ({
widget,
}: FrontComponentWidgetRendererProps) => {
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const { targetRecordIdentifier } = useLayoutRenderingContext();
const configuration = widget.configuration;
@@ -44,7 +46,10 @@ export const FrontComponentWidgetRenderer = ({
return (
<StyledContainer isInEditMode={isPageLayoutInEditMode}>
<Suspense fallback={null}>
<FrontComponentRenderer frontComponentId={frontComponentId} />
<FrontComponentRenderer
frontComponentId={frontComponentId}
recordId={targetRecordIdentifier?.id}
/>
</Suspense>
</StyledContainer>
);
@@ -2,10 +2,8 @@ import { Suspense, lazy } from 'react';
import { viewableFrontComponentIdComponentState } from '@/side-panel/pages/front-component/states/viewableFrontComponentIdComponentState';
import { viewableFrontComponentRecordContextComponentState } from '@/side-panel/pages/front-component/states/viewableFrontComponentRecordContextComponentState';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
@@ -27,22 +25,11 @@ export const SidePanelFrontComponentPage = () => {
}
return (
<LayoutRenderingProvider
value={{
targetRecordIdentifier: isDefined(viewableFrontComponentRecordContext)
? {
id: viewableFrontComponentRecordContext.recordId,
targetObjectNameSingular:
viewableFrontComponentRecordContext.objectNameSingular,
}
: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInSidePanel: true,
}}
>
<Suspense fallback={null}>
<FrontComponentRenderer frontComponentId={viewableFrontComponentId} />
</Suspense>
</LayoutRenderingProvider>
<Suspense fallback={null}>
<FrontComponentRenderer
frontComponentId={viewableFrontComponentId}
recordId={viewableFrontComponentRecordContext?.recordId}
/>
</Suspense>
);
};
@@ -293,6 +293,42 @@ describe('useAddressAutocomplete', () => {
);
});
it('should close dropdown and not throw when autocomplete query fails', async () => {
mockGetPlaceAutocompleteData.mockRejectedValue(
new Error('Network error'),
);
const { result } = renderHook(() => useAddressAutocomplete());
await act(async () => {
await result.current.getAutocompletePlaceData('ü', 'token123');
});
expect(mockCloseDropdown).toHaveBeenCalled();
expect(mockOpenDropdown).not.toHaveBeenCalled();
});
it('should close dropdown and not throw when place details query fails', async () => {
const mockOnChange = jest.fn();
mockGetPlaceDetailsData.mockRejectedValue(
new Error('Network error'),
);
const { result } = renderHook(() => useAddressAutocomplete(mockOnChange));
await act(async () => {
await result.current.autoFillInputsFromPlaceDetails(
'place123',
'token123',
'123 Main St',
);
});
expect(mockCloseDropdown).toHaveBeenCalled();
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should handle address autocomplete with country and isFieldCity parameters', async () => {
mockGetPlaceAutocompleteData.mockResolvedValue([
{ text: 'Boston, MA', placeId: 'place1' },
@@ -47,12 +47,20 @@ export const useAddressAutocomplete = (
country?: string,
isFieldCity?: boolean,
) => {
const placeAutocompleteData = await getPlaceAutocompleteData(
address,
token,
country,
isFieldCity,
);
let placeAutocompleteData: PlaceAutocompleteResult[] | undefined;
try {
placeAutocompleteData = await getPlaceAutocompleteData(
address,
token,
country,
isFieldCity,
);
} catch {
closeDropdownOfAutocomplete();
return;
}
const newData = placeAutocompleteData?.map((data) => ({
text: data.text,
@@ -76,7 +84,16 @@ export const useAddressAutocomplete = (
addressStreet1?: string,
internalValue?: FieldAddressDraftValue,
) => {
const placeData = await getPlaceDetailsData(placeId, token);
let placeData: Awaited<ReturnType<typeof getPlaceDetailsData>>;
try {
placeData = await getPlaceDetailsData(placeId, token);
} catch {
closeDropdownOfAutocomplete();
return;
}
const countryName = findCountryNameByCountryCode(placeData?.country);
const updatedAddress = {
@@ -0,0 +1,101 @@
import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { useChangeRecordFieldVisibility } from '@/object-record/record-field/hooks/useChangeRecordFieldVisibility';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { ViewType } from '@/views/types/ViewType';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { IconEye, IconEyeOff, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
type ViewFieldsSearchDropdownSectionProps = {
searchInput: string;
};
export const ViewFieldsSearchDropdownSection = ({
searchInput,
}: ViewFieldsSearchDropdownSectionProps) => {
const { t } = useLingui();
const { getIcon } = useIcons();
const { viewType, objectMetadataItem, recordIndexId } = useContext(
ObjectOptionsDropdownContext,
);
const { changeRecordFieldVisibility } =
useChangeRecordFieldVisibility(recordIndexId);
const { handleBoardFieldVisibilityChange } = useObjectOptionsForBoard({
objectNameSingular: objectMetadataItem.nameSingular,
recordBoardId: recordIndexId,
viewBarId: recordIndexId,
});
const handleChangeFieldVisibility =
viewType === ViewType.KANBAN
? handleBoardFieldVisibilityChange
: changeRecordFieldVisibility;
const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
objectMetadataItem,
});
const visibleRecordFields = useAtomComponentSelectorValue(
visibleRecordFieldsComponentSelector,
);
const visibleFieldIds = new Set(
visibleRecordFields.map((recordField) => recordField.fieldMetadataItemId),
);
const fieldMetadataItemLabelIdentifier =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const filteredFields = activeFieldMetadataItems.filter(
(fieldMetadataItem) => {
return fieldMetadataItem.label
.toLowerCase()
.includes(searchInput.toLowerCase());
},
);
return (
<DropdownMenuItemsContainer>
{filteredFields.length > 0 ? (
filteredFields.map((fieldMetadataItem) => {
const isVisible = visibleFieldIds.has(fieldMetadataItem.id);
const isLabelIdentifier =
fieldMetadataItem.id === fieldMetadataItemLabelIdentifier?.id;
return (
<MenuItem
key={fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
iconButtons={
isLabelIdentifier
? undefined
: [
{
Icon: isVisible ? IconEyeOff : IconEye,
onClick: () =>
handleChangeFieldVisibility({
fieldMetadataId: fieldMetadataItem.id,
isVisible: !isVisible,
}),
},
]
}
text={fieldMetadataItem.label}
/>
);
})
) : (
<MenuItem text={t`No results`} />
)}
</DropdownMenuItemsContainer>
);
};
@@ -1,20 +1,38 @@
import { getFunctionInputFromInputSchema } from 'twenty-shared/workflow';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput';
import { LogicFunctionExecutionResult } from '@/logic-functions/components/LogicFunctionExecutionResult';
import { LogicFunctionLogs } from '@/logic-functions/components/LogicFunctionLogs';
import { LogicFunctionTestInputInitEffect } from '@/logic-functions/components/LogicFunctionTestInputInitEffect';
import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction';
import { useGetOneLogicFunction } from '@/logic-functions/hooks/useGetOneLogicFunction';
import { InputLabel } from '@/ui/input/components/InputLabel';
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 { type WorkflowLogicFunctionAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowEditActionCodeFields } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput';
import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/setNestedValue';
import { WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/logic-function-action/constants/WorkflowLogicFunctionActionTabListComponentId';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isObject } from '@sniptt/guards';
import { useMemo } from 'react';
import { getOutputSchemaFromValue } from 'twenty-shared/logic-function';
import { isDefined } from 'twenty-shared/utils';
import { Callout } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import { getFunctionInputFromInputSchema } from 'twenty-shared/workflow';
import {
Callout,
IconPlayerPlay,
IconSettingsAutomation,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
const INPUT_TAB_ID = 'input';
const TEST_TAB_ID = 'test';
const StyledContainer = styled.div`
display: flex;
@@ -22,6 +40,17 @@ const StyledContainer = styled.div`
gap: ${themeCssVariables.spacing[4]};
`;
const StyledTabListContainer = styled.div`
background-color: ${themeCssVariables.background.secondary};
padding-left: ${themeCssVariables.spacing[2]};
`;
const StyledResultContainer = styled.div`
display: flex;
flex-direction: column;
position: relative;
`;
type WorkflowEditActionLogicFunctionProps = {
action: WorkflowLogicFunctionAction;
actionOptions:
@@ -46,6 +75,11 @@ export const WorkflowEditActionLogicFunction = ({
id: logicFunctionId,
});
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID,
);
const functionInput = useMemo(() => {
const toolInputSchema = logicFunction?.toolInputSchema;
@@ -86,6 +120,34 @@ export const WorkflowEditActionLogicFunction = ({
500,
);
const updateOutputSchemaFromTestResult = (testResult: object) => {
if (actionOptions.readonly === true) {
return;
}
const newOutputSchema = getOutputSchemaFromValue(testResult);
updateAction({
...action,
settings: { ...action.settings, outputSchema: newOutputSchema },
});
};
const {
executeLogicFunction,
isExecuting,
logicFunctionTestData,
updateLogicFunctionInput,
} = useExecuteLogicFunction({
logicFunctionId,
callback: updateOutputSchemaFromTestResult,
});
const testInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: functionInput,
oldInput: logicFunctionTestData.input,
});
const handleInputChange = (value: unknown, path: string[]) => {
const updatedFunctionInput = setNestedValue(functionInput, path, value);
@@ -100,34 +162,115 @@ export const WorkflowEditActionLogicFunction = ({
});
};
const handleTestInputChange = (value: unknown, path: string[]) => {
if (actionOptions.readonly === true) {
return;
}
const updatedTestFunctionInput = setNestedValue(testInput, path, value);
updateLogicFunctionInput(updatedTestFunctionInput);
};
const handleTestFunction = async () => {
if (actionOptions.readonly === true) {
return;
}
await executeLogicFunction();
};
if (loading) {
return null;
}
const hasInputFields = Object.keys(functionInput).length > 0;
const tabs = [
{
id: INPUT_TAB_ID,
title: t`Input`,
Icon: IconSettingsAutomation,
},
{
id: TEST_TAB_ID,
title: t`Test`,
Icon: IconPlayerPlay,
},
];
return (
<>
<LogicFunctionTestInputInitEffect logicFunctionId={logicFunctionId} />
<StyledTabListContainer>
<TabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={
WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID
}
/>
</StyledTabListContainer>
<WorkflowStepBody>
<StyledContainer>
{hasInputFields ? (
{activeTabId === TEST_TAB_ID ? (
<>
<WorkflowEditActionCodeFields
functionInput={functionInput}
functionInput={testInput}
onInputChange={handleTestInputChange}
readonly={actionOptions.readonly}
onInputChange={handleInputChange}
VariablePicker={WorkflowVariablePicker}
fullWidth
/>
) : (
<Callout
variant={'neutral'}
title={t`No input fields for this action`}
description={t`You can see the function logic in your application settings.`}
/>
)}
</StyledContainer>
<StyledResultContainer>
<InputLabel>{t`Result`}</InputLabel>
<LogicFunctionExecutionResult
logicFunctionTestData={logicFunctionTestData}
isTesting={isExecuting}
/>
</StyledResultContainer>
{logicFunctionTestData.output.logs.length > 0 && (
<StyledResultContainer>
<LogicFunctionLogs
componentInstanceId={`workflow-edit-action-logs-${action.id}`}
value={isExecuting ? '' : logicFunctionTestData.output.logs}
/>
</StyledResultContainer>
)}
</>
) : (
<StyledContainer>
{hasInputFields ? (
<WorkflowEditActionCodeFields
functionInput={functionInput}
readonly={actionOptions.readonly}
onInputChange={handleInputChange}
VariablePicker={WorkflowVariablePicker}
fullWidth
/>
) : (
<Callout
variant={'neutral'}
title={t`No input fields for this action`}
description={t`You can see the function logic in your application settings.`}
/>
)}
</StyledContainer>
)}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
{!actionOptions.readonly && (
<WorkflowStepFooter
stepId={action.id}
additionalActions={
activeTabId === TEST_TAB_ID
? [
<WorkflowStepCmdEnterButton
title={t`Test`}
onClick={handleTestFunction}
disabled={isExecuting}
/>,
]
: []
}
/>
)}
</>
);
};
@@ -0,0 +1,2 @@
export const WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID =
'workflow-logic-function-action-tab-list-component-id';
@@ -61,6 +61,15 @@ describe('computeStepOutputSchema', () => {
expect(result).toBeUndefined();
});
it('should return undefined for LOGIC_FUNCTION step type', () => {
const result = computeStepOutputSchema({
step: { type: 'LOGIC_FUNCTION', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
});
describe('DATABASE_EVENT trigger', () => {
@@ -466,6 +475,10 @@ describe('shouldComputeOutputSchemaOnFrontend', () => {
expect(shouldComputeOutputSchemaOnFrontend('ITERATOR')).toBe(false);
});
it('should return false for LOGIC_FUNCTION', () => {
expect(shouldComputeOutputSchemaOnFrontend('LOGIC_FUNCTION')).toBe(false);
});
it('should return true for DATABASE_EVENT', () => {
expect(shouldComputeOutputSchemaOnFrontend('DATABASE_EVENT')).toBe(true);
});
@@ -17,6 +17,7 @@ const PERSISTED_OUTPUT_SCHEMA_TYPES = [
'AI_AGENT',
'CODE',
'HTTP_REQUEST',
'LOGIC_FUNCTION',
'WEBHOOK',
'ITERATOR',
];
@@ -24,6 +24,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { UpgradeStatusCommand } from 'src/engine/core-modules/upgrade/commands/upgrade-status.command';
import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
@@ -86,6 +87,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
EnterpriseKeyValidationCronCommand,
GenerateApiKeyCommand,
EnforceUsageCapCronCommand,
UpgradeStatusCommand,
],
})
export class DatabaseCommandModule {}
@@ -9,6 +9,7 @@ import { TWENTY_PREVIOUS_VERSIONS } from 'src/engine/core-modules/upgrade/consta
import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service';
import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
type RunInstanceCommandsOptions = {
@@ -30,6 +31,7 @@ export class RunInstanceCommandsCommand extends CommandRunner {
private readonly dataSource: DataSource,
private readonly workspaceVersionService: WorkspaceVersionService,
private readonly upgradeCommandRegistryService: UpgradeCommandRegistryService,
private readonly upgradeSequenceReaderService: UpgradeSequenceReaderService,
private readonly instanceUpgradeService: InstanceCommandRunnerService,
private readonly upgradeMigrationService: UpgradeMigrationService,
) {
@@ -65,31 +67,26 @@ export class RunInstanceCommandsCommand extends CommandRunner {
const activeOrSuspendedWorkspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
for (const {
command,
name,
} of this.upgradeCommandRegistryService.getCrossUpgradeSupportedFastInstanceCommands()) {
const result = await this.instanceUpgradeService.runFastInstanceCommand(
{
command,
name,
},
);
const sequence = this.upgradeSequenceReaderService.getUpgradeSequence();
if (result.status === 'failed') {
throw result.error;
for (const step of sequence) {
if (step.kind === 'fast-instance') {
const result =
await this.instanceUpgradeService.runFastInstanceCommand({
command: step.command,
name: step.name,
});
if (result.status === 'failed') {
throw result.error;
}
}
}
if (options.includeSlow) {
for (const {
command,
name,
} of this.upgradeCommandRegistryService.getCrossUpgradeSupportedSlowInstanceCommands()) {
if (step.kind === 'slow-instance' && options.includeSlow) {
const result =
await this.instanceUpgradeService.runSlowInstanceCommand({
command,
name,
command: step.command,
name: step.name,
skipDataMigration: activeOrSuspendedWorkspaceIds.length === 0,
});
@@ -238,6 +238,8 @@ export class RefactorNavigationCommandsCommand extends ActiveOrSuspendedWorkspac
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
});
@@ -0,0 +1,33 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.23.0', 1776168404836)
export class AddPageLayoutIdToCommandMenuItemFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ADD "pageLayoutId" uuid',
);
await queryRunner.query(
'CREATE INDEX "IDX_COMMAND_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID" ON "core"."commandMenuItem" ("pageLayoutId", "workspaceId") ',
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "FK_8577be6253969364b6725b807b4" FOREIGN KEY ("pageLayoutId") REFERENCES "core"."pageLayout"("id") ON DELETE CASCADE ON UPDATE NO ACTION',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT "FK_8577be6253969364b6725b807b4"',
);
await queryRunner.query(
'DROP INDEX "core"."IDX_COMMAND_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID"',
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" DROP COLUMN "pageLayoutId"',
);
}
}
@@ -13,6 +13,7 @@ import { AddCreditBalanceToBillingCustomerFastInstanceCommand } from 'src/databa
import { BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-slow-1775758621018-backfill-workspace-id-on-indirect-entities';
import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1785000000000-drop-workspace-version-column';
import { AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776090711153-add-global-object-context-to-command-menu-item-availability-type';
import { AddPageLayoutIdToCommandMenuItemFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776168404836-add-page-layout-id-to-command-menu-item';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -28,4 +29,5 @@ export const INSTANCE_COMMANDS = [
AddCreditBalanceToBillingCustomerFastInstanceCommand,
DropWorkspaceVersionColumnFastInstanceCommand,
AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand,
AddPageLayoutIdToCommandMenuItemFastInstanceCommand,
];
@@ -1 +1 @@
export const MCP_PROTOCOL_VERSION = '2024-11-05';
export const MCP_PROTOCOL_VERSION = '2025-06-18';

Some files were not shown because too many files have changed in this diff Show More