Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code d914039d45 fix: gracefully skip unknown fields in SSE optimistic record updates
https://sonarly.com/issue/18992?type=bug

When an SSE event delivers a record update containing custom fields that are not yet present in the frontend's cached object metadata, `computeOptimisticRecordFromInput` throws a fatal error that breaks SSE event processing for the session.

Fix: Replaced `throw new Error(...)` with `console.warn(...)` in `computeOptimisticRecordFromInput` when unknown fields are detected in the record input.

**Why this is safe:** The function's main for-loop (line 80) iterates only over `objectMetadataItem.fields`, not over `recordInput` keys. Unknown fields in `recordInput` are naturally skipped by the loop and never processed. The throw was a defensive assertion added in commit `29745c6756` for mutation-triggered optimistic updates where the frontend controls the input. When SSE real-time events were added, this function started receiving server-controlled input that may include custom fields not yet in the frontend's stale metadata cache.

**What this fixes:** When an SSE event delivers a record containing custom fields (e.g., `mail`, `pitched`, `media`, `number`, `businessCategory` on the `company` object) and the frontend's metadata cache hasn't refreshed yet, the function now logs a warning and continues processing known fields instead of crashing the entire SSE event stream handler.

Updated the test from asserting a throw to asserting a `console.warn` call and verifying that known fields are still correctly processed while unknown fields are skipped.
2026-03-27 12:59:17 +00:00
c2b058a6a7 fix: use workspace-generated id for core dual-write in message folder save (#19038)
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-27 13:53:30 +01:00
Abdullah.andGitHub 22c9693ce5 First PR to bring in the new twenty website. (#19035)
This PR contains Menu, Hero, TrustedBy, Problem, ThreeCards and Footer
sections of the new website.

Most components in there match the Figma designs, except for two things.
- Zoom levels on 3D illustrations from Endless Tools.
- Menu needs to have the same color as Hero - it's not happening at the
moment since Menu is in the layout, not nested inside pages or Hero.

Images are placeholders (same as Figma).
2026-03-27 13:48:03 +01:00
50ea560e57 Seed company workflow for email upserts (#18909)
@thomtrp

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-27 13:47:07 +01:00
EtienneandGitHub 56056b885d refacto - remove hello-pangea/dnd from navigation-menu-item module (#19033)
Removed all @hello-pangea/dnd imports from the navigation-menu-item/
module by replacing the type bridge layer with a native
NavigationMenuItemDropResult type. The dnd-kit events were already
powering all DnD — hello-pangea was only used as a type contract and one
dead <Droppable> component.
3 files deleted (dead <Droppable> wrapper, DROP_RESULT_OPTIONS shim,
toDropResult bridge), 4 files edited (handler signatures simplified,
bridge removed from orchestrator, utility type narrowed), 1 type
created. Zero behavioral changes, typecheck and tests pass.
2026-03-27 11:23:25 +00:00
Paul RastoinandGitHub db9da3194f Refactor client auto heal (#19032) 2026-03-27 09:23:26 +00:00
BugIsGodandGitHub 68f5e70ade fix: sign file URLs in timeline and file loss on click outside (#19001)
Fixes: #18943
## Problem

  Two bugs related to the Files field:

1. **File loss on click outside**: When `MultiItemFieldInput` was not in
edit mode (input hidden), clicking outside would still call
`validateInputAndComputeUpdatedItems()` with an empty `inputValue` and
`itemToEditIndex = 0`, causing the first file to be silently deleted.
<img width="624" height="332" alt="image"
src="https://github.com/user-attachments/assets/657aff2c-e497-4685-b8b7-fa22aba772f8"
/>


2. **Unsigned file URLs in timeline activity**: FileId, FileName stored
in `timelineActivity.properties.diff` (before/after values) were not
being signed (timeActivity.properties is stored as json in database),
making them inaccessible from the
frontend.

<img width="1092" height="103" alt="image"
src="https://github.com/user-attachments/assets/23d83ea3-4eb9-41ef-a99c-c4515d1cfb35"
/>


 ## Reproduction


https://github.com/user-attachments/assets/e75b842b-5cbb-46e8-a923-ac9df62deb98

## Changes
- `MultiItemFieldInput.tsx`: Wrap the validate + onChange logic in `if
(isInputDisplayed)` so it only runs when the input is actually open.
- `timeline-activity-query-result-getter.handler.ts`: New handler that
iterates over `properties.diff` fields and signs any file arrays found
in `before`/`after` values (call same method:
`fileUrlService.signFileByIdUrl` as table field view
`FilesFieldQueryResultGetterHandler`.
- `common-result-getters.service.ts`: Register the new handler for
`timelineActivity`.


## After 


https://github.com/user-attachments/assets/368c7be1-3101-43a2-ac93-fe1b0d8a7a37
2026-03-27 08:28:30 +00:00
Félix MalfaitandGitHub 08077476f3 fix: remove remaining direct cookie writes that make tokenPair a session cookie on renewal (#19031)
## Summary

- Removes two remaining direct `cookieStorage.setItem('tokenPair', ...)`
calls that were overwriting the Jotai-managed cookie (180-day expiry)
with a session cookie (no expiry) during **token renewal**
- Followup to #18795 which fixed the same issue in `handleSetAuthTokens`
but missed the renewal code paths

## Root cause

Two token renewal paths still had direct cookie writes without
`expires`:

1. **`apollo.factory.ts`** — `attemptTokenRenewal()` fires on every
`UNAUTHENTICATED` GraphQL error after a successful token refresh
2. **`useAgentChat.ts`** — `retryFetchWithRenewedToken()` fires on 401
from the AI chat endpoint

Both called `cookieStorage.setItem('tokenPair', JSON.stringify(tokens))`
without an `expires` attribute, creating a session cookie that overwrote
the Jotai-managed one. This is why the bug was **intermittent after
#18795**: it only appeared after a token renewal, not on fresh login.

The `onTokenPairChange` / `setTokenPair` calls already write through
Jotai's `atomWithStorage` → `createJotaiCookieStorage`, which always
sets `expires: 180 days`.

## Test plan

- Log in to the app
- Wait for a token renewal to occur (or force one by letting the access
token expire)
- Inspect the `tokenPair` cookie in DevTools → Application → Cookies
- Verify the cookie retains an expiration date ~180 days from now (not
"Session")
- Close and reopen the browser — confirm you remain logged in


Made with [Cursor](https://cursor.com)
2026-03-27 08:21:26 +00:00
6f0ac88e20 fix: batch viewGroup mutations sequentially to prevent race conditions (#19027)
## Bug Description

When reordering stages in the Kanban board, the frontend fires all
viewGroup update mutations concurrently via Promise.all, causing race
conditions in the workspace migration runner's cache invalidation,
database contention, and a thundering herd effect that stalls the
server.

## Changes

Changed `usePerformViewGroupAPIPersist` to execute viewGroup update
mutations sequentially instead of concurrently. The `Promise.all`
pattern fired all N mutations simultaneously, each triggering a full
workspace migration runner pipeline (transaction + cache invalidation).
The sequential `for...of` loop ensures each mutation completes
(including its cache invalidation) before the next begins, eliminating
the race condition.

## Related Issue

Fixes #18865

## Testing

This fix addresses the root cause identified in the Sonarly analysis on
the issue. The concurrent mutation pattern was causing:
- PostgreSQL row-level lock contention on viewGroup rows
- Cache thundering herd from repeated invalidation/recomputation cycles
- Server stalls requiring container restarts

The sequential approach ensures proper ordering and prevents these race
conditions.

---------

Co-authored-by: Rayan <rayan@example.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-27 08:13:51 +00:00
198 changed files with 6738 additions and 898 deletions
+1
View File
@@ -203,6 +203,7 @@
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-website-new",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
@@ -2273,6 +2273,11 @@ type FieldConnection {
edges: [FieldEdge!]!
}
type LogicFunctionLogs {
"""Execution Logs"""
logs: String!
}
type DeleteTwoFactorAuthenticationMethod {
"""Boolean that confirms query was dispatched"""
success: Boolean!
@@ -2593,11 +2598,6 @@ type UsageAnalytics {
userDailyUsage: UsageUserDaily
}
type LogicFunctionLogs {
"""Execution Logs"""
logs: String!
}
type FrontComponent {
id: UUID!
name: String!
@@ -3501,6 +3501,7 @@ type Mutation {
createViewGroup(input: CreateViewGroupInput!): ViewGroup!
createManyViewGroups(inputs: [CreateViewGroupInput!]!): [ViewGroup!]!
updateViewGroup(input: UpdateViewGroupInput!): ViewGroup!
updateManyViewGroups(inputs: [UpdateViewGroupInput!]!): [ViewGroup!]!
deleteViewGroup(input: DeleteViewGroupInput!): ViewGroup!
destroyViewGroup(input: DestroyViewGroupInput!): ViewGroup!
updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder!
@@ -1937,6 +1937,12 @@ export interface FieldConnection {
__typename: 'FieldConnection'
}
export interface LogicFunctionLogs {
/** Execution Logs */
logs: Scalars['String']
__typename: 'LogicFunctionLogs'
}
export interface DeleteTwoFactorAuthenticationMethod {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
@@ -2298,12 +2304,6 @@ export interface UsageAnalytics {
__typename: 'UsageAnalytics'
}
export interface LogicFunctionLogs {
/** Execution Logs */
logs: Scalars['String']
__typename: 'LogicFunctionLogs'
}
export interface FrontComponent {
id: Scalars['UUID']
name: Scalars['String']
@@ -2954,6 +2954,7 @@ export interface Mutation {
createViewGroup: ViewGroup
createManyViewGroups: ViewGroup[]
updateViewGroup: ViewGroup
updateManyViewGroups: ViewGroup[]
deleteViewGroup: ViewGroup
destroyViewGroup: ViewGroup
updateMessageFolder: MessageFolder
@@ -5096,6 +5097,13 @@ export interface FieldConnectionGenqlSelection{
__scalar?: boolean | number
}
export interface LogicFunctionLogsGenqlSelection{
/** Execution Logs */
logs?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface DeleteTwoFactorAuthenticationMethodGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
@@ -5501,13 +5509,6 @@ export interface UsageAnalyticsGenqlSelection{
__scalar?: boolean | number
}
export interface LogicFunctionLogsGenqlSelection{
/** Execution Logs */
logs?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FrontComponentGenqlSelection{
id?: boolean | number
name?: boolean | number
@@ -6207,6 +6208,7 @@ export interface MutationGenqlSelection{
createViewGroup?: (ViewGroupGenqlSelection & { __args: {input: CreateViewGroupInput} })
createManyViewGroups?: (ViewGroupGenqlSelection & { __args: {inputs: CreateViewGroupInput[]} })
updateViewGroup?: (ViewGroupGenqlSelection & { __args: {input: UpdateViewGroupInput} })
updateManyViewGroups?: (ViewGroupGenqlSelection & { __args: {inputs: UpdateViewGroupInput[]} })
deleteViewGroup?: (ViewGroupGenqlSelection & { __args: {input: DeleteViewGroupInput} })
destroyViewGroup?: (ViewGroupGenqlSelection & { __args: {input: DestroyViewGroupInput} })
updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} })
@@ -8047,6 +8049,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const LogicFunctionLogs_possibleTypes: string[] = ['LogicFunctionLogs']
export const isLogicFunctionLogs = (obj?: { __typename?: any } | null): obj is LogicFunctionLogs => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionLogs"')
return LogicFunctionLogs_possibleTypes.includes(obj.__typename)
}
const DeleteTwoFactorAuthenticationMethod_possibleTypes: string[] = ['DeleteTwoFactorAuthenticationMethod']
export const isDeleteTwoFactorAuthenticationMethod = (obj?: { __typename?: any } | null): obj is DeleteTwoFactorAuthenticationMethod => {
if (!obj?.__typename) throw new Error('__typename is missing in "isDeleteTwoFactorAuthenticationMethod"')
@@ -8431,14 +8441,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const LogicFunctionLogs_possibleTypes: string[] = ['LogicFunctionLogs']
export const isLogicFunctionLogs = (obj?: { __typename?: any } | null): obj is LogicFunctionLogs => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionLogs"')
return LogicFunctionLogs_possibleTypes.includes(obj.__typename)
}
const FrontComponent_possibleTypes: string[] = ['FrontComponent']
export const isFrontComponent = (obj?: { __typename?: any } | null): obj is FrontComponent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFrontComponent"')
@@ -63,8 +63,8 @@ export default {
210,
227,
244,
282,
283,
284,
299,
300,
325,
@@ -4458,6 +4458,14 @@ export default {
1
]
},
"LogicFunctionLogs": {
"logs": [
1
],
"__typename": [
1
]
},
"DeleteTwoFactorAuthenticationMethod": {
"success": [
6
@@ -4503,10 +4511,10 @@ export default {
},
"AuthTokenPair": {
"accessOrWorkspaceAgnosticToken": [
250
251
],
"refreshToken": [
250
251
],
"__typename": [
1
@@ -4514,7 +4522,7 @@ export default {
},
"AvailableWorkspacesAndAccessTokens": {
"tokens": [
251
252
],
"availableWorkspaces": [
224
@@ -4566,10 +4574,10 @@ export default {
},
"SignUp": {
"loginToken": [
250
251
],
"workspace": [
256
257
],
"__typename": [
1
@@ -4577,7 +4585,7 @@ export default {
},
"TransientToken": {
"transientToken": [
250
251
],
"__typename": [
1
@@ -4599,7 +4607,7 @@ export default {
},
"VerifyEmailAndGetLoginToken": {
"loginToken": [
250
251
],
"workspaceUrls": [
157
@@ -4618,7 +4626,7 @@ export default {
},
"AuthTokens": {
"tokens": [
251
252
],
"__typename": [
1
@@ -4626,7 +4634,7 @@ export default {
},
"LoginToken": {
"loginToken": [
250
251
],
"__typename": [
1
@@ -4656,10 +4664,10 @@ export default {
},
"Impersonate": {
"loginToken": [
250
251
],
"workspace": [
256
257
],
"__typename": [
1
@@ -4689,10 +4697,10 @@ export default {
},
"ApplicationTokenPair": {
"applicationAccessToken": [
250
251
],
"applicationRefreshToken": [
250
251
],
"__typename": [
1
@@ -4764,7 +4772,7 @@ export default {
1
],
"fields": [
271
272
],
"__typename": [
1
@@ -4861,10 +4869,10 @@ export default {
6
],
"objectPermissions": [
275
276
],
"fieldPermissions": [
276
277
],
"permissionFlags": [
1
@@ -4914,19 +4922,19 @@ export default {
1
],
"objects": [
272
],
"fields": [
271
],
"logicFunctions": [
273
],
"frontComponents": [
"fields": [
272
],
"logicFunctions": [
274
],
"frontComponents": [
275
],
"defaultRole": [
277
278
],
"sourcePackage": [
1
@@ -4986,13 +4994,13 @@ export default {
1
],
"driver": [
282
],
"status": [
283
],
"status": [
284
],
"verificationRecords": [
280
281
],
"verifiedAt": [
4
@@ -5039,7 +5047,7 @@ export default {
1
],
"location": [
285
286
],
"__typename": [
1
@@ -5067,13 +5075,13 @@ export default {
},
"ImapSmtpCaldavConnectionParameters": {
"IMAP": [
287
288
],
"SMTP": [
287
288
],
"CALDAV": [
287
288
],
"__typename": [
1
@@ -5093,7 +5101,7 @@ export default {
3
],
"connectionParameters": [
288
289
],
"__typename": [
1
@@ -5157,7 +5165,7 @@ export default {
1
],
"dailyUsage": [
293
294
],
"__typename": [
1
@@ -5165,13 +5173,13 @@ export default {
},
"UsageAnalytics": {
"usageByUser": [
292
293
],
"usageByOperationType": [
292
293
],
"timeSeries": [
293
294
],
"periodStart": [
4
@@ -5180,15 +5188,7 @@ export default {
4
],
"userDailyUsage": [
294
],
"__typename": [
1
]
},
"LogicFunctionLogs": {
"logs": [
1
295
],
"__typename": [
1
@@ -5235,7 +5235,7 @@ export default {
6
],
"applicationTokenPair": [
269
270
],
"__typename": [
1
@@ -6656,7 +6656,7 @@ export default {
}
],
"checkUserExists": [
264,
265,
{
"email": [
1,
@@ -6668,7 +6668,7 @@ export default {
}
],
"checkWorkspaceInviteHashIsValid": [
265,
266,
{
"inviteHash": [
1,
@@ -6686,7 +6686,7 @@ export default {
}
],
"validatePasswordResetToken": [
259,
260,
{
"passwordResetToken": [
1,
@@ -6769,7 +6769,7 @@ export default {
220
],
"getConnectedImapSmtpCaldavAccount": [
289,
290,
{
"id": [
3,
@@ -6778,7 +6778,7 @@ export default {
}
],
"getAutoCompleteAddress": [
284,
285,
{
"address": [
1,
@@ -6797,7 +6797,7 @@ export default {
}
],
"getAddressDetails": [
286,
287,
{
"placeId": [
1,
@@ -6889,19 +6889,19 @@ export default {
}
],
"getPostgresCredentials": [
291
292
],
"findManyPublicDomains": [
279
280
],
"getEmailingDomains": [
281
282
],
"findManyMarketplaceApps": [
278
279
],
"findOneMarketplaceApp": [
278,
279,
{
"universalIdentifier": [
1,
@@ -6924,7 +6924,7 @@ export default {
}
],
"getUsageAnalytics": [
295,
296,
{
"input": [
365
@@ -8130,6 +8130,15 @@ export default {
]
}
],
"updateManyViewGroups": [
56,
{
"inputs": [
450,
"[UpdateViewGroupInput!]!"
]
}
],
"deleteViewGroup": [
56,
{
@@ -8300,7 +8309,7 @@ export default {
}
],
"getAuthorizationUrlForSSO": [
254,
255,
{
"input": [
466,
@@ -8309,7 +8318,7 @@ export default {
}
],
"getLoginTokenFromCredentials": [
263,
264,
{
"email": [
1,
@@ -8335,7 +8344,7 @@ export default {
}
],
"signIn": [
252,
253,
{
"email": [
1,
@@ -8357,7 +8366,7 @@ export default {
}
],
"verifyEmailAndGetLoginToken": [
260,
261,
{
"emailVerificationToken": [
1,
@@ -8377,7 +8386,7 @@ export default {
}
],
"verifyEmailAndGetWorkspaceAgnosticToken": [
252,
253,
{
"emailVerificationToken": [
1,
@@ -8393,7 +8402,7 @@ export default {
}
],
"getAuthTokensFromOTP": [
262,
263,
{
"otp": [
1,
@@ -8413,7 +8422,7 @@ export default {
}
],
"signUp": [
252,
253,
{
"email": [
1,
@@ -8435,7 +8444,7 @@ export default {
}
],
"signUpInWorkspace": [
257,
258,
{
"email": [
1,
@@ -8466,13 +8475,13 @@ export default {
}
],
"signUpInNewWorkspace": [
257
],
"generateTransientToken": [
258
],
"generateTransientToken": [
259
],
"getAuthTokensFromLoginToken": [
262,
263,
{
"loginToken": [
1,
@@ -8485,7 +8494,7 @@ export default {
}
],
"authorizeApp": [
249,
250,
{
"clientId": [
1,
@@ -8507,7 +8516,7 @@ export default {
}
],
"renewToken": [
262,
263,
{
"appToken": [
1,
@@ -8516,7 +8525,7 @@ export default {
}
],
"generateApiKeyToken": [
261,
262,
{
"apiKeyId": [
3,
@@ -8529,7 +8538,7 @@ export default {
}
],
"emailPasswordResetLink": [
253,
254,
{
"email": [
1,
@@ -8541,7 +8550,7 @@ export default {
}
],
"updatePasswordViaResetToken": [
255,
256,
{
"passwordResetToken": [
1,
@@ -8642,7 +8651,7 @@ export default {
}
],
"initiateOTPProvisioning": [
247,
248,
{
"loginToken": [
1,
@@ -8655,10 +8664,10 @@ export default {
}
],
"initiateOTPProvisioningForAuthenticatedUser": [
247
248
],
"deleteTwoFactorAuthenticationMethod": [
246,
247,
{
"twoFactorAuthenticationMethodId": [
3,
@@ -8667,7 +8676,7 @@ export default {
}
],
"verifyTwoFactorAuthenticationMethodForAuthenticatedUser": [
248,
249,
{
"otp": [
1,
@@ -8773,7 +8782,7 @@ export default {
}
],
"impersonate": [
266,
267,
{
"userId": [
3,
@@ -8795,7 +8804,7 @@ export default {
}
],
"saveImapSmtpCaldavAccount": [
290,
291,
{
"accountOwnerId": [
3,
@@ -8998,13 +9007,13 @@ export default {
}
],
"enablePostgresProxy": [
291
292
],
"disablePostgresProxy": [
291
292
],
"createPublicDomain": [
279,
280,
{
"domain": [
1,
@@ -9031,14 +9040,14 @@ export default {
}
],
"createEmailingDomain": [
281,
282,
{
"domain": [
1,
"String!"
],
"driver": [
282,
283,
"EmailingDomainDriver!"
]
}
@@ -9053,7 +9062,7 @@ export default {
}
],
"verifyEmailingDomain": [
281,
282,
{
"id": [
1,
@@ -9130,7 +9139,7 @@ export default {
}
],
"createDevelopmentApplication": [
267,
268,
{
"universalIdentifier": [
1,
@@ -9143,7 +9152,7 @@ export default {
}
],
"generateApplicationToken": [
269,
270,
{
"applicationId": [
3,
@@ -9152,7 +9161,7 @@ export default {
}
],
"syncApplication": [
268,
269,
{
"manifest": [
15,
@@ -9161,7 +9170,7 @@ export default {
}
],
"uploadApplicationFile": [
270,
271,
{
"file": [
372,
@@ -9195,7 +9204,7 @@ export default {
}
],
"renewApplicationToken": [
269,
270,
{
"applicationRefreshToken": [
1,
@@ -11455,7 +11464,7 @@ export default {
}
],
"logicFunctionLogs": [
296,
246,
{
"input": [
490,
File diff suppressed because one or more lines are too long
@@ -31,8 +31,6 @@ import { useCallback, useState } from 'react';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
export const useAgentChat = (
uiMessages: ExtendedUIMessage[],
ensureThreadIdForSend: () => Promise<string | null>,
@@ -94,7 +92,6 @@ export const useAgentChat = (
return null;
}
cookieStorage.setItem('tokenPair', JSON.stringify(renewedTokens));
setTokenPair(renewedTokens);
const updatedHeaders = new Headers(init?.headers ?? {});
@@ -34,7 +34,6 @@ import {
import isEmpty from 'lodash.isempty';
import { getGenericOperationName, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const logger = loggerLink(() => 'Twenty');
@@ -175,7 +174,6 @@ export class ApolloFactory implements ApolloManager {
if (isDefined(tokens)) {
onTokenPairChange?.(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
}
};
@@ -0,0 +1,8 @@
import type { DropDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDropDestination';
export type NavigationMenuItemDropResult = {
source: DropDestination;
destination: DropDestination | null;
draggableId: string;
insertBeforeItemId?: string | null;
};
@@ -1,14 +1,14 @@
import { NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG } from '@/navigation-menu-item/common/constants/NavigationMenuItemSectionDroppableConfig';
import type { DropDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDropDestination';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import type { DropResult } from '@hello-pangea/dnd';
export const getDropTargetIdFromDestination = ({
navigationMenuItemSection,
destination,
}: {
navigationMenuItemSection: NavigationMenuItemSection;
destination: DropResult['destination'];
destination: DropDestination | null;
}): string | null => {
if (
!destination ||
@@ -1,69 +0,0 @@
import { Droppable } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/display/dnd/hooks/useIsDropDisabledForSection';
type NavigationMenuItemDroppableProps = {
droppableId: string;
children: React.ReactNode;
isDragIndicatorVisible?: boolean;
showDropLine?: boolean;
isWorkspaceSection?: boolean;
};
const StyledDroppableWrapper = styled.div`
position: relative;
transition: all 150ms ease-in-out;
width: 100%;
&[data-dragging-over='true'] {
background-color: ${themeCssVariables.background.transparent.blue};
}
&[data-dragging-over='true'][data-show-drop-line='true']::before {
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.sm}
${themeCssVariables.border.radius.sm} 0 0;
bottom: 0;
content: '';
height: 2px;
left: 0;
position: absolute;
width: 100%;
}
`;
export const NavigationMenuItemDroppable = ({
droppableId,
children,
isDragIndicatorVisible = true,
showDropLine = true,
isWorkspaceSection = false,
}: NavigationMenuItemDroppableProps) => {
const isDropDisabled = useIsDropDisabledForSection(isWorkspaceSection);
return (
<Droppable droppableId={droppableId} isDropDisabled={isDropDisabled}>
{(provided, snapshot) => (
<StyledDroppableWrapper
data-dragging-over={
snapshot.isDraggingOver && isDragIndicatorVisible
? 'true'
: undefined
}
data-show-drop-line={showDropLine ? 'true' : undefined}
>
<div
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{children}
{provided.placeholder}
</div>
</StyledDroppableWrapper>
)}
</Droppable>
);
};
@@ -1,6 +0,0 @@
export const DROP_RESULT_OPTIONS = {
reason: 'DROP' as const,
combine: null,
mode: 'FLUID' as const,
type: 'DEFAULT' as const,
};
@@ -1,4 +1,3 @@
import type { DropResult, ResponderProvided } from '@hello-pangea/dnd';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -28,6 +27,8 @@ import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { useStore } from 'jotai';
import { NavigationMenuItemType } from 'twenty-shared/types';
import type { NavigationMenuItemDropResult } from '@/navigation-menu-item/common/types/navigationMenuItemDropResult';
export const useHandleAddToNavigationDrop = () => {
const store = useStore();
const { addObjectToDraft } = useAddObjectToNavigationMenuDraft();
@@ -50,7 +51,7 @@ export const useHandleAddToNavigationDrop = () => {
);
const handleAddToNavigationDrop = useCallback(
(result: DropResult, _provided: ResponderProvided) => {
(result: NavigationMenuItemDropResult) => {
const { source, destination, draggableId } = result;
if (
source.droppableId !== ADD_TO_NAV_SOURCE_DROPPABLE_ID ||
@@ -1,8 +1,9 @@
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { useStore } from 'jotai';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItemDropResult } from '@/navigation-menu-item/common/types/navigationMenuItemDropResult';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useUpdateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useUpdateManyNavigationMenuItems';
import { NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG } from '@/navigation-menu-item/common/constants/NavigationMenuItemSectionDroppableConfig';
@@ -115,10 +116,8 @@ export const useHandleNavigationMenuItemDragAndDrop = (
});
};
const handleNavigationMenuItemDragAndDrop: OnDragEndResponder = async (
result: Parameters<OnDragEndResponder>[0] & {
insertBeforeItemId?: string | null;
},
const handleNavigationMenuItemDragAndDrop = async (
result: NavigationMenuItemDropResult,
) => {
const { destination, source, draggableId } = result;
@@ -1,6 +1,5 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { isSortable } from '@dnd-kit/react/sortable';
import type { ResponderProvided } from '@hello-pangea/dnd';
import { useStore } from 'jotai';
import { type ComponentProps, useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -11,17 +10,16 @@ import { NavigationSections } from '@/navigation-menu-item/common/constants/Navi
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/common/states/addToNavPayloadRegistryState';
import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData';
import type { DropDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDropDestination';
import type { NavigationMenuItemDropResult } from '@/navigation-menu-item/common/types/navigationMenuItemDropResult';
import type { SortableTargetDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitSortableTargetDestination';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { extractFolderIdFromDroppableId } from '@/navigation-menu-item/common/utils/extractFolderIdFromDroppableId';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { DROP_RESULT_OPTIONS } from '@/navigation-menu-item/display/dnd/constants/navigationMenuItemDndKitDropResultOptions';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleAddToNavigationDrop';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleNavigationMenuItemDragAndDrop';
import { resolveDropTarget } from '@/navigation-menu-item/display/dnd/utils/navigationMenuItemDndKitResolveDropTarget';
import { toDropResult } from '@/navigation-menu-item/display/dnd/utils/navigationMenuItemDndKitToDropResult';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
@@ -172,23 +170,13 @@ export const useNavigationMenuItemDndKit = (
destination: DropDestination,
insertBeforeItemId?: string | null,
) => {
const result = toDropResult(
id,
{
sourceDroppableId: source.droppableId,
sourceIndex: source.index,
},
const result: NavigationMenuItemDropResult = {
draggableId: id,
source,
destination,
);
const provided: ResponderProvided = { announce: () => {} };
handleNavigationMenuItemDragAndDrop(
{
...result,
...DROP_RESULT_OPTIONS,
...(insertBeforeItemId != null && { insertBeforeItemId }),
},
provided,
);
...(insertBeforeItemId != null && { insertBeforeItemId }),
};
handleNavigationMenuItemDragAndDrop(result);
},
[handleNavigationMenuItemDragAndDrop],
);
@@ -351,12 +339,18 @@ export const useNavigationMenuItemDndKit = (
destination = fallback;
}
const result = toDropResult(draggableId, data, destination);
const provided: ResponderProvided = { announce: () => {} };
const dropResult = { ...result, ...DROP_RESULT_OPTIONS };
const dropResult: NavigationMenuItemDropResult = {
draggableId,
source: {
droppableId: data?.sourceDroppableId ?? '',
index: data?.sourceIndex ?? 0,
},
destination,
...(insertBeforeItemId != null && { insertBeforeItemId }),
};
if (sourceId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
handleAddToNavigationDrop(dropResult, provided);
handleAddToNavigationDrop(dropResult);
return;
}
@@ -386,7 +380,7 @@ export const useNavigationMenuItemDndKit = (
return;
}
handleNavigationMenuItemDragAndDrop(dropResult, provided);
handleNavigationMenuItemDragAndDrop(dropResult);
};
const contextValues: NavigationMenuItemDndKitContextValues = {
@@ -1,20 +0,0 @@
import type { DropDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDropDestination';
import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData';
export const toDropResult = (
draggableId: string,
data: DraggableData | undefined,
destination: DropDestination | null,
): {
source: DropDestination;
destination: DropDestination | null;
draggableId: string;
} => {
const sourceDroppableId = data?.sourceDroppableId ?? '';
const sourceIndex = data?.sourceIndex ?? 0;
return {
source: { droppableId: sourceDroppableId, index: sourceIndex },
destination,
draggableId,
};
};
@@ -1,102 +0,0 @@
import {
type DraggableProvided,
type DraggableRubric,
type DraggableStateSnapshot,
} from '@hello-pangea/dnd';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { getNavigationMenuItemObjectNameSingular } from '@/navigation-menu-item/display/object/utils/getNavigationMenuItemObjectNameSingular';
import { getObjectNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemSecondaryLabel';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { ViewKey } from '@/views/types/ViewKey';
type WorkspaceNavigationMenuItemFolderDragCloneProps = {
draggableProvided: DraggableProvided;
draggableSnapshot: DraggableStateSnapshot;
rubric: DraggableRubric;
navigationMenuItems: NavigationMenuItem[];
navigationMenuItemFolderContentLength: number;
selectedNavigationMenuItemIndex: number;
};
export const WorkspaceNavigationMenuItemFolderDragClone = ({
draggableProvided,
draggableSnapshot,
rubric,
navigationMenuItems,
navigationMenuItemFolderContentLength,
selectedNavigationMenuItemIndex,
}: WorkspaceNavigationMenuItemFolderDragCloneProps) => {
const { theme } = useContext(ThemeContext);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
const navigationMenuItem = navigationMenuItems[rubric.source.index];
if (!isDefined(navigationMenuItem)) {
return null;
}
const label = getNavigationMenuItemLabel(
navigationMenuItem,
objectMetadataItems,
views,
);
const objectNameSingular = getNavigationMenuItemObjectNameSingular(
navigationMenuItem,
objectMetadataItems,
views,
);
const view = isDefined(navigationMenuItem.viewId)
? views.find((view) => view.id === navigationMenuItem.viewId)
: undefined;
const isIndexView = view?.key === ViewKey.INDEX;
return (
<div
ref={draggableProvided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
style={{
...draggableProvided.draggableProps.style,
background: draggableSnapshot.isDragging
? theme.background.transparent.light
: undefined,
}}
>
<NavigationDrawerSubItem
secondaryLabel={
isIndexView
? undefined
: getObjectNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular: objectNameSingular ?? '',
})
}
label={label}
Icon={() => (
<NavigationMenuItemIcon navigationMenuItem={navigationMenuItem} />
)}
to={undefined}
active={false}
isDragging={true}
subItemState={getNavigationSubItemLeftAdornment({
index: rubric.source.index,
arrayLength: navigationMenuItemFolderContentLength,
selectedIndex: selectedNavigationMenuItemIndex,
})}
triggerEvent="CLICK"
/>
</div>
);
};
@@ -91,13 +91,20 @@ export const MultiItemFieldInput = <T,>({
) {
return;
}
const { isValid, updatedItems } = validateInputAndComputeUpdatedItems();
if (!isValid) {
if (isInputDisplayed) {
const { isValid, updatedItems } = validateInputAndComputeUpdatedItems();
if (!isValid) {
return;
}
onChange(updatedItems);
onClickOutside(updatedItems, event);
return;
}
onChange(updatedItems);
onClickOutside(items, event);
},
listenerId: instanceId,
@@ -252,26 +252,36 @@ describe('computeOptimisticRecordFromInput', () => {
});
});
it('should throw an error if recordInput contains fields unrelated to the current objectMetadata', () => {
it('should warn and skip unknown fields if recordInput contains fields unrelated to the current objectMetadata', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
expect(() =>
computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: getTestEnrichedObjectMetadataItemsMock(),
objectMetadataItem: personObjectMetadataItem,
recordInput: {
unknwon: 'unknown',
foo: 'foo',
bar: 'bar',
city: 'Paris',
},
cache,
objectPermissionsByObjectMetadataId: {},
}),
).toThrowErrorMatchingInlineSnapshot(
`"Should never occur, encountered unknown fields unknwon, foo, bar in objectMetadataItem person"`,
const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: getTestEnrichedObjectMetadataItemsMock(),
objectMetadataItem: personObjectMetadataItem,
recordInput: {
unknwon: 'unknown',
foo: 'foo',
bar: 'bar',
city: 'Paris',
},
cache,
objectPermissionsByObjectMetadataId: {},
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'encountered unknown fields unknwon, foo, bar in objectMetadataItem person',
),
);
expect(result).toEqual({
city: 'Paris',
});
consoleWarnSpy.mockRestore();
});
});
@@ -71,8 +71,8 @@ export const computeOptimisticRecordFromInput = ({
},
);
if (unknownRecordInputFields.length > 0) {
throw new Error(
`Should never occur, encountered unknown fields ${unknownRecordInputFields.join(', ')} in objectMetadataItem ${objectMetadataItem.nameSingular}`,
console.warn(
`computeOptimisticRecordFromInput: encountered unknown fields ${unknownRecordInputFields.join(', ')} in objectMetadataItem ${objectMetadataItem.nameSingular}. Skipping unknown fields.`,
);
}
@@ -0,0 +1,11 @@
import { VIEW_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewGroupFragment';
import { gql } from '@apollo/client';
export const UPDATE_MANY_VIEW_GROUPS = gql`
${VIEW_GROUP_FRAGMENT}
mutation UpdateManyViewGroups($inputs: [UpdateViewGroupInput!]!) {
updateManyViewGroups(inputs: $inputs) {
...ViewGroupFragment
}
}
`;
@@ -8,43 +8,44 @@ import { t } from '@lingui/core/macro';
import { CrudOperationType } from 'twenty-shared/types';
import { useMutation } from '@apollo/client/react';
import {
type UpdateViewGroupMutationVariables,
UpdateViewGroupDocument,
type UpdateManyViewGroupsMutationVariables,
UpdateManyViewGroupsDocument,
} from '~/generated-metadata/graphql';
export const usePerformViewGroupAPIPersist = () => {
const [updateViewGroupMutation] = useMutation(UpdateViewGroupDocument);
const [updateManyViewGroupsMutation] = useMutation(
UpdateManyViewGroupsDocument,
);
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const performViewGroupAPIUpdate = useCallback(
async (
updateViewGroupInputs: UpdateViewGroupMutationVariables[],
updateViewGroupInputs: UpdateManyViewGroupsMutationVariables,
): Promise<
MetadataRequestResult<
Awaited<ReturnType<typeof updateViewGroupMutation>>[]
>
MetadataRequestResult<Awaited<
ReturnType<typeof updateManyViewGroupsMutation>
> | null>
> => {
if (updateViewGroupInputs.length === 0) {
if (
!Array.isArray(updateViewGroupInputs.inputs) ||
updateViewGroupInputs.inputs.length === 0
) {
return {
status: 'successful',
response: [],
response: null,
};
}
try {
const results = await Promise.all(
updateViewGroupInputs.map((variables) =>
updateViewGroupMutation({
variables,
}),
),
);
const result = await updateManyViewGroupsMutation({
variables: updateViewGroupInputs,
});
return {
status: 'successful',
response: results,
response: result,
};
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
@@ -62,7 +63,7 @@ export const usePerformViewGroupAPIPersist = () => {
};
}
},
[updateViewGroupMutation, handleMetadataError, enqueueErrorSnackBar],
[updateManyViewGroupsMutation, handleMetadataError, enqueueErrorSnackBar],
);
return {
@@ -67,9 +67,9 @@ export const useSaveCurrentViewGroups = () => {
return;
}
await performViewGroupAPIUpdate([
{
input: {
await performViewGroupAPIUpdate({
inputs: [
{
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
@@ -77,8 +77,8 @@ export const useSaveCurrentViewGroups = () => {
fieldValue: viewGroupToSave.fieldValue,
},
},
},
]);
],
});
},
[
store,
@@ -109,7 +109,7 @@ export const useSaveCurrentViewGroups = () => {
const currentViewGroups = view.viewGroups;
const viewGroupsToUpdate = viewGroupsToSave
const viewGroupInputsToUpdate = viewGroupsToSave
.map((viewGroupToSave) => {
const existingField = currentViewGroups.find(
(currentViewGroup) =>
@@ -136,13 +136,11 @@ export const useSaveCurrentViewGroups = () => {
}
return {
input: {
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
position: viewGroupToSave.position,
fieldValue: viewGroupToSave.fieldValue,
},
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
position: viewGroupToSave.position,
fieldValue: viewGroupToSave.fieldValue,
},
};
})
@@ -152,7 +150,7 @@ export const useSaveCurrentViewGroups = () => {
throw new Error('mainGroupByFieldMetadataId is required');
}
await performViewGroupAPIUpdate(viewGroupsToUpdate);
await performViewGroupAPIUpdate({ inputs: viewGroupInputsToUpdate });
},
[
store,
@@ -1,67 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:1-20:generate-application-sdk-clients',
description:
'Generate SDK client archives for all existing applications so drivers do not crash with ARCHIVE_NOT_FOUND',
})
export class GenerateApplicationSdkClientsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly sdkClientGenerationService: SdkClientGenerationService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const dryRun = options.dryRun ?? false;
const applications = await this.applicationRepository.find({
where: { workspaceId },
});
this.logger.log(
`Found ${applications.length} application(s) in workspace ${workspaceId}`,
);
for (const application of applications) {
if (dryRun) {
this.logger.log(
`[DRY RUN] Would generate SDK client for application ${application.universalIdentifier} (${application.id})`,
);
continue;
}
try {
await this.sdkClientGenerationService.generateSdkClientForApplication({
workspaceId,
applicationId: application.id,
applicationUniversalIdentifier: application.universalIdentifier,
});
} catch (error) {
this.logger.error(
`Failed to generate SDK client for application ${application.universalIdentifier} (${application.id}): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
}
@@ -6,7 +6,6 @@ import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upg
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts-and-fields-widget-view-fields.command';
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command';
import { GenerateApplicationSdkClientsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-generate-application-sdk-clients.command';
import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-object-permission-metadata.command';
import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command';
import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command';
@@ -17,10 +16,8 @@ import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-vers
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { UpdateStandardIndexViewNamesCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-update-standard-index-view-names.command';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
@@ -40,7 +37,6 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
imports: [
TypeOrmModule.forFeature([
WorkspaceEntity,
ApplicationEntity,
ConnectedAccountEntity,
MessageChannelEntity,
CalendarChannelEntity,
@@ -57,7 +53,6 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
ApplicationRegistrationModule,
WorkspaceMigrationModule,
FeatureFlagModule,
SdkClientModule,
WorkflowCommonModule,
],
providers: [
@@ -70,7 +65,6 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
BackfillSelectFieldOptionIdsCommand,
DeleteOrphanNavigationMenuItemsCommand,
GenerateApplicationSdkClientsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
MigrateMessagingInfrastructureToMetadataCommand,
@@ -87,7 +81,6 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
BackfillSelectFieldOptionIdsCommand,
DeleteOrphanNavigationMenuItemsCommand,
GenerateApplicationSdkClientsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
MigrateMessagingInfrastructureToMetadataCommand,
@@ -38,7 +38,6 @@ import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upg
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts-and-fields-widget-view-fields.command';
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command';
import { GenerateApplicationSdkClientsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-generate-application-sdk-clients.command';
import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-object-permission-metadata.command';
import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command';
import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command';
@@ -106,7 +105,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillCommandMenuItemsCommand: BackfillCommandMenuItemsCommand,
protected readonly deleteOrphanNavigationMenuItemsCommand: DeleteOrphanNavigationMenuItemsCommand,
protected readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
protected readonly generateApplicationSdkClientsCommand: GenerateApplicationSdkClientsCommand,
protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand,
protected readonly migrateRichTextToTextCommand: MigrateRichTextToTextCommand,
protected readonly migrateMessagingInfrastructureToMetadataCommand: MigrateMessagingInfrastructureToMetadataCommand,
@@ -176,7 +174,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.backfillSelectFieldOptionIdsCommand,
this.updateStandardIndexViewNamesCommand,
this.makeWorkflowSearchableCommand,
this.generateApplicationSdkClientsCommand,
];
this.allCommands = {
@@ -721,6 +721,7 @@ export class LambdaDriver implements LogicFunctionDriver {
const sdkArchiveBuffer =
await this.sdkClientArchiveService.downloadArchiveBuffer({
workspaceId: flatApplication.workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier,
});
@@ -113,6 +113,7 @@ export class LocalDriver implements LogicFunctionDriver {
await this.sdkClientArchiveService.downloadAndExtractToPackage({
workspaceId: flatApplication.workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier,
targetPackagePath: sdkPackagePath,
});
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { promises as fs } from 'fs';
import { dirname, join } from 'path';
import { type QueryRunner } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -40,6 +41,7 @@ type UpdateSourceFilesParams = Omit<
'builtHandlerPath'
> & {
sourceHandlerCode: string;
queryRunner?: QueryRunner;
};
type GetSourceCodeParams = Identifier & {
@@ -128,6 +130,7 @@ export class LogicFunctionResourceService {
workspaceId,
applicationUniversalIdentifier,
sourceHandlerCode,
queryRunner,
}: UpdateSourceFilesParams): Promise<void> {
await this.fileStorageService.writeFile({
workspaceId,
@@ -137,6 +140,20 @@ export class LogicFunctionResourceService {
sourceFile: sourceHandlerCode,
settings: { isTemporaryFile: false, toDelete: false },
mimeType: 'application/typescript',
queryRunner,
});
}
async deleteSourceFile({
sourceHandlerPath,
workspaceId,
applicationUniversalIdentifier,
}: GetSourceCodeParams): Promise<void> {
await this.fileStorageService.delete({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Source,
resourcePath: sourceHandlerPath,
});
}
@@ -59,6 +59,7 @@ export class SdkClientController {
const fileBuffer =
await this.sdkClientArchiveService.getClientModuleFromArchive({
workspaceId: workspace.id,
applicationId,
applicationUniversalIdentifier: application.universalIdentifier,
moduleName,
});
@@ -1,11 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createWriteStream } from 'fs';
import * as fs from 'fs/promises';
import { join } from 'path';
import { type Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { FileFolder } from 'twenty-shared/types';
import { Repository } from 'typeorm';
@@ -16,12 +12,12 @@ import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/temporary-dir-manager';
import { type SdkModuleName } from 'src/engine/core-modules/sdk-client/constants/allowed-sdk-modules';
import {
SdkClientException,
SdkClientExceptionCode,
} from 'src/engine/core-modules/sdk-client/exceptions/sdk-client.exception';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@@ -29,75 +25,74 @@ const SDK_CLIENT_ARCHIVE_NAME = 'twenty-client-sdk.zip';
@Injectable()
export class SdkClientArchiveService {
private readonly logger = new Logger(SdkClientArchiveService.name);
constructor(
private readonly fileStorageService: FileStorageService,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly sdkClientGenerationService: SdkClientGenerationService,
) {}
async downloadAndExtractToPackage({
workspaceId,
applicationId,
applicationUniversalIdentifier,
targetPackagePath,
}: {
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
targetPackagePath: string;
}): Promise<void> {
const temporaryDirManager = new TemporaryDirManager();
const archiveBuffer = await this.downloadArchiveBufferOrGenerate({
workspaceId,
applicationId,
applicationUniversalIdentifier,
});
try {
const { sourceTemporaryDir } = await temporaryDirManager.init();
const archivePath = join(sourceTemporaryDir, SDK_CLIENT_ARCHIVE_NAME);
await fs.rm(targetPackagePath, { recursive: true, force: true });
await fs.mkdir(targetPackagePath, { recursive: true });
const archiveStream = await this.readArchiveStream({
workspaceId,
applicationUniversalIdentifier,
});
const { default: unzipper } = await import('unzipper');
const directory = await unzipper.Open.buffer(archiveBuffer);
await pipeline(archiveStream, createWriteStream(archivePath));
await fs.rm(targetPackagePath, { recursive: true, force: true });
await fs.mkdir(targetPackagePath, { recursive: true });
const { default: unzipper } = await import('unzipper');
const directory = await unzipper.Open.file(archivePath);
await directory.extract({ path: targetPackagePath });
} finally {
await temporaryDirManager.clean();
}
await directory.extract({ path: targetPackagePath });
}
async downloadArchiveBuffer({
workspaceId,
applicationId,
applicationUniversalIdentifier,
}: {
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
}): Promise<Buffer> {
const archiveStream = await this.readArchiveStream({
return this.downloadArchiveBufferOrGenerate({
workspaceId,
applicationId,
applicationUniversalIdentifier,
});
return streamToBuffer(archiveStream);
}
async getClientModuleFromArchive({
workspaceId,
applicationId,
applicationUniversalIdentifier,
moduleName,
}: {
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
moduleName: SdkModuleName;
}): Promise<Buffer> {
const filePath = `dist/${moduleName}.mjs`;
const archiveBuffer = await this.downloadArchiveBuffer({
const archiveBuffer = await this.downloadArchiveBufferOrGenerate({
workspaceId,
applicationId,
applicationUniversalIdentifier,
});
@@ -135,32 +130,41 @@ export class SdkClientArchiveService {
]);
}
private async readArchiveStream({
private async downloadArchiveBufferOrGenerate({
workspaceId,
applicationId,
applicationUniversalIdentifier,
}: {
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
}): Promise<Readable> {
}): Promise<Buffer> {
try {
return await this.fileStorageService.readFile({
const stream = await this.fileStorageService.readFile({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.GeneratedSdkClient,
resourcePath: SDK_CLIENT_ARCHIVE_NAME,
});
return await streamToBuffer(stream);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
!(error instanceof FileStorageException) ||
error.code !== FileStorageExceptionCode.FILE_NOT_FOUND
) {
throw new SdkClientException(
`SDK client archive "${SDK_CLIENT_ARCHIVE_NAME}" not found for application "${applicationUniversalIdentifier}" in workspace "${workspaceId}".`,
SdkClientExceptionCode.ARCHIVE_NOT_FOUND,
);
throw error;
}
throw error;
}
this.logger.warn(
`SDK client archive missing for application "${applicationUniversalIdentifier}" in workspace "${workspaceId}", generating on-the-fly`,
);
return this.sdkClientGenerationService.generateSdkClientForApplication({
workspaceId,
applicationId,
applicationUniversalIdentifier,
});
}
}
@@ -44,13 +44,13 @@ export class SdkClientGenerationService {
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
}): Promise<void> {
}): Promise<Buffer> {
const graphqlSchema = await this.workspaceSchemaFactory.createGraphQLSchema(
{ id: workspaceId } as WorkspaceEntity,
applicationId,
);
await this.generateAndStore({
const archiveBuffer = await this.generateAndStore({
workspaceId,
applicationId,
applicationUniversalIdentifier,
@@ -60,6 +60,8 @@ export class SdkClientGenerationService {
this.logger.log(
`Generated SDK client for application ${applicationUniversalIdentifier}`,
);
return archiveBuffer;
}
private async generateAndStore({
@@ -72,7 +74,7 @@ export class SdkClientGenerationService {
applicationId: string;
applicationUniversalIdentifier: string;
schema: string;
}): Promise<void> {
}): Promise<Buffer> {
const temporaryDirManager = new TemporaryDirManager();
try {
@@ -101,12 +103,14 @@ export class SdkClientGenerationService {
await createZipFile(tempPackageRoot, archivePath);
const archiveBuffer = await fs.readFile(archivePath);
await this.fileStorageService.writeFile({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.GeneratedSdkClient,
resourcePath: SDK_CLIENT_ARCHIVE_NAME,
sourceFile: await fs.readFile(archivePath),
sourceFile: archiveBuffer,
mimeType: 'application/zip',
settings: { isTemporaryFile: false, toDelete: false },
});
@@ -119,6 +123,8 @@ export class SdkClientGenerationService {
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);
return archiveBuffer;
} catch (error) {
throw new SdkClientException(
`Failed to generate SDK client for application "${applicationUniversalIdentifier}" in workspace "${workspaceId}": ${error instanceof Error ? error.message : String(error)}`,
@@ -33,6 +33,7 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
describe('WorkspaceService', () => {
@@ -119,6 +120,7 @@ describe('WorkspaceService', () => {
PermissionsService,
FileCorePictureService,
AiModelRegistryService,
PrefillLogicFunctionService,
].map((service) => ({
provide: service,
useValue: {},
@@ -53,12 +53,14 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-companies';
import { prefillDashboards } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-dashboards';
import { prefillOpportunities } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-opportunities';
import { prefillPeople } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-people';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflow-command-menu-items';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-companies.util';
import { prefillDashboards } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-dashboards.util';
import { prefillOpportunities } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-opportunities.util';
import { prefillPeople } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-people.util';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-command-menu-items.util';
import { getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-code-step-logic-functions.util';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflows.util';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch';
@@ -111,6 +113,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
private readonly permissionsService: PermissionsService,
private readonly dnsManagerService: DnsManagerService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly prefillLogicFunctionService: PrefillLogicFunctionService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly subdomainManagerService: SubdomainManagerService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
@@ -681,6 +684,14 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
},
);
await this.prefillLogicFunctionService.ensureSeeded({
workspaceId,
definitions:
getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions(
workspaceId,
),
});
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
@@ -694,6 +705,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
await prefillWorkflows(
queryRunner.manager,
workspaceId,
schemaName,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
@@ -720,6 +732,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
);
}
}
throw error;
} finally {
await queryRunner.release();
@@ -41,12 +41,14 @@ import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { StandardObjectsPrefillModule } from 'src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill.module';
@Module({
imports: [
TypeORMModule,
TypeOrmModule.forFeature([BillingSubscriptionEntity, WorkspaceEntity]),
MetricsModule,
StandardObjectsPrefillModule,
NestjsQueryGraphQLModule.forFeature({
imports: [
AuditModule,
@@ -79,6 +81,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ApplicationModule,
EnterpriseModule,
StandardObjectsPrefillModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,
@@ -118,11 +118,11 @@ export class MessageFolderDataAccessService {
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.save(data);
const savedData = await workspaceRepository.save(data);
if (await this.isMigrated(workspaceId)) {
try {
const coreData = await this.toCore(workspaceId, data);
const coreData = await this.toCore(workspaceId, savedData);
await this.coreRepository.save(
coreData as unknown as MessageFolderEntity,
@@ -84,6 +84,19 @@ export class ViewGroupResolver {
});
}
@Mutation(() => [ViewGroupDTO])
@UseGuards(UpdateViewGroupPermissionGuard)
async updateManyViewGroups(
@Args('inputs', { type: () => [UpdateViewGroupInput] })
updateViewGroupInputs: UpdateViewGroupInput[],
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewGroupDTO[]> {
return await this.viewGroupService.updateMany({
updateViewGroupInputs,
workspaceId,
});
}
@Mutation(() => ViewGroupDTO)
@UseGuards(DeleteViewGroupPermissionGuard)
async deleteViewGroup(
@@ -7,6 +7,7 @@ import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateViewGroupInputToFlatViewGroupToCreate } from 'src/engine/metadata-modules/flat-view-group/utils/from-create-view-group-input-to-flat-view-group-to-create.util';
@@ -152,6 +153,32 @@ export class ViewGroupService {
workspaceId: string;
updateViewGroupInput: UpdateViewGroupInput;
}): Promise<ViewGroupDTO> {
const [updatedViewGroup] = await this.updateMany({
updateViewGroupInputs: [updateViewGroupInput],
workspaceId,
});
if (!isDefined(updatedViewGroup)) {
throw new ViewGroupException(
'Failed to update view group',
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
);
}
return updatedViewGroup;
}
async updateMany({
updateViewGroupInputs,
workspaceId,
}: {
updateViewGroupInputs: UpdateViewGroupInput[];
workspaceId: string;
}): Promise<ViewGroupDTO[]> {
if (updateViewGroupInputs.length === 0) {
return [];
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
@@ -167,11 +194,13 @@ export class ViewGroupService {
},
);
const optimisticallyUpdatedFlatViewGroup =
fromUpdateViewGroupInputToFlatViewGroupToUpdateOrThrow({
flatViewGroupMaps: existingFlatViewGroupMaps,
updateViewGroupInput,
});
const flatViewGroupsToUpdate = updateViewGroupInputs.map(
(updateViewGroupInput) =>
fromUpdateViewGroupInputToFlatViewGroupToUpdateOrThrow({
flatViewGroupMaps: existingFlatViewGroupMaps,
updateViewGroupInput,
}),
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
@@ -180,7 +209,7 @@ export class ViewGroupService {
viewGroup: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [optimisticallyUpdatedFlatViewGroup],
flatEntityToUpdate: flatViewGroupsToUpdate,
},
},
workspaceId,
@@ -193,7 +222,7 @@ export class ViewGroupService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating view group',
'Multiple validation errors occurred while updating view groups',
);
}
@@ -205,12 +234,13 @@ export class ViewGroupService {
},
);
return fromFlatViewGroupToViewGroupDto(
findFlatEntityByUniversalIdentifierOrThrow({
universalIdentifier:
optimisticallyUpdatedFlatViewGroup.universalIdentifier,
flatEntityMaps: recomputedExistingFlatViewGroupMaps,
}),
return updateViewGroupInputs.map(({ id }) =>
fromFlatViewGroupToViewGroupDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedExistingFlatViewGroupMaps,
}),
),
);
}
@@ -121,8 +121,10 @@ import {
WORKSPACE_MEMBER_DATA_SEED_COLUMNS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
import { TimelineActivitySeederService } from 'src/engine/workspace-manager/dev-seeder/data/services/timeline-activity-seeder.service';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflow-command-menu-items';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-command-menu-items.util';
import { getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-code-step-logic-functions.util';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflows.util';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
type RecordSeedConfig = {
@@ -304,6 +306,7 @@ export class DevSeederDataService {
private readonly timelineActivitySeederService: TimelineActivitySeederService,
private readonly fileStorageService: FileStorageService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly prefillLogicFunctionService: PrefillLogicFunctionService,
) {}
public async seed({
@@ -331,6 +334,14 @@ export class DevSeederDataService {
const { seeds: attachmentSeeds, fileSeedMetadata: attachmentFileMeta } =
generateAttachmentSeedsForWorkspace(workspaceId);
await this.prefillLogicFunctionService.ensureSeeded({
workspaceId,
definitions:
getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions(
workspaceId,
),
});
await this.coreDataSource.transaction(
async (entityManager: WorkspaceEntityManager) => {
await this.seedRecordsInBatches({
@@ -359,6 +370,7 @@ export class DevSeederDataService {
await prefillWorkflows(
entityManager,
workspaceId,
schemaName,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
@@ -26,6 +26,7 @@ import { DevSeederDataService } from 'src/engine/workspace-manager/dev-seeder/da
import { TimelineActivitySeederService } from 'src/engine/workspace-manager/dev-seeder/data/services/timeline-activity-seeder.service';
import { DevSeederMetadataService } from 'src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service';
import { DevSeederService } from 'src/engine/workspace-manager/dev-seeder/services/dev-seeder.service';
import { StandardObjectsPrefillModule } from 'src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill.module';
import { TwentyStandardApplicationModule } from 'src/engine/workspace-manager/twenty-standard-application/twenty-standard-application.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -48,6 +49,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
TypeOrmModule.forFeature([WorkspaceEntity, ObjectMetadataEntity]),
ObjectPermissionModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
StandardObjectsPrefillModule,
WorkspaceCacheModule,
WorkspaceMigrationModule,
TwentyStandardApplicationModule,
@@ -1,302 +0,0 @@
import { FieldActorSource } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type EntityManager } from 'typeorm';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { generateObjectRecordFields } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-object-record-fields';
export const QUICK_LEAD_WORKFLOW_ID = '8b213cac-a68b-4ffe-817a-3ec994e9932d';
export const QUICK_LEAD_WORKFLOW_VERSION_ID =
'ac67974f-c524-4288-9d88-af8515400b68';
export const prefillWorkflows = async (
entityManager: EntityManager,
schemaName: string,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
const { idByNameSingular: objectIdByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
const companyObjectMetadataId = objectIdByNameSingular['company'];
const personObjectMetadataId = objectIdByNameSingular['person'];
if (
!isDefined(companyObjectMetadataId) ||
!isDefined(personObjectMetadataId)
) {
throw new Error('Company or person object metadata not found');
}
const companyObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: companyObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const personObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: personObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(companyObjectMetadata) || !isDefined(personObjectMetadata)) {
throw new Error('Company or person object metadata not found');
}
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.workflow`, [
'id',
'name',
'lastPublishedVersionId',
'statuses',
'position',
'createdBySource',
'createdByWorkspaceMemberId',
'createdByName',
'createdByContext',
'updatedBySource',
'updatedByWorkspaceMemberId',
'updatedByName',
])
.orIgnore()
.values([
{
id: QUICK_LEAD_WORKFLOW_ID,
name: 'Quick Lead',
lastPublishedVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
statuses: ['ACTIVE'],
position: 1,
createdBySource: FieldActorSource.SYSTEM,
createdByWorkspaceMemberId: null,
createdByName: 'System',
createdByContext: {},
updatedBySource: FieldActorSource.SYSTEM,
updatedByWorkspaceMemberId: null,
updatedByName: 'System',
},
])
.returning('*')
.execute();
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.workflowVersion`, [
'id',
'name',
'trigger',
'steps',
'status',
'position',
'workflowId',
])
.orIgnore()
.values([
{
id: QUICK_LEAD_WORKFLOW_VERSION_ID,
name: 'v1',
trigger: JSON.stringify({
name: 'Launch manually',
type: 'MANUAL',
settings: {
outputSchema: {},
icon: 'IconUserPlus',
availability: { type: 'GLOBAL', locations: undefined },
},
nextStepIds: ['6e089bc9-aabd-435f-865f-f31c01c8f4a7'],
}),
steps: JSON.stringify([
{
id: '6e089bc9-aabd-435f-865f-f31c01c8f4a7',
name: 'Quick Lead Form',
type: 'FORM',
valid: false,
settings: {
input: [
{
id: '14d669f0-5249-4fa4-b0bb-f8bd408328d5',
name: 'firstName',
type: 'TEXT',
label: 'First name',
placeholder: 'Tim',
},
{
id: '4eb6ce85-d231-4aef-9837-744490c026d0',
name: 'lastName',
type: 'TEXT',
label: 'Last Name',
placeholder: 'Apple',
},
{
id: 'adbf0e9f-1427-49be-b4fb-092b34d97350',
name: 'email',
type: 'TEXT',
label: 'Email',
placeholder: 'timapple@apple.com',
},
{
id: '4ffc7992-9e65-4a4d-9baf-b52e62f2c273',
name: 'jobTitle',
type: 'TEXT',
label: 'Job title',
placeholder: 'CEO',
},
{
id: '42f11926-04ea-4924-94a4-2293cc748362',
name: 'companyName',
type: 'TEXT',
label: 'Company name',
placeholder: 'Apple',
},
{
id: 'd6ca80ee-26cd-466d-91bf-984d7205451c',
name: 'companyDomain',
type: 'TEXT',
label: 'Company domain',
placeholder: 'https://www.apple.com',
},
],
outputSchema: {
email: {
type: 'TEXT',
label: 'Email',
value: 'My text',
isLeaf: true,
},
jobTitle: {
type: 'TEXT',
label: 'Job title',
value: 'My text',
isLeaf: true,
},
lastName: {
type: 'TEXT',
label: 'Last Name',
value: 'My text',
isLeaf: true,
},
firstName: {
type: 'TEXT',
label: 'First name',
value: 'My text',
isLeaf: true,
},
companyName: {
type: 'TEXT',
label: 'Company name',
value: 'My text',
isLeaf: true,
},
companyDomain: {
type: 'TEXT',
label: 'Company domain',
value: 'My text',
isLeaf: true,
},
},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
__typename: 'WorkflowAction',
nextStepIds: ['0715b6cd-7cc1-4b98-971b-00f54dfe643b'],
},
{
id: '0715b6cd-7cc1-4b98-971b-00f54dfe643b',
name: 'Create Company',
type: 'CREATE_RECORD',
valid: false,
settings: {
input: {
objectName: 'company',
objectRecord: {
name: '{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.companyName}}',
domainName: {
primaryLinkUrl:
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.companyDomain}}',
primaryLinkLabel: '',
},
},
},
outputSchema: {
object: {
icon: 'IconBuildingSkyscraper',
label: 'Company',
value: 'A company',
isLeaf: true,
fieldIdName: 'id',
nameSingular: 'company',
},
_outputSchemaType: 'RECORD',
fields: generateObjectRecordFields({
objectMetadataInfo: {
flatObjectMetadata: companyObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
},
}),
},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
__typename: 'WorkflowAction',
nextStepIds: ['6f553ea7-b00e-4371-9d88-d8298568a246'],
},
{
id: '6f553ea7-b00e-4371-9d88-d8298568a246',
name: 'Create Person',
type: 'CREATE_RECORD',
valid: false,
settings: {
input: {
objectName: 'person',
objectRecord: {
name: {
lastName:
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.lastName}}',
firstName:
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.firstName}}',
},
emails: {
primaryEmail:
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.email}}',
additionalEmails: [],
},
companyId: '{{0715b6cd-7cc1-4b98-971b-00f54dfe643b.id}}',
},
},
outputSchema: {
fields: generateObjectRecordFields({
objectMetadataInfo: {
flatObjectMetadata: personObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
},
}),
},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
__typename: 'WorkflowAction',
nextStepIds: null,
},
]),
status: 'ACTIVE',
position: 1,
workflowId: QUICK_LEAD_WORKFLOW_ID,
},
])
.returning('*')
.execute();
};
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service';
import { type PrefilledWorkflowCodeStepLogicFunctionDefinition } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-code-step-logic-functions.util';
@Injectable()
export class PrefillLogicFunctionService {
constructor(
private readonly logicFunctionFromSourceService: LogicFunctionFromSourceService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async ensureSeeded({
workspaceId,
definitions,
}: {
workspaceId: string;
definitions: PrefilledWorkflowCodeStepLogicFunctionDefinition[];
}) {
const { flatLogicFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatLogicFunctionMaps'],
},
);
for (const definition of definitions) {
const existingLogicFunction = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: definition.id,
flatEntityMaps: flatLogicFunctionMaps,
});
if (isDefined(existingLogicFunction)) {
continue;
}
await this.logicFunctionFromSourceService.createOneFromSource({
workspaceId,
input: {
id: definition.id,
name: definition.name,
description: definition.description,
toolInputSchema: definition.toolInputSchema,
source: {
sourceHandlerCode: definition.sourceHandlerCode,
toolInputSchema: definition.toolInputSchema,
handlerName: 'main',
},
},
});
}
}
}
@@ -1,32 +0,0 @@
import { type DataSource, type EntityManager } from 'typeorm';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-companies';
import { prefillPeople } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-people';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflow-command-menu-items';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
export const standardObjectsPrefillData = async (
dataSource: DataSource,
schemaName: string,
workspaceId: string,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
dataSource.transaction(async (entityManager: EntityManager) => {
await prefillCompanies(entityManager, schemaName);
await prefillPeople(entityManager, schemaName);
await prefillWorkflows(
entityManager,
schemaName,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
await prefillWorkflowCommandMenuItems(entityManager, workspaceId);
});
};
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
@Module({
imports: [LogicFunctionModule, WorkspaceManyOrAllFlatEntityMapsCacheModule],
providers: [PrefillLogicFunctionService],
exports: [PrefillLogicFunctionService],
})
export class StandardObjectsPrefillModule {}
@@ -7,14 +7,14 @@ import {
FIGMA_ID,
NOTION_ID,
STRIPE_ID,
} from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-companies';
} from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-companies.util';
import {
BRIAN_CHESKY_ID,
DARIO_AMODEI_ID,
DYLAN_FIELD_ID,
IVAN_ZHAO_ID,
PATRICK_COLLISON_ID,
} from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-people';
} from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-people.util';
export const OPPORTUNITY_STRIPE_PLATFORM_MIGRATION_ID =
'822639e5-9bf7-40f1-8882-a11140362339';
@@ -7,7 +7,7 @@ import {
FIGMA_ID,
NOTION_ID,
STRIPE_ID,
} from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-companies';
} from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-companies.util';
export const BRIAN_CHESKY_ID = 'a2e78a5e-338b-46df-8811-fa08c7d19d35'; // Airbnb
export const DARIO_AMODEI_ID = '93c72d2e-e65c-44c4-99ad-f87f50349dcf'; // Anthropic
@@ -0,0 +1,336 @@
import { v5 as uuidv5 } from 'uuid';
const CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_LOGIC_FUNCTION_ID_NAMESPACE =
'd41b0a2d-aa97-44dc-ad5d-89774164af27';
export const getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionIds = (
workspaceId: string,
) => ({
extractDomainLogicFunctionId: uuidv5(
`${workspaceId}:create-company-when-adding-new-person:extract-domain`,
CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_LOGIC_FUNCTION_ID_NAMESPACE,
),
findMatchingCompanyByDomainLogicFunctionId: uuidv5(
`${workspaceId}:create-company-when-adding-new-person:find-matching-company-by-domain`,
CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_LOGIC_FUNCTION_ID_NAMESPACE,
),
isPersonalEmailLogicFunctionId: uuidv5(
`${workspaceId}:create-company-when-adding-new-person:is-personal-email`,
CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_LOGIC_FUNCTION_ID_NAMESPACE,
),
});
const EXTRACT_DOMAIN_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
email: {
type: 'string',
},
},
required: ['email'],
};
const IS_PERSONAL_EMAIL_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
primaryEmail: {
type: 'string',
},
},
required: ['primaryEmail'],
};
const FIND_MATCHING_COMPANY_BY_DOMAIN_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
companies: {
type: 'array',
items: {
type: 'object',
},
},
domain: {
type: 'string',
},
},
required: ['companies', 'domain'],
};
const EXTRACT_DOMAIN_LOGIC_FUNCTION_SOURCE = `const MULTI_PART_SUFFIXES = new Set([
'ac.uk',
'co.in',
'co.jp',
'co.kr',
'co.nz',
'co.uk',
'co.za',
'com.ar',
'com.au',
'com.br',
'com.cn',
'com.hk',
'com.mx',
'com.sg',
'com.tr',
'com.tw',
'com.ua',
'gov.uk',
'ne.jp',
'net.au',
'org.au',
'org.uk',
]);
const getRegistrableDomain = (host) => {
const normalizedHost = host.toLowerCase().replace(/^www\\./, '');
const parts = normalizedHost.split('.').filter(Boolean);
if (parts.length <= 2) {
return normalizedHost;
}
const lastTwo = parts.slice(-2).join('.');
if (MULTI_PART_SUFFIXES.has(lastTwo) && parts.length >= 3) {
return parts.slice(-3).join('.');
}
return lastTwo;
};
export const main = async (params) => {
const email =
typeof params?.email === 'string' ? params.email.trim().toLowerCase() : '';
const domainFromEmail = email.split('@')[1] ?? '';
const domain = domainFromEmail ? getRegistrableDomain(domainFromEmail) : '';
return {
url: domain ? \`https://\${domain}\` : '',
domain,
};
};`;
const IS_PERSONAL_EMAIL_LOGIC_FUNCTION_SOURCE = `const MULTI_PART_SUFFIXES = new Set([
'ac.uk',
'co.in',
'co.jp',
'co.kr',
'co.nz',
'co.uk',
'co.za',
'com.ar',
'com.au',
'com.br',
'com.cn',
'com.hk',
'com.mx',
'com.sg',
'com.tr',
'com.tw',
'com.ua',
'gov.uk',
'ne.jp',
'net.au',
'org.au',
'org.uk',
]);
const PERSONAL_EMAIL_DOMAINS = new Set([
'aol.com',
'att.net',
'btinternet.com',
'comcast.net',
'fastmail.com',
'free.fr',
'gmx.com',
'gmx.de',
'googlemail.com',
'hotmail.com',
'hotmail.fr',
'icloud.com',
'laposte.net',
'live.com',
'mac.com',
'mail.com',
'me.com',
'msn.com',
'orange.fr',
'outlook.com',
'outlook.fr',
'pm.me',
'proton.me',
'protonmail.com',
'sfr.fr',
'verizon.net',
'wanadoo.fr',
'yahoo.co.jp',
'yahoo.co.uk',
'yahoo.com',
'yandex.com',
'yandex.ru',
'zoho.com',
'gmail.com',
]);
const getRegistrableDomain = (host) => {
const normalizedHost = host.toLowerCase().replace(/^www\\./, '');
const parts = normalizedHost.split('.').filter(Boolean);
if (parts.length <= 2) {
return normalizedHost;
}
const lastTwo = parts.slice(-2).join('.');
if (MULTI_PART_SUFFIXES.has(lastTwo) && parts.length >= 3) {
return parts.slice(-3).join('.');
}
return lastTwo;
};
export const main = async (params) => {
const email =
typeof params?.primaryEmail === 'string'
? params.primaryEmail.trim().toLowerCase()
: '';
if (!email || !email.includes('@')) {
return { isPersonal: true };
}
const host = email.split('@')[1] ?? '';
const registrableDomain = host ? getRegistrableDomain(host) : '';
return {
isPersonal:
!registrableDomain ||
PERSONAL_EMAIL_DOMAINS.has(host) ||
PERSONAL_EMAIL_DOMAINS.has(registrableDomain),
};
};`;
const FIND_MATCHING_COMPANY_BY_DOMAIN_LOGIC_FUNCTION_SOURCE = `const MULTI_PART_SUFFIXES = new Set([
'ac.uk',
'co.in',
'co.jp',
'co.kr',
'co.nz',
'co.uk',
'co.za',
'com.ar',
'com.au',
'com.br',
'com.cn',
'com.hk',
'com.mx',
'com.sg',
'com.tr',
'com.tw',
'com.ua',
'gov.uk',
'ne.jp',
'net.au',
'org.au',
'org.uk',
]);
const normalizeHost = (value) => {
if (typeof value !== 'string') {
return '';
}
return value
.trim()
.toLowerCase()
.replace(/^https?:\\/\\//, '')
.replace(/^www\\./, '')
.split('/')[0]
.split('?')[0]
.split('#')[0]
.split(':')[0];
};
const getRegistrableDomain = (value) => {
const normalizedHost = normalizeHost(value);
const parts = normalizedHost.split('.').filter(Boolean);
if (parts.length <= 2) {
return normalizedHost;
}
const lastTwo = parts.slice(-2).join('.');
if (MULTI_PART_SUFFIXES.has(lastTwo) && parts.length >= 3) {
return parts.slice(-3).join('.');
}
return lastTwo;
};
export const main = async (params) => {
const domain = getRegistrableDomain(params?.domain);
const companies = Array.isArray(params?.companies) ? params.companies : [];
const matchingCompany = companies.find((company) => {
const companyDomain = getRegistrableDomain(
company?.domainName?.primaryLinkUrl,
);
return companyDomain !== '' && companyDomain === domain;
});
const companyId =
typeof matchingCompany?.id === 'string' ? matchingCompany.id : '';
return {
companyId,
hasMatch: companyId !== '',
};
};`;
export type PrefilledWorkflowCodeStepLogicFunctionDefinition = {
id: string;
name: string;
description: string;
sourceHandlerCode: string;
toolInputSchema: object;
};
export const getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions =
(workspaceId: string): PrefilledWorkflowCodeStepLogicFunctionDefinition[] => {
const {
extractDomainLogicFunctionId,
findMatchingCompanyByDomainLogicFunctionId,
isPersonalEmailLogicFunctionId,
} =
getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionIds(workspaceId);
return [
{
id: extractDomainLogicFunctionId,
name: 'Extract domain from email',
description:
'Extracts a normalized company domain and URL from a person email address.',
sourceHandlerCode: EXTRACT_DOMAIN_LOGIC_FUNCTION_SOURCE,
toolInputSchema: EXTRACT_DOMAIN_TOOL_INPUT_SCHEMA,
},
{
id: findMatchingCompanyByDomainLogicFunctionId,
name: 'Find matching company by domain',
description:
'Finds an existing company whose website matches a normalized registrable domain.',
sourceHandlerCode:
FIND_MATCHING_COMPANY_BY_DOMAIN_LOGIC_FUNCTION_SOURCE,
toolInputSchema: FIND_MATCHING_COMPANY_BY_DOMAIN_TOOL_INPUT_SCHEMA,
},
{
id: isPersonalEmailLogicFunctionId,
name: 'Is this a personal email?',
description:
'Detects whether an email address belongs to a common personal email provider.',
sourceHandlerCode: IS_PERSONAL_EMAIL_LOGIC_FUNCTION_SOURCE,
toolInputSchema: IS_PERSONAL_EMAIL_TOOL_INPUT_SCHEMA,
},
];
};
@@ -2,7 +2,7 @@ import { type EntityManager } from 'typeorm';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { QUICK_LEAD_WORKFLOW_VERSION_ID } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
import { QUICK_LEAD_WORKFLOW_VERSION_ID } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflows.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
const QUICK_LEAD_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER =
@@ -0,0 +1,762 @@
import { FieldActorSource } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type EntityManager } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event';
import { generateObjectRecordFields } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-object-record-fields';
import { getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionIds } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-code-step-logic-functions.util';
export const QUICK_LEAD_WORKFLOW_ID = '8b213cac-a68b-4ffe-817a-3ec994e9932d';
export const QUICK_LEAD_WORKFLOW_VERSION_ID =
'ac67974f-c524-4288-9d88-af8515400b68';
export const CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_WORKFLOW_ID =
'887c6c06-fbc5-4b45-8d6b-f7b6b0f40b12';
export const CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_WORKFLOW_VERSION_ID =
'0f276d7e-a950-41ab-ad98-35e80753dc58';
export const CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_AUTOMATED_TRIGGER_ID =
'c54f5990-13a3-4c3b-b75d-df09e7843036';
export const prefillWorkflows = async (
entityManager: EntityManager,
workspaceId: string,
schemaName: string,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
const {
extractDomainLogicFunctionId,
findMatchingCompanyByDomainLogicFunctionId,
isPersonalEmailLogicFunctionId,
} = getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionIds(workspaceId);
const { idByNameSingular: objectIdByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
const companyObjectMetadataId = objectIdByNameSingular['company'];
const personObjectMetadataId = objectIdByNameSingular['person'];
if (
!isDefined(companyObjectMetadataId) ||
!isDefined(personObjectMetadataId)
) {
throw new Error('Company or person object metadata not found');
}
const companyObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: companyObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const personObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: personObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(companyObjectMetadata) || !isDefined(personObjectMetadata)) {
throw new Error('Company or person object metadata not found');
}
const companyDomainNameFieldMetadata = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).find(
(fieldMetadata) =>
fieldMetadata?.objectMetadataId === companyObjectMetadataId &&
fieldMetadata?.name === 'domainName',
);
if (!isDefined(companyDomainNameFieldMetadata)) {
throw new Error('Company domainName field metadata not found');
}
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.workflow`, [
'id',
'name',
'lastPublishedVersionId',
'statuses',
'position',
'createdBySource',
'createdByWorkspaceMemberId',
'createdByName',
'createdByContext',
'updatedBySource',
'updatedByWorkspaceMemberId',
'updatedByName',
])
.orIgnore()
.values([
{
id: QUICK_LEAD_WORKFLOW_ID,
name: 'Quick Lead',
lastPublishedVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
statuses: ['ACTIVE'],
position: 1,
createdBySource: FieldActorSource.SYSTEM,
createdByWorkspaceMemberId: null,
createdByName: 'System',
createdByContext: {},
updatedBySource: FieldActorSource.SYSTEM,
updatedByWorkspaceMemberId: null,
updatedByName: 'System',
},
{
id: CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_WORKFLOW_ID,
name: 'Create company when adding a new person',
lastPublishedVersionId:
CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_WORKFLOW_VERSION_ID,
statuses: ['ACTIVE'],
position: 2,
createdBySource: FieldActorSource.SYSTEM,
createdByWorkspaceMemberId: null,
createdByName: 'System',
createdByContext: {},
updatedBySource: FieldActorSource.SYSTEM,
updatedByWorkspaceMemberId: null,
updatedByName: 'System',
},
])
.returning('*')
.execute();
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.workflowVersion`, [
'id',
'name',
'trigger',
'steps',
'status',
'position',
'workflowId',
])
.orIgnore()
.values([
{
id: QUICK_LEAD_WORKFLOW_VERSION_ID,
name: 'v1',
trigger: JSON.stringify({
name: 'Launch manually',
type: 'MANUAL',
settings: {
outputSchema: {},
icon: 'IconUserPlus',
availability: { type: 'GLOBAL', locations: undefined },
},
nextStepIds: ['6e089bc9-aabd-435f-865f-f31c01c8f4a7'],
}),
steps: JSON.stringify([
{
id: '6e089bc9-aabd-435f-865f-f31c01c8f4a7',
name: 'Quick Lead Form',
type: 'FORM',
valid: false,
settings: {
input: [
{
id: '14d669f0-5249-4fa4-b0bb-f8bd408328d5',
name: 'firstName',
type: 'TEXT',
label: 'First name',
placeholder: 'Tim',
},
{
id: '4eb6ce85-d231-4aef-9837-744490c026d0',
name: 'lastName',
type: 'TEXT',
label: 'Last Name',
placeholder: 'Apple',
},
{
id: 'adbf0e9f-1427-49be-b4fb-092b34d97350',
name: 'email',
type: 'TEXT',
label: 'Email',
placeholder: 'timapple@apple.com',
},
{
id: '4ffc7992-9e65-4a4d-9baf-b52e62f2c273',
name: 'jobTitle',
type: 'TEXT',
label: 'Job title',
placeholder: 'CEO',
},
{
id: '42f11926-04ea-4924-94a4-2293cc748362',
name: 'companyName',
type: 'TEXT',
label: 'Company name',
placeholder: 'Apple',
},
{
id: 'd6ca80ee-26cd-466d-91bf-984d7205451c',
name: 'companyDomain',
type: 'TEXT',
label: 'Company domain',
placeholder: 'https://www.apple.com',
},
],
outputSchema: {
email: {
type: 'TEXT',
label: 'Email',
value: 'My text',
isLeaf: true,
},
jobTitle: {
type: 'TEXT',
label: 'Job title',
value: 'My text',
isLeaf: true,
},
lastName: {
type: 'TEXT',
label: 'Last Name',
value: 'My text',
isLeaf: true,
},
firstName: {
type: 'TEXT',
label: 'First name',
value: 'My text',
isLeaf: true,
},
companyName: {
type: 'TEXT',
label: 'Company name',
value: 'My text',
isLeaf: true,
},
companyDomain: {
type: 'TEXT',
label: 'Company domain',
value: 'My text',
isLeaf: true,
},
},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
__typename: 'WorkflowAction',
nextStepIds: ['0715b6cd-7cc1-4b98-971b-00f54dfe643b'],
},
{
id: '0715b6cd-7cc1-4b98-971b-00f54dfe643b',
name: 'Create Company',
type: 'CREATE_RECORD',
valid: false,
settings: {
input: {
objectName: 'company',
objectRecord: {
name: '{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.companyName}}',
domainName: {
primaryLinkUrl:
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.companyDomain}}',
primaryLinkLabel: '',
},
},
},
outputSchema: {
object: {
icon: 'IconBuildingSkyscraper',
label: 'Company',
value: 'A company',
isLeaf: true,
fieldIdName: 'id',
nameSingular: 'company',
},
_outputSchemaType: 'RECORD',
fields: generateObjectRecordFields({
objectMetadataInfo: {
flatObjectMetadata: companyObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
},
}),
},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
__typename: 'WorkflowAction',
nextStepIds: ['6f553ea7-b00e-4371-9d88-d8298568a246'],
},
{
id: '6f553ea7-b00e-4371-9d88-d8298568a246',
name: 'Create Person',
type: 'CREATE_RECORD',
valid: false,
settings: {
input: {
objectName: 'person',
objectRecord: {
name: {
lastName:
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.lastName}}',
firstName:
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.firstName}}',
},
emails: {
primaryEmail:
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.email}}',
additionalEmails: [],
},
companyId: '{{0715b6cd-7cc1-4b98-971b-00f54dfe643b.id}}',
},
},
outputSchema: {
fields: generateObjectRecordFields({
objectMetadataInfo: {
flatObjectMetadata: personObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
},
}),
},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
__typename: 'WorkflowAction',
nextStepIds: null,
},
]),
status: 'ACTIVE',
position: 1,
workflowId: QUICK_LEAD_WORKFLOW_ID,
},
{
id: CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_WORKFLOW_VERSION_ID,
name: 'v1',
trigger: JSON.stringify({
name: 'Record is created or updated',
type: 'DATABASE_EVENT',
settings: {
eventName: 'person.upserted',
fields: ['emails'],
outputSchema: generateFakeObjectRecordEvent(
{
flatObjectMetadata: personObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
},
DatabaseEventAction.UPSERTED,
),
},
nextStepIds: ['c30d7cbe-00e0-4966-bc1a-99b0a11a2cca'],
}),
steps: JSON.stringify([
{
id: 'c30d7cbe-00e0-4966-bc1a-99b0a11a2cca',
name: 'Is this a personal email?',
type: 'CODE',
valid: false,
position: {
x: 227.25,
y: 130,
},
settings: {
input: {
logicFunctionId: isPersonalEmailLogicFunctionId,
logicFunctionInput: {
primaryEmail:
'{{trigger.properties.after.emails.primaryEmail}}',
},
},
outputSchema: {
isPersonal: {
type: 'boolean',
label: 'isPersonal',
value: true,
isLeaf: true,
},
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
nextStepIds: ['01f3db05-aae5-4e4b-b361-96684f09c704'],
},
{
id: '01f3db05-aae5-4e4b-b361-96684f09c704',
name: 'If business email',
type: 'FILTER',
valid: false,
position: {
x: 249.25,
y: 260,
},
settings: {
input: {
stepFilters: [
{
id: '0e595385-9e18-4869-abfd-cf72952b124c',
type: 'boolean',
value: 'false',
operand: 'IS',
isFullRecord: false,
stepOutputKey:
'{{c30d7cbe-00e0-4966-bc1a-99b0a11a2cca.isPersonal}}',
stepFilterGroupId: '5204d5f5-7b23-428c-9f84-c37971d497d3',
positionInStepFilterGroup: 0,
},
],
stepFilterGroups: [
{
id: '5204d5f5-7b23-428c-9f84-c37971d497d3',
logicalOperator: 'AND',
},
],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
nextStepIds: ['1b01193b-8300-4d79-940b-44464bf45505'],
},
{
id: '1b01193b-8300-4d79-940b-44464bf45505',
name: 'Extract domain from email',
type: 'CODE',
valid: false,
position: {
x: 219.75,
y: 390,
},
settings: {
input: {
logicFunctionId: extractDomainLogicFunctionId,
logicFunctionInput: {
email: '{{trigger.properties.after.emails.primaryEmail}}',
},
},
outputSchema: {
url: {
icon: 'IconVariable',
type: 'string',
label: 'url',
value: 'https://twenty.com',
isLeaf: true,
},
domain: {
icon: 'IconVariable',
type: 'string',
label: 'domain',
value: 'twenty.com',
isLeaf: true,
},
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
nextStepIds: ['becb3acf-79bb-4672-8a42-3696e94957b5'],
},
{
id: 'becb3acf-79bb-4672-8a42-3696e94957b5',
name: 'Search Company',
type: 'FIND_RECORDS',
valid: false,
position: {
x: 247.75,
y: 520,
},
settings: {
input: {
limit: 25,
filter: {
recordFilters: [
{
id: 'a9b917a0-5c4c-4e8f-bf91-160d0b888693',
type: 'LINKS',
label: 'Domain Name',
value: '{{1b01193b-8300-4d79-940b-44464bf45505.domain}}',
operand: 'CONTAINS',
displayValue:
'{{1b01193b-8300-4d79-940b-44464bf45505.domain}}',
subFieldName: 'primaryLinkUrl',
fieldMetadataId: companyDomainNameFieldMetadata.id,
recordFilterGroupId:
'194e151c-cf46-4e8f-a48b-649c36082dfa',
},
],
recordFilterGroups: [
{
id: '194e151c-cf46-4e8f-a48b-649c36082dfa',
logicalOperator: 'AND',
},
],
},
objectName: 'company',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
nextStepIds: ['9d0b6ef2-aad2-4853-92e1-95f2abf10d5b'],
},
{
id: '9d0b6ef2-aad2-4853-92e1-95f2abf10d5b',
name: 'Find exact company match',
type: 'CODE',
valid: false,
position: {
x: 247.75,
y: 650,
},
settings: {
input: {
logicFunctionId: findMatchingCompanyByDomainLogicFunctionId,
logicFunctionInput: {
companies: '{{becb3acf-79bb-4672-8a42-3696e94957b5.all}}',
domain: '{{1b01193b-8300-4d79-940b-44464bf45505.domain}}',
},
},
outputSchema: {
companyId: {
type: 'string',
label: 'companyId',
value: '00000000-0000-0000-0000-000000000000',
isLeaf: true,
},
hasMatch: {
type: 'boolean',
label: 'hasMatch',
value: true,
isLeaf: true,
},
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
nextStepIds: ['0c99a900-656a-40e8-977e-5a7357be33b9'],
},
{
id: '0c99a900-656a-40e8-977e-5a7357be33b9',
name: 'If a company already exists',
type: 'IF_ELSE',
valid: false,
position: {
x: 216.25,
y: 780,
},
settings: {
input: {
branches: [
{
id: '1344c151-15ff-40e2-a1a3-925fabaf5b1c',
nextStepIds: ['ffdd4271-75d4-4805-b1f8-2167a113c3b2'],
filterGroupId: 'f5c41047-2a6e-49fb-968a-fa7789a90ee5',
},
{
id: 'fe6dd152-1103-4324-af51-3ff994d1f8a7',
nextStepIds: ['ddafb9db-a94f-40b9-a5c9-becce857edf7'],
},
],
stepFilters: [
{
id: '290cc6a3-08fd-4be5-b42e-966d0bb90ff7',
type: 'boolean',
value: 'true',
operand: 'IS',
isFullRecord: false,
stepOutputKey:
'{{9d0b6ef2-aad2-4853-92e1-95f2abf10d5b.hasMatch}}',
stepFilterGroupId: 'f5c41047-2a6e-49fb-968a-fa7789a90ee5',
positionInStepFilterGroup: 0,
},
],
stepFilterGroups: [
{
id: 'f5c41047-2a6e-49fb-968a-fa7789a90ee5',
logicalOperator: 'AND',
},
],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
},
{
id: 'ffdd4271-75d4-4805-b1f8-2167a113c3b2',
name: 'Attach person to existing company',
type: 'UPDATE_RECORD',
valid: false,
position: {
x: 0,
y: 910,
},
settings: {
input: {
objectName: 'person',
objectRecord: {
companyId:
'{{9d0b6ef2-aad2-4853-92e1-95f2abf10d5b.companyId}}',
},
fieldsToUpdate: ['companyId'],
objectRecordId: '{{trigger.properties.after.id}}',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
},
{
id: 'ddafb9db-a94f-40b9-a5c9-becce857edf7',
name: 'Create a new company',
type: 'CREATE_RECORD',
valid: false,
position: {
x: 440,
y: 910,
},
settings: {
input: {
objectName: 'company',
objectRecord: {
name: '{{1b01193b-8300-4d79-940b-44464bf45505.domain}}',
domainName: {
primaryLinkUrl:
'{{1b01193b-8300-4d79-940b-44464bf45505.url}}',
primaryLinkLabel:
'{{1b01193b-8300-4d79-940b-44464bf45505.domain}}',
},
},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
nextStepIds: ['d5d5d6e1-391f-4142-83c1-670f7087f079'],
},
{
id: 'd5d5d6e1-391f-4142-83c1-670f7087f079',
name: 'Attach person to this company',
type: 'UPDATE_RECORD',
valid: false,
position: {
x: 420.5,
y: 1040,
},
settings: {
input: {
objectName: 'person',
objectRecord: {
companyId: '{{ddafb9db-a94f-40b9-a5c9-becce857edf7.id}}',
},
fieldsToUpdate: ['companyId'],
objectRecordId: '{{trigger.properties.after.id}}',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
__typename: 'WorkflowAction',
},
]),
status: 'ACTIVE',
position: 2,
workflowId: CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_WORKFLOW_ID,
},
])
.returning('*')
.execute();
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.workflowAutomatedTrigger`, [
'id',
'workflowId',
'type',
'settings',
])
.orIgnore()
.values([
{
id: CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_AUTOMATED_TRIGGER_ID,
workflowId: CREATE_COMPANY_WHEN_ADDING_NEW_PERSON_WORKFLOW_ID,
type: 'DATABASE_EVENT',
settings: {
eventName: 'person.upserted',
fields: ['emails'],
},
},
])
.execute();
};
@@ -0,0 +1,235 @@
import { createOneSelectFieldMetadataForIntegrationTests } from 'test/integration/metadata/suites/field-metadata/utils/create-one-select-field-metadata-for-integration-tests.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { createManyViewGroups } from 'test/integration/metadata/suites/view-group/utils/create-many-view-groups.util';
import { deleteOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/delete-one-view-group.util';
import { destroyOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/destroy-one-view-group.util';
import { updateManyViewGroups } from 'test/integration/metadata/suites/view-group/utils/update-many-view-groups.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { isDefined } from 'twenty-shared/utils';
import { type CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
describe('View Group Resolver - Successful Update Many Operations - v2', () => {
let testSetup: {
testViewId: string;
testObjectMetadataId: string;
};
let createdViewGroupIds: string[] = [];
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'myUpdateManyGroupTestObjectV2',
namePlural: 'myUpdateManyGroupTestObjectsV2',
labelSingular: 'My Update Many Group Test Object v2',
labelPlural: 'My Update Many Group Test Objects v2',
icon: 'Icon123',
},
});
const { selectFieldMetadataId } =
await createOneSelectFieldMetadataForIntegrationTests({
input: {
objectMetadataId,
},
});
const {
data: {
createView: { id: testViewId },
},
} = await createOneView({
input: {
icon: 'icon123',
objectMetadataId,
name: 'TestViewForUpdateManyGroups',
mainGroupByFieldMetadataId: selectFieldMetadataId,
},
expectToFail: false,
});
testSetup = {
testViewId,
testObjectMetadataId: objectMetadataId,
};
});
afterAll(async () => {
await updateOneObjectMetadata({
input: {
idToUpdate: testSetup.testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testSetup.testObjectMetadataId },
});
});
afterEach(async () => {
for (const viewGroupId of createdViewGroupIds) {
if (isDefined(viewGroupId)) {
const {
data: { deleteViewGroup },
} = await deleteOneViewGroup({
expectToFail: false,
input: {
id: viewGroupId,
},
});
expect(deleteViewGroup.deletedAt).not.toBeNull();
await destroyOneViewGroup({
expectToFail: false,
input: {
id: viewGroupId,
},
});
}
}
createdViewGroupIds = [];
});
it('should batch-update positions of multiple view groups at once', async () => {
const createInputs: CreateViewGroupInput[] = [
{
viewId: testSetup.testViewId,
position: 0,
isVisible: true,
fieldValue: 'Group A',
},
{
viewId: testSetup.testViewId,
position: 1,
isVisible: true,
fieldValue: 'Group B',
},
{
viewId: testSetup.testViewId,
position: 2,
isVisible: true,
fieldValue: 'Group C',
},
];
const {
data: { createManyViewGroups: createdGroups },
} = await createManyViewGroups({
inputs: createInputs,
expectToFail: false,
});
createdViewGroupIds = createdGroups.map(
(viewGroup: { id: string }) => viewGroup.id,
);
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [
{ id: createdGroups[0].id, update: { position: 2 } },
{ id: createdGroups[1].id, update: { position: 0 } },
{ id: createdGroups[2].id, update: { position: 1 } },
],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(3);
expect(updatedGroups[0]).toMatchObject({
id: createdGroups[0].id,
position: 2,
});
expect(updatedGroups[1]).toMatchObject({
id: createdGroups[1].id,
position: 0,
});
expect(updatedGroups[2]).toMatchObject({
id: createdGroups[2].id,
position: 1,
});
});
it('should batch-update visibility of multiple view groups at once', async () => {
const createInputs: CreateViewGroupInput[] = [
{
viewId: testSetup.testViewId,
position: 0,
isVisible: true,
fieldValue: 'Visible Group',
},
{
viewId: testSetup.testViewId,
position: 1,
isVisible: true,
fieldValue: 'To Be Hidden Group',
},
];
const {
data: { createManyViewGroups: createdGroups },
} = await createManyViewGroups({
inputs: createInputs,
expectToFail: false,
});
createdViewGroupIds = createdGroups.map(
(viewGroup: { id: string }) => viewGroup.id,
);
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [
{ id: createdGroups[0].id, update: { isVisible: false } },
{
id: createdGroups[1].id,
update: { isVisible: false, position: 5 },
},
],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(2);
expect(updatedGroups[0]).toMatchObject({
id: createdGroups[0].id,
isVisible: false,
});
expect(updatedGroups[1]).toMatchObject({
id: createdGroups[1].id,
isVisible: false,
position: 5,
});
});
it('should return empty array for empty inputs', async () => {
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(0);
});
});
@@ -0,0 +1,23 @@
import gql from 'graphql-tag';
import { VIEW_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { type UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
export const updateManyViewGroupsQueryFactory = ({
gqlFields = VIEW_GROUP_GQL_FIELDS,
inputs,
}: {
gqlFields?: string;
inputs: UpdateViewGroupInput[];
}) => ({
query: gql`
mutation UpdateManyViewGroups($inputs: [UpdateViewGroupInput!]!) {
updateManyViewGroups(inputs: $inputs) {
${gqlFields}
}
}
`,
variables: {
inputs,
},
});
@@ -0,0 +1,45 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { updateManyViewGroupsQueryFactory } from 'test/integration/metadata/suites/view-group/utils/update-many-view-groups-query-factory.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
import { type ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
export const updateManyViewGroups = async ({
inputs,
gqlFields,
expectToFail,
}: {
inputs: UpdateViewGroupInput[];
gqlFields?: string;
expectToFail?: boolean;
}): CommonResponseBody<{
updateManyViewGroups: ViewGroupEntity[];
}> => {
const graphqlOperation = updateManyViewGroupsQueryFactory({
inputs,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'View Groups batch update should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage:
'View Groups batch update has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
+39
View File
@@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# DB
*.sqlite
@@ -0,0 +1,48 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
+4
View File
@@ -0,0 +1,4 @@
# Twenty-Website
This is used for the marketing website (twenty.com).
This is not related in any way to the main app, which you can find in twenty-front and twenty-server.
@@ -0,0 +1,5 @@
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
const nextConfig: LinariaConfig = { reactCompiler: true };
module.exports = withLinaria(nextConfig);
+27
View File
@@ -0,0 +1,27 @@
{
"name": "twenty-website-new",
"private": true,
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-website-new node ../../node_modules/nx/bin/nx.js",
"dev": "npx next dev",
"build": "npx next build",
"start": "npx next start"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@base-ui/utils": "^0.2.6",
"@linaria/core": "^7.0.0",
"@linaria/react": "^7.0.1",
"@wyw-in-js/babel-preset": "^0.8.1",
"next": "16.1.7",
"next-with-linaria": "^1.3.0",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0"
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "twenty-website-new",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-website-new/src",
"projectType": "application",
"tags": ["scope:website"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/.next"],
"options": {
"cwd": "{projectRoot}",
"command": "npx next build"
},
"dependsOn": ["^build"]
},
"dev": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"cwd": "{projectRoot}",
"command": "npx next dev"
}
},
"start": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["build"],
"options": {
"cwd": "{projectRoot}",
"command": "npx next start"
}
},
"lint": {},
"lint:diff-with-main": {},
"typecheck": {},
"fmt": {
"options": {
"files": "."
},
"configurations": {
"fix": {}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,12 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'Build', fontFamily: 'sans' },
{ text: ' your Enterprise CRM ', fontFamily: 'serif' },
{ text: 'at AI Speed', fontFamily: 'sans' },
],
body: {
text: 'Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.',
},
};
@@ -0,0 +1,23 @@
import { ProblemDataType } from '@/sections/Problem/types';
export const PROBLEM_DATA: ProblemDataType = {
eyebrow: { heading: { text: 'The Problem:', fontFamily: 'sans' } },
heading: [
{ text: 'A custom CRM gives your org an edge, ', fontFamily: 'serif' },
{ text: 'but building one comes with tradeoffs', fontFamily: 'sans' },
],
points: [
{
heading: { text: 'The Giant Monolith', fontFamily: 'sans' },
body: {
text: 'Proprietary languages, slow deployment cycles, and "black box" logic.',
},
},
{
heading: { text: 'The In-house Burden', fontFamily: 'sans' },
body: {
text: "It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.",
},
},
],
};
@@ -0,0 +1,34 @@
import { ThreeCardsFeatureCardsDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
eyebrow: {
heading: {
text: 'Skip the clunky UX that always comes with custom.',
fontFamily: 'sans',
},
},
heading: [
{ text: 'Make your GTM team happy with ', fontFamily: 'serif' },
{ text: "a CRM they'll love", fontFamily: 'sans' },
],
featureCards: [
{
heading: { text: 'Familiar, modern interface', fontFamily: 'sans' },
body: {
text: "Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.",
},
},
{
heading: { text: 'Fast path to action', fontFamily: 'sans' },
body: {
text: 'Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.',
},
},
{
heading: { text: 'Live data and AI Built', fontFamily: 'sans' },
body: {
text: 'Everything updates in real time, with AI chat always ready to help you move faster.',
},
},
],
};
@@ -0,0 +1,49 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
heading: { text: 'Stop settling for trade-offs.', fontFamily: 'sans' },
},
heading: [
{ text: 'Assemble, iterate and adapt a robust CRM,', fontFamily: 'serif' },
{ text: " that's quick to flex", fontFamily: 'sans' },
],
body: {
text: 'Compose your CRM and interal apps with a single exesibility toolkit.',
},
illustrationCards: [
{
heading: { text: 'Production grade quality', fontFamily: 'sans' },
body: {
text: "What stood out with Twenty wasn't just flexibility it was the quality. The system feels stable, polished, and ready for real teams with real stakes.",
},
benefits: undefined,
attribution: {
role: { text: 'Head of Engineering' },
company: { text: 'Mid-Market Fintech' },
},
},
{
heading: { text: 'AI for rapid iterations', fontFamily: 'sans' },
body: {
text: 'Twenty removes friction from iteration and lets us adapt our CRM as the business changes.',
},
benefits: undefined,
attribution: {
role: { text: 'Head of Engineering' },
company: { text: 'Mid-Market Fintech' },
},
},
{
heading: { text: 'Control without drag', fontFamily: 'sans' },
body: {
text: "It's a modern, open-source alternative to Salesforce/Hubspot that lets you manage your customer relationships in a secure + privacy-first way.",
},
benefits: undefined,
attribution: {
role: { text: 'Head of Engineering' },
company: { text: 'Mid-Market Fintech' },
},
},
],
};
@@ -0,0 +1,7 @@
import { TrustedByDataType } from '@/sections/TrustedBy/types';
export const TRUSTED_BY_DATA: TrustedByDataType = {
separator: { text: 'trusted by' },
clientCountLabel: { text: '+ 10 000 other clients' },
logos: [{ icon: 'flexport' }, { icon: 'zapier' }],
};
@@ -0,0 +1,88 @@
import { HERO_DATA } from '@/app/(home)/constants/hero';
import { PROBLEM_DATA } from '@/app/(home)/constants/problem';
import { THREE_CARDS_FEATURE_DATA } from '@/app/(home)/constants/three-cards-feature';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/(home)/constants/three-cards-illustration';
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { Problem } from '@/sections/Problem/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
export default function HomePage() {
return (
<>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Home} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Home} body={HERO_DATA.body} size="sm" />
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
</Hero.Cta>
<Hero.HomeVisual />
</Hero.Root>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
<Problem.Root>
<Problem.Visual />
<Problem.Content>
<Eyebrow
colorScheme="primary"
heading={PROBLEM_DATA.eyebrow.heading}
/>
<Problem.Heading segments={PROBLEM_DATA.heading} />
<Problem.Points points={PROBLEM_DATA.points} />
</Problem.Content>
</Problem.Root>
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Home} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
/>
</ThreeCards.Root>
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Home} align="center">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_FEATURE_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_FEATURE_DATA.heading}
size="lg"
weight="light"
/>
</ThreeCards.Intro>
<ThreeCards.FeatureCards
featureCards={THREE_CARDS_FEATURE_DATA.featureCards}
/>
</ThreeCards.Root>
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

@@ -0,0 +1,89 @@
import { Footer } from '@/sections/Footer/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { cssVariables } from '@/theme/css-variables';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
import { Aleo, Azeret_Mono, Host_Grotesk } from 'next/font/google';
const hostGrotesk = Host_Grotesk({
subsets: ['latin'],
weight: ['300', '400', '500', '600'],
variable: '--font-sans',
display: 'swap',
});
const aleo = Aleo({
subsets: ['latin'],
weight: ['300'],
variable: '--font-serif',
display: 'swap',
});
const azeretMono = Azeret_Mono({
subsets: ['latin'],
weight: ['300', '500'],
variable: '--font-mono',
display: 'swap',
});
css`
:global(*),
:global(*::before),
:global(*::after) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(html) {
background-color: ${theme.colors.primary.background[100]};
}
:global(body) {
color: ${theme.colors.primary.text[100]};
display: flex;
font-family: ${theme.font.family.sans};
flex-direction: column;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`;
const StyledMain = styled.main`
flex-grow: 1;
`;
export const metadata: Metadata = {
title: 'Twenty — Open Source CRM',
description: 'Modular, scalable open source CRM for modern teams.',
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable}`}
>
<Menu.Root>
<Menu.Logo />
<Menu.Nav />
<Menu.Social />
<Menu.Cta />
</Menu.Root>
<StyledMain>{children}</StyledMain>
<Footer.Root>
<Footer.Logo />
<Footer.Nav />
<Footer.Bottom>
<Footer.Social />
</Footer.Bottom>
</Footer.Root>
</body>
</html>
);
}
@@ -0,0 +1,11 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'Become a ', fontFamily: 'serif' },
{ text: 'Twenty Partner', fontFamily: 'sans' },
],
body: {
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
},
};
@@ -0,0 +1,53 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
heading: {
text: 'Which partner program is right for you?',
fontFamily: 'sans',
},
},
heading: [
{ text: 'Find the program that fits your business ', fontFamily: 'serif' },
{ text: 'and unlock new opportunities with Twenty', fontFamily: 'sans' },
],
body: { text: '' },
illustrationCards: [
{
heading: { text: 'Technology Partners', fontFamily: 'sans' },
body: {
text: 'Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.',
},
benefits: [
{ text: 'Co-marketing opportunities' },
{ text: 'Listing on Twenty integrations page' },
{ text: 'Soon: earn revenue' },
],
attribution: undefined,
},
{
heading: { text: 'Content & Community Partners', fontFamily: 'sans' },
body: {
text: "Share Twenty with your audience and help shape the future of open-source CRM. We're looking for creators, educators, and community builders who want to showcase great software.",
},
benefits: [
{ text: 'Revenue share for referred customers' },
{ text: 'Exclusive content collaboration opportunities' },
{ text: 'Marketing assets & brand resources' },
],
attribution: undefined,
},
{
heading: { text: 'Solutions Partners', fontFamily: 'sans' },
body: {
text: 'Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.',
},
benefits: [
{ text: 'Resale discounts & revenue share' },
{ text: 'Marketplace listing' },
{ text: 'Dedicated partner support' },
],
attribution: undefined,
},
],
};
@@ -0,0 +1,68 @@
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { HERO_DATA } from '@/app/partner/constants/hero';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/partner/constants/three-cards-illustration';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
export default function PartnerPage() {
return (
<>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Partner} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Partner} body={HERO_DATA.body} />
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Become a partner"
type="anchor"
variant="outlined"
/>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Find a partner"
type="anchor"
variant="contained"
/>
</Hero.Cta>
<Hero.Illustration
src="https://app.endlesstools.io/embed/1c6c8259-3276-4cf2-84d8-6b7e87e7ec95"
title="Endless Tools Editor"
backgroundColor={theme.colors.secondary.background[100]}
/>
</Hero.Root>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
<ThreeCards.Root backgroundColor={theme.colors.secondary.background[5]}>
<ThreeCards.Intro align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
variant="simple"
/>
</ThreeCards.Root>
</>
);
}
@@ -0,0 +1,11 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'A pricing that scales ', fontFamily: 'serif' },
{ text: 'with your need', fontFamily: 'sans' },
],
body: {
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
},
};
@@ -0,0 +1,15 @@
import { HERO_DATA } from '@/app/pricing/constants/hero';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { theme } from '@/theme';
export default function PricingPage() {
return (
<>
<Hero.Root backgroundColor={theme.colors.secondary.background[5]}>
<Hero.Heading page={Pages.Pricing} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Pricing} body={HERO_DATA.body} />
</Hero.Root>
</>
);
}
@@ -0,0 +1,11 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'The CRM that moves ', fontFamily: 'serif' },
{ text: 'as fast as you do', fontFamily: 'sans' },
],
body: {
text: 'Modern interface. AI assistance. All the features you need, ready from day one.',
},
};
@@ -0,0 +1,38 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
heading: { text: 'Stop settling for trade-offs.', fontFamily: 'sans' },
},
heading: [
{ text: 'A modern CRM with ', fontFamily: 'serif' },
{ text: 'an intuitive interface', fontFamily: 'sans' },
],
body: { text: '' },
illustrationCards: [
{
heading: { text: 'Built for speed', fontFamily: 'sans' },
body: {
text: 'Fly through your workspace with shortcuts and short load times.',
},
benefits: undefined,
attribution: undefined,
},
{
heading: { text: 'Real-time data', fontFamily: 'sans' },
body: {
text: 'See updates as they happen. Work with your team and agents seamlessly.',
},
benefits: undefined,
attribution: undefined,
},
{
heading: { text: 'Stay in Flow', fontFamily: 'sans' },
body: {
text: 'AI chat, settings, and records in a side panels for fast, single-screen access.',
},
benefits: undefined,
attribution: undefined,
},
],
};
@@ -0,0 +1,60 @@
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { HERO_DATA } from '@/app/product/constants/hero';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/product/constants/three-cards';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
export default function ProductPage() {
return (
<>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Product} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Product} body={HERO_DATA.body} />
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
</Hero.Cta>
<Hero.Illustration
src="https://app.endlesstools.io/embed/0bcf3ac2-58cf-4cd5-90bd-e8fada9816a9"
title="Endless Tools Editor"
backgroundColor={theme.colors.secondary.background[5]}
/>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Product} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
/>
</ThreeCards.Root>
</Hero.Root>
</>
);
}
@@ -0,0 +1,11 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'The future of CRM is built, ', fontFamily: 'serif' },
{ text: 'not bought.', fontFamily: 'sans' },
],
body: {
text: 'Most packaged software makes companies more similar. CRM is no exception. AI is turning software into a commodity. That does not make your go-to-market a commodity. It makes it the battleground.',
},
};
@@ -0,0 +1,14 @@
import { HERO_DATA } from '@/app/why-twenty/constants/hero';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { theme } from '@/theme';
export default function WhyTwentyPage() {
return (
<Hero.Root backgroundColor={theme.colors.secondary.background[100]}>
<Hero.Heading page={Pages.WhyTwenty} segments={HERO_DATA.heading} size="xl" />
<Hero.Body page={Pages.WhyTwenty} body={HERO_DATA.body} />
<Hero.WhyTwentyVisual />
</Hero.Root>
);
}
@@ -0,0 +1,99 @@
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { BodyType } from './types/Body';
const bodyClassName = css`
margin: 0;
&[data-family='sans'] {
font-family: ${theme.font.family.sans};
}
&[data-family='serif'] {
font-family: ${theme.font.family.serif};
}
&[data-family='mono'] {
font-family: ${theme.font.family.mono};
}
&[data-weight='light'] {
font-weight: ${theme.font.weight.light};
}
&[data-weight='regular'] {
font-weight: ${theme.font.weight.regular};
}
&[data-weight='medium'] {
font-weight: ${theme.font.weight.medium};
}
&[data-size='md'] {
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
}
&[data-size='sm'] {
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
}
&[data-size='xs'] {
font-size: ${theme.font.size(3)};
line-height: ${theme.lineHeight(3.5)};
}
@media (min-width: ${theme.breakpoints.md}px) {
&[data-size='md'] {
font-size: ${theme.font.size(4.5)};
line-height: ${theme.lineHeight(6)};
}
&[data-size='sm'] {
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
}
&[data-size='xs'] {
font-size: ${theme.font.size(3)};
line-height: ${theme.lineHeight(3.5)};
}
}
`;
export type BodyAs = 'p' | 'span' | 'div';
export type BodyFamily = 'sans' | 'serif' | 'mono';
export type BodyWeight = 'light' | 'regular' | 'medium';
export type BodySize = 'md' | 'sm' | 'xs';
export type BodyProps = {
as?: BodyAs;
body: BodyType;
family?: BodyFamily;
weight?: BodyWeight;
size?: BodySize;
className?: string;
};
export function Body({
as: Tag = 'p',
body,
family = 'sans',
weight = 'regular',
size = 'md',
className,
}: BodyProps) {
const rootClassName = [bodyClassName, className].filter(Boolean).join(' ');
return (
<Tag
className={rootClassName}
data-family={family}
data-weight={weight}
data-size={size}
>
{body.text}
</Tag>
);
}
@@ -0,0 +1,3 @@
export type BodyType = {
text: string;
}
@@ -0,0 +1,84 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { ButtonShape } from './ButtonShape';
export const buttonBaseStyles = `
align-items: center;
background: transparent;
border: none;
border-radius: ${theme.radius(2)};
cursor: pointer;
display: inline-flex;
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
height: ${theme.spacing(10)};
justify-content: center;
letter-spacing: 0;
padding: 0 ${theme.spacing(5)};
position: relative;
text-decoration: none;
text-transform: uppercase;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
const Label = styled.span`
position: relative;
z-index: 1;
&[data-color='primary'] {
color: ${theme.colors.primary.text[100]};
}
&[data-color='secondary'] {
color: ${theme.colors.secondary.text[100]};
}
`;
export type BaseButtonProps = {
color: 'primary' | 'secondary';
label: string;
variant: 'contained' | 'outlined';
};
export function BaseButton({ color, label, variant }: BaseButtonProps) {
let fillColor: string;
let strokeColor: string;
let labelColor: 'primary' | 'secondary';
switch (`${variant}.${color}`) {
case 'contained.primary':
fillColor = theme.colors.primary.background[100];
strokeColor = 'none';
labelColor = 'primary';
break;
case 'contained.secondary':
fillColor = theme.colors.secondary.background[100];
strokeColor = 'none';
labelColor = 'secondary';
break;
case 'outlined.primary':
fillColor = 'none';
strokeColor = theme.colors.primary.background[100];
labelColor = 'secondary';
break;
case 'outlined.secondary':
fillColor = 'none';
strokeColor = theme.colors.secondary.background[100];
labelColor = 'primary';
break;
default:
throw new Error(`Unhandled button appearance: ${variant} ${color}`);
}
return (
<>
<ButtonShape fillColor={fillColor} strokeColor={strokeColor} />
<Label data-color={labelColor}>{label}</Label>
</>
);
}
@@ -0,0 +1,74 @@
import { styled } from "@linaria/react";
interface ButtonShapeProps {
fillColor: string;
strokeColor: string;
}
const LEFT_FILL = "M4 0 A4 4 0 0 0 0 4 V36 A4 4 0 0 0 4 40 Z";
const LEFT_OUTLINE = "M4 0.5 A3.5 3.5 0 0 0 0.5 4 V36 A3.5 3.5 0 0 0 4 39.5";
const RIGHT_FILL = "M0 0 h11 a4 4 0 0 1 4 4 v20.523 a6 6 0 0 1 -1.544 4.019 l-8.548 9.477 A6 6 0 0 1 0.453 40 H0 Z";
const RIGHT_OUTLINE = "M0 0.5 h11 a3.5 3.5 0 0 1 3.5 3.5 v20.523 a5.5 5.5 0 0 1 -1.416 3.684 l-8.547 9.477 a5.5 5.5 0 0 1 -4.084 1.816 H0";
const ShapeContainer = styled.div`
display: flex;
height: 40px;
inset: 0;
pointer-events: none;
position: absolute;
width: 100%;
z-index: 0;
`;
const LeftCap = styled.svg`
display: block;
flex-shrink: 0;
`;
const MiddleSegment = styled.svg`
display: block;
flex-grow: 1;
margin: 0 -1px;
min-width: 0;
`;
const RightCap = styled.svg`
display: block;
flex-shrink: 0;
`;
export function ButtonShape({ fillColor, strokeColor }: ButtonShapeProps) {
const isOutline = fillColor === "none";
return (
<ShapeContainer>
<LeftCap width="4" height="40" viewBox="0 0 4 40" fill="none" xmlns="http://www.w3.org/2000/svg">
{isOutline ? (
<path d={LEFT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" />
) : (
<path d={LEFT_FILL} fill={fillColor} stroke={strokeColor} />
)}
</LeftCap>
<MiddleSegment width="100%" height="40" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
{isOutline ? (
<>
<line x1="0" y1="0.5" x2="100%" y2="0.5" stroke={strokeColor} strokeWidth="1" />
<line x1="0" y1="39.5" x2="100%" y2="39.5" stroke={strokeColor} strokeWidth="1" />
</>
) : (
<rect width="100%" height="40" fill={fillColor} stroke={strokeColor} />
)}
</MiddleSegment>
<RightCap width="15" height="40" viewBox="0 0 15 40" fill="none" xmlns="http://www.w3.org/2000/svg">
{isOutline ? (
<path d={RIGHT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" strokeLinejoin="round" strokeLinecap="round" />
) : (
<path d={RIGHT_FILL} fill={fillColor} stroke={strokeColor} />
)}
</RightCap>
</ShapeContainer>
);
}
@@ -0,0 +1,49 @@
import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType';
import { styled } from '@linaria/react';
import Link from 'next/link';
import {
BaseButton,
type BaseButtonProps,
buttonBaseStyles,
} from './BaseButton';
const StyledButtonAnchor = styled.a`
${buttonBaseStyles}
`;
const StyledButtonLink = styled(Link)`
${buttonBaseStyles}
`;
export type LinkButtonPresentation = 'anchor' | 'link';
export type LinkButtonProps = Omit<BaseButtonProps, 'label'> &
LinkButtonType & { type: LinkButtonPresentation };
export function LinkButton({
color,
href,
label,
type,
variant,
}: LinkButtonProps) {
const inner = <BaseButton color={color} label={label} variant={variant} />;
if (type === 'anchor') {
return (
<StyledButtonAnchor
href={href}
rel="noopener noreferrer"
target="_blank"
>
{inner}
</StyledButtonAnchor>
);
}
return (
<StyledButtonLink href={href}>
{inner}
</StyledButtonLink>
);
}
@@ -0,0 +1,27 @@
import { styled } from '@linaria/react';
import {
BaseButton,
type BaseButtonProps,
buttonBaseStyles,
} from './BaseButton';
import type { SubmitButtonType } from './types/SubmitButtonType';
const StyledSubmitButton = styled.button`
${buttonBaseStyles}
`;
export type SubmitButtonProps = Omit<BaseButtonProps, 'label'> &
SubmitButtonType & { onClick?: () => void };
export function SubmitButton({
color,
label,
onClick,
variant,
}: SubmitButtonProps) {
return (
<StyledSubmitButton type="submit" onClick={onClick}>
<BaseButton color={color} label={label} variant={variant} />
</StyledSubmitButton>
);
}
@@ -0,0 +1 @@
export type LinkButtonType = { href: string; label: string };
@@ -0,0 +1 @@
export type SubmitButtonType = { label: string };
@@ -0,0 +1,15 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const ContainerRoot = styled.div`
margin: 0 auto;
max-width: ${theme.breakpoints.maxContent}px;
width: 100%;
`;
export type ContainerProps = { children: ReactNode; className?: string };
export function Container({ children, className }: ContainerProps) {
return <ContainerRoot className={className}>{children}</ContainerRoot>;
}
@@ -0,0 +1,53 @@
import { Heading } from '@/design-system/components/Heading/Heading';
import { HeadingType } from '@/design-system/components/Heading/types/Heading';
import { RectangleFillIcon } from '@/icons';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
const EyebrowRow = styled.div`
align-items: start;
display: grid;
gap: ${theme.spacing(2)};
grid-template-columns: auto 1fr;
text-align: start;
`;
const IconWrapper = styled.div`
display: flex;
align-items: center;
height: ${theme.lineHeight(6)};
`;
const eyebrowColorPrimary = css`
color: ${theme.colors.primary.text[60]};
`;
const eyebrowColorSecondary = css`
color: ${theme.colors.secondary.text[60]};
`;
type EyebrowProps = {
heading: HeadingType;
colorScheme: 'primary' | 'secondary';
};
export function Eyebrow({ heading, colorScheme }: EyebrowProps) {
const colorClassName =
colorScheme === 'primary' ? eyebrowColorPrimary : eyebrowColorSecondary;
return (
<EyebrowRow>
<IconWrapper>
<RectangleFillIcon size={14} fillColor={theme.colors.highlight[100]} />
</IconWrapper>
<Heading
as="h3"
className={colorClassName}
segments={{ fontFamily: heading.fontFamily, text: heading.text }}
size="xs"
weight="medium"
/>
</EyebrowRow>
);
}
@@ -0,0 +1,3 @@
import { HeadingType } from '@/design-system/components/Heading/types/Heading';
export type EyebrowType = { heading: HeadingType };
@@ -0,0 +1,130 @@
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { HeadingType } from './types/Heading';
const headingRootClassName = css`
margin: 0;
&[data-weight='light'] {
font-weight: ${theme.font.weight.light};
}
&[data-weight='regular'] {
font-weight: ${theme.font.weight.regular};
}
&[data-weight='medium'] {
font-weight: ${theme.font.weight.medium};
}
&[data-size='xl'] {
font-size: ${theme.font.size(15)};
line-height: ${theme.lineHeight(16.5)};
}
&[data-size='lg'] {
font-size: ${theme.font.size(10)};
line-height: ${theme.lineHeight(11.5)};
}
&[data-size='md'] {
font-size: ${theme.font.size(10)};
line-height: ${theme.lineHeight(11.5)};
}
&[data-size='sm'] {
font-size: ${theme.font.size(8)};
line-height: ${theme.lineHeight(10)};
}
&[data-size='xs'] {
font-size: ${theme.font.size(4.5)};
line-height: ${theme.lineHeight(6)};
}
@media (min-width: ${theme.breakpoints.md}px) {
&[data-size='xl'] {
font-size: ${theme.font.size(20)};
line-height: ${theme.lineHeight(21.5)};
}
&[data-size='lg'] {
font-size: ${theme.font.size(15)};
line-height: ${theme.lineHeight(16.5)};
}
&[data-size='md'] {
font-size: ${theme.font.size(12)};
line-height: ${theme.lineHeight(14)};
}
&[data-size='sm'] {
font-size: ${theme.font.size(8)};
line-height: ${theme.lineHeight(10)};
}
&[data-size='xs'] {
font-size: ${theme.font.size(5.5)};
line-height: ${theme.lineHeight(7)};
}
}
`;
const StyledSpan = styled.span`
&[data-family='sans'] {
font-family: ${theme.font.family.sans};
letter-spacing: -0.02em;
}
&[data-family='serif'] {
font-family: ${theme.font.family.serif};
letter-spacing: 0;
}
&[data-family='mono'] {
font-family: ${theme.font.family.mono};
letter-spacing: -0.02em;
}
`;
export type HeadingAs = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
export type HeadingFamily = 'sans' | 'serif' | 'mono';
export type HeadingWeight = 'light' | 'regular' | 'medium';
export type HeadingSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs';
export type HeadingProps = {
as?: HeadingAs;
segments: HeadingType | HeadingType[];
weight?: HeadingWeight;
size?: HeadingSize;
className?: string;
};
export function Heading({
as: Tag = 'h1',
segments,
weight = 'regular',
size = 'md',
className,
}: HeadingProps) {
const rootClassName = [headingRootClassName, className]
.filter(Boolean)
.join(' ');
return (
<Tag className={rootClassName} data-weight={weight} data-size={size}>
{Array.isArray(segments) ? (
segments.map((segment, index) => (
<StyledSpan key={index} data-family={segment.fontFamily}>
{segment.text}
</StyledSpan>
))
) : (
<StyledSpan data-family={segments.fontFamily}>
{segments.text}
</StyledSpan>
)}
</Tag>
);
}
@@ -0,0 +1,4 @@
export type HeadingType = {
fontFamily: 'sans' | 'serif' | 'mono';
text: string;
}
@@ -0,0 +1,68 @@
import type { ComponentType } from "react";
import { theme } from "@/theme";
import { styled } from "@linaria/react";
type IconComponent =
| ComponentType<{ size: number; fillColor: string }>
| ComponentType<{ size: number; strokeColor: string }>;
interface IconButtonProps {
icon: IconComponent;
ariaLabel: string;
borderColor: string;
iconFillColor: string;
iconSize: number;
iconStrokeColor: string;
size: number;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
ariaExpanded?: boolean;
}
const StyledButton = styled.button<{ size: number; borderColor: string }>`
align-items: center;
background: transparent;
border: 1px solid ${(p) => p.borderColor};
border-radius: ${theme.radius(2)};
cursor: pointer;
display: inline-flex;
height: ${(p) => p.size}px;
justify-content: center;
margin: 0;
padding: 0;
width: ${(p) => p.size}px;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
export function IconButton({
icon: Icon,
ariaLabel,
borderColor,
iconFillColor,
iconSize,
iconStrokeColor,
size,
onClick,
ariaExpanded,
}: IconButtonProps) {
return (
<StyledButton
type="button"
size={size}
borderColor={borderColor}
aria-label={ariaLabel}
aria-expanded={ariaExpanded}
onClick={onClick}
>
<Icon
size={iconSize}
strokeColor={iconStrokeColor}
fillColor={iconFillColor}
aria-hidden="true"
/>
</StyledButton>
);
}
@@ -0,0 +1,4 @@
export type IllustrationType = {
src: string;
title: string;
};
@@ -0,0 +1,26 @@
import NextImage from 'next/image';
import { styled } from '@linaria/react';
import type { ImageType } from './types/Image';
const ImageRoot = styled.div`
aspect-ratio: 16 / 9;
overflow: hidden;
position: relative;
width: 100%;
`;
export type ImageComponentProps = ImageType & { className?: string };
export function Image({ alt, className, src }: ImageComponentProps) {
return (
<ImageRoot className={className}>
<NextImage
alt={alt}
fill
sizes="100vw"
src={src}
style={{ objectFit: 'cover' }}
/>
</ImageRoot>
);
}

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