Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code cce76504e1 Uninitialized per-instance dropdown state when changing operand in dashboard chart filter
https://sonarly.com/issue/8513?type=bug

When a user changes a filter operand to a valueless type (IS_EMPTY, IS_NOT_EMPTY, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY) in an advanced filter on a dashboard chart, the per-instance dropdown state atoms are not populated, causing an unhandled crash.
2026-03-04 01:36:07 +00:00
Sonarly Claude Code ec12597a1f ConsoleListener.release() accumulates wrapper chain causing stack overflow
https://sonarly.com/issue/5485?type=bug

Each logic function execution adds an extra wrapper layer to global console methods via ConsoleListener.release(), eventually exceeding V8's call stack limit after thousands of executions.

Fix: The `release()` method in `ConsoleListener` was creating a new arrow function wrapper around the captured original console method instead of directly restoring it. Each call to `release()` added one more layer to an ever-growing call chain, eventually causing a stack overflow after thousands of logic function executions.

The fix replaces the wrapper creation with a direct assignment:

```typescript file=packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/intercept-console.ts lines=26-31
release() {
    Object.keys(this.originalConsole).forEach((method) => {
      // @ts-expect-error legacy noImplicitAny
      console[method] = this.originalConsole[method];
    });
  }
```

**Before:** Each `release()` call did `console[method] = (...args) => { this.originalConsole[method](...args) }` — creating a new closure that held a reference to the previous wrapper, forming a chain N layers deep after N executions.

**After:** Each `release()` call does `console[method] = this.originalConsole[method]` — directly restoring the reference captured at construction time, with no wrapper and no chain growth regardless of how many executions have occurred.
2026-03-04 01:31:35 +00:00
Sonarly Claude Code 970d8511e1 Microsoft 404 on folder delta endpoint permanently fails email sync
https://sonarly.com/issue/8521?type=bug

When Microsoft Graph API returns HTTP 404 during message list fetch (e.g., deleted folder), the exception handler permanently marks the message channel as FAILED_UNKNOWN instead of retrying, breaking email sync for the affected user.

Fix: ## Fix

The `handleNotFoundException` method was permanently failing the message channel (`FAILED_UNKNOWN`) when a `NOT_FOUND` exception occurred during `MESSAGE_LIST_FETCH`. This was incorrect because Microsoft Graph API legitimately returns HTTP 404 for its folder delta endpoint when a mail folder is deleted, moved, or otherwise inaccessible — which the Microsoft driver maps to `MessageImportDriverExceptionCode.NOT_FOUND`.

The fix removes the special-case branch that treated `NOT_FOUND` during `MESSAGE_LIST_FETCH` as an impossible/fatal state, and instead routes all `NOT_FOUND` exceptions (regardless of sync step) to the retry path: `resetAndMarkAsMessagesListFetchPending`. This is consistent with:
- How `SYNC_CURSOR_ERROR` is handled (also resets and retries)
- The original intent before the Oct 2025 refactor (commit `cd4800eb86d`) introduced the permanent failure path

```typescript file=packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service.ts lines=247-256
private async handleNotFoundException(
    syncStep: MessageImportSyncStep,
    messageChannel: Pick<MessageChannelWorkspaceEntity, 'id'>,
    workspaceId: string,
  ): Promise<void> {
    await this.messageChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending(
      [messageChannel.id],
      workspaceId,
    );
  }
```

The old code for `MESSAGE_LIST_FETCH` (which created a "should never happen" Sentry error and called `markAsFailed`) is entirely removed. When the delta link becomes invalid (folder deleted/moved), the sync cursor resets and the channel queues for a fresh list fetch rather than being permanently broken.
2026-03-04 01:25:20 +00:00
07dd27f6c4 i18n - translations (#18368)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-04 01:29:35 +01:00
Charles BochetandGitHub 7a2e397ad1 Complete linaria migration (#18361)
## Summary

Completes the migration of the frontend styling system from **Emotion**
(`@emotion/styled`, `@emotion/react`) to **Linaria** (`@linaria/react`,
`@linaria/core`), a zero-runtime CSS-in-JS library where styles are
extracted at build time.

This is the final step of the migration — all ~494 files across
`twenty-front`, `twenty-ui`, `twenty-website`, and `twenty-sdk` are now
fully converted.

## Changes

### Styling Migration (across ~480 component files)
- Replaced all `@emotion/styled` imports with `@linaria/react`
- Converted runtime theme access patterns (`({ theme }) => theme.x.y`)
to build-time `themeCssVariables` CSS custom properties
- Replaced `useTheme()` hook (from Emotion) with
`useContext(ThemeContext)` where runtime theme values are still needed
(e.g., passing colors to non-CSS props like icon components)
- Removed `@emotion/react` `css` helper usages in favor of Linaria
template literals

### Dependency & Configuration Changes
- **Removed**: `@emotion/react`, `@emotion/styled` from root
`package.json`
- **Added**: `@wyw-in-js/babel-preset`, `next-with-linaria` (for
twenty-website SSR support)
- Updated Nx generator defaults from `@emotion/styled` to
`@linaria/react` in `nx.json`
- Simplified `vite.config.ts` (removed Emotion-specific configuration)
- Updated `twenty-website/next.config.js` to use `next-with-linaria` for
SSR Linaria support

### Storybook & Testing
- Removed `ThemeProvider` from Emotion in Storybook previews
(`twenty-front`, `twenty-sdk`)
- Now relies solely on `ThemeContextProvider` for theme injection

### Documentation
- Removed the temporary `docs/emotion-to-linaria-migration-plan.md`
(migration complete)
- Updated `CLAUDE.md` and `README.md` to reflect Linaria as the styling
stack
- Updated frontend style guide docs across all locales

## How it works

Linaria extracts styles at build time via the `@wyw-in-js/vite` plugin.
All expressions in `styled` template literals must be **statically
evaluable** — no runtime theme objects or closures over component state.

- **Static styles** use `themeCssVariables` which map to CSS custom
properties (`var(--theme-color-x)`)
- **Runtime theme access** (for non-CSS use cases like icon `color`
props) uses `useContext(ThemeContext)` instead of Emotion's `useTheme()`
2026-03-04 00:50:06 +01:00
Paul RastoinandGitHub 8a3b96d911 Remove files (#18360) 2026-03-03 19:01:27 +01:00
Paul RastoinandGitHub 132a19f688 [SDK] Execute logic function e2e test (#18351)
# Introduction
Creating an e2e test covering the execute logic function public
operation
2026-03-03 17:44:19 +01:00
Charles BochetandGitHub 3bfdc2c83f chore(twenty-front): migrate command-menu, workflow, page-layout and UI modules from Emotion to Linaria (PR 4-6/10) (#18342)
## Summary

Continues the Emotion → Linaria migration (PR 4-6 from the [migration
plan](docs/emotion-to-linaria-migration-plan.md)). Migrates **311
files** across four module groups:

| Module | Files |
|---|---|
| command-menu | 53 |
| workflow | 84 |
| page-layout | 84 |
| UI (partial - first ~80 files) | ~80 |
| twenty-ui (TEXT_INPUT_STYLE) | 1 |
| misc (hooks, keyboard-shortcut-menu, file-upload) | ~9 |

### Migration patterns applied

- `import styled from '@emotion/styled'` → `import { styled } from
'@linaria/react'`
- `import { useTheme } from '@emotion/react'` → `import { useContext }
from 'react'` + `import { ThemeContext } from 'twenty-ui/theme'`
- `${({ theme }) => theme.X.Y.Z}` → `${themeCssVariables.X.Y.Z}` (static
CSS variables)
- `theme.spacing(N)` → `themeCssVariables.spacing[N]`
- `styled(motion.div)` → `motion.create(StyledBase)` (11 components)
- `styled(Component)<TypeParams>` → wrapper div approach for non-HTML
elements
- Multi-declaration interpolations split into one CSS property per
interpolation
- Interpolation return types fixed (`&&` → ternary `? : ''`)
- `TEXT_INPUT_STYLE` converted from function to static string constant
(backward compatible)
- Emotion `<Global>` replaced with `useEffect` style injection
- Complex runtime-dependent styles use CSS custom properties via
`style={}` prop

### After this PR

- **Remaining files**: ~400 (object-record: ~160, settings: ~200, UI:
~44)
- **No breaking changes**: CSS variables resolve identically to the
previous Emotion theme values
2026-03-03 16:42:03 +01:00
martmullandGitHub 8b26020a0b Fix missing omit in application config (#18354)
as title
2026-03-03 16:33:49 +01:00
b1107c823a Apollo enrich (#18277)
- apollo enrich application (via OAuth 2)
- add applicationId to var env in logic function executor
- update `getDefaultUrl` logic

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-03 14:43:10 +01:00
martmullandGitHub 5c4a1f931a Fix trigger missing (#18348)
## After

<img width="1170" height="448" alt="image"
src="https://github.com/user-attachments/assets/3a8dd77c-06d2-438e-b2d3-07d272c08588"
/>

<img width="977" height="441" alt="image"
src="https://github.com/user-attachments/assets/3e16ec94-63ce-4d09-af54-38f330bfa0c9"
/>

<img width="802" height="333" alt="image"
src="https://github.com/user-attachments/assets/0975f199-b89a-4a05-a8e4-2101f5556445"
/>
2026-03-03 14:37:17 +01:00
4266f4022a i18n - translations (#18349)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-03 14:24:43 +01:00
Thomas TrompetteandGitHub b2b3a3f860 Workflow iterator continues on faillure (#18325)
<img width="450" height="212" alt="Capture d’écran 2026-03-03 à 11 41
54"
src="https://github.com/user-attachments/assets/b2c29a48-7dc0-4b16-a085-8f305d21f7ca"
/>

New status `FAIL_SAFE` added. This status propagates to the following
nodes until reaching the iterator, that will start the new iteration.

The difference with `SKIP` is that, when the parent nodes have at least
one `FAIL_SAFE`, it becomes `FAIL_SAFE` too. While a parent 1 `SKIP` +
parent 2 `SUCCESS` => to be executed.

I also thought about just going back to the iterator as a break would,
but since we have branches, it may lead to inconsistent statuses with
parallel updates.
2026-03-03 14:18:57 +01:00
Charles BochetandGitHub d48c58640c Migrate CI runners from Depot back to GitHub-hosted runners (#18347)
## Summary
- Replaces all `depot-ubuntu-24.04` runners with `ubuntu-latest`
- Replaces all `depot-ubuntu-24.04-8` runners with
`ubuntu-latest-8-cores`
- Updates storybook build cache keys in ci-front.yaml to reflect the
runner name change

Reverts the temporary Depot migration introduced in #18163 / #18179
across all 23 workflow files.
2026-03-03 14:14:27 +01:00
Paul RastoinandGitHub 005223de8c [SDK] Make public-operations non throw (#18343)
Followup https://github.com/twentyhq/twenty/pull/18320
2026-03-03 14:04:13 +01:00
Paul RastoinandGitHub 2f09fb8c04 SDK Split command and cli logic (#18320)
# Introduction

Allow a consumer call the commands programmatically instead of passing
by the exec
To do so extract from the command definition all the core logic, created
a new error api that allow keeping same error logs granularity than
before

## Usage
```ts
import { authLogin, appUninstall, functionExecute } from 'twenty-sdk/cli';

const result = await authLogin({
  apiKey: 'my-key',
  apiUrl: 'https://my-twenty.com',
});

if (!result.success) {
  throw new Error(result.error);
}
```

## `app:build`
Introduced a new command that will allow building the whole project
without any watch setup
- Build and validate manifest
- Get or create app
- Synchronize manifest with twenty-sdk stub and no typecheck
- generate client
- Run typecheck
- Synchronize manifest again
2026-03-03 12:24:49 +01:00
neo773andGitHub 083df3e7ca OAuth Edge case crash + cleanup (#18326)
Fixes Sentry issue https://twenty-v7.sentry.io/issues/6603377117/

Also cleaned up the code with proper types removing `//
eslint-disable-next-line @typescript-eslint/no-explicit-any`
2026-03-03 12:22:12 +01:00
martmullandGitHub 2e9624858c Fix name singular updates in dev mode (#18339)
as title
2026-03-03 11:40:17 +01:00
Paul RastoinandGitHub 58e37a118c Builder runs delete update and then create (#18272)
# Introduction
We need to build and validate the flat entity operation in the following
order delete update and create
For example if not, if a created field has the same name than a deleted
one than it will fail whereas it should not
2026-03-03 11:26:06 +01:00
Paul RastoinandGitHub 0b766464e4 Composite action: Spawn twenty instance (#18317)
# Introduction

## Runs:

Public personal repo:
-
[latest](https://github.com/prastoin/twenty-app/actions/runs/22568051680/job/65368592903)
2026-03-03 11:19:08 +01:00
Charles BochetandGitHub 802a5b0af6 chore(twenty-front): migrate auth, activities, AI, pages and small modules from Emotion to Linaria (PR 2-3/10) (#18328)
## Summary

- Migrate ~200 files from `@emotion/styled` / `@emotion/react` to
`@linaria/react` + `themeCssVariables`, continuing the zero-runtime
CSS-in-JS migration (PR 2-3 of the [migration
plan](docs/emotion-to-linaria-migration-plan.md))
- Modules covered: **auth** (19), **activities** (53), **ai** (30),
**pages** (70), **action-menu** (3), **object-metadata** (4),
**onboarding** (2), **workspace** (2), **file** (3), **error-handler**
(2), **front-components** (1), **geo-map** (1), **loading** (5),
**testing** (5), plus a `style` prop addition to `TableRow`
- Handles `styled(FunctionComponent)<Props>` incompatibility with
Linaria by using CSS custom properties via `style` + `var()` references

## Test plan

- [x] `npx nx lint:diff-with-main twenty-front` passes
- [x] `npx nx typecheck twenty-front` passes
- [ ] Visual spot-check of auth, onboarding, settings, activities, and
AI chat screens
- [ ] No remaining `@emotion/styled` or `@emotion/react` imports in
migrated files


Made with [Cursor](https://cursor.com)
2026-03-03 11:17:47 +01:00
Abdul RahmanandGitHub ae291c99ba fix: record does not open in side panel after returning from fullscreen (#17131)
Closes #17089 

### 1. Can't reopen record after having navigated to its show page
After opening a record in the show page from the command menu and going
back to the index, clicking the same record again did nothing. The
command menu navigation stack was not cleared when opening in the show
page, so the "already open" check skipped reopening. We now clear the
command menu navigation stack before navigating to the show page (in
`RecordShowRightDrawerOpenRecordButton`), so the same record can be
reopened from the index.

### 2. Row doesn't highlight when opening command menu after return from
show page
After returning from the record show page to the index, the first row
click opened the command menu but the row did not highlight. The "side
panel close" event was emitted not only when the panel actually closed,
but also when opening the command menu (cleanup ran with
`isCommandMenuClosing` and always emitted the event). Listeners like
`RecordTableDeactivateRecordTableRowEffect` then deactivated the row. We
now emit the side panel close event only when the close animation
actually completes (`CommandMenuSidePanelForDesktop`), and skip emitting
it when cleanup is run from the open path (`useNavigateCommandMenu`
passes `emitSidePanelCloseEvent: false`). The table still deactivates
the row when the user closes the panel, but no longer when they open the
command menu by clicking a row.
2026-03-02 23:25:11 +00:00
7809f83e72 fix: [Note] Title not filled by default #13838 (#18297)
Fixes #13838 
When creating a note from the command menu side panel (e.g. clicking
"Add Note" in a related notes section on an Opportunity/company/people
page), the title field was not auto-focused — focus point went to body
instead.

## Root Cause

When a record opens in the side panel, there is no page navigation, so
`PageChangeEffect` (which handles title auto-focus for full-page views)
never runs. `openNewRecordTitleCell()` was simply never called for the
side-panel path.

## Fix

`openRecordInCommandMenu` is the single entry point for all side-panel
record opens, so title auto-focus is handled there once for all callers.
Previously, `useCreateNewIndexRecord` called `openRecordInCommandMenu`
and then called `openNewRecordTitleCell` separately, which would have
caused a double invocation after this fix. The redundant call has been
removed.

## Before


https://github.com/user-attachments/assets/df0d9e4f-dc25-4a0d-a49e-898a14f9c0a0

## After


https://github.com/user-attachments/assets/1a5044f7-6bb7-4333-8934-c1081b935e97

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 20:09:36 +00:00
27847f6ac6 i18n - translations (#18330)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-02 21:00:18 +01:00
Félix MalfaitandGitHub 6351c6c1c6 feat: remember original URL and redirect after login (#18308)
## Summary

- Implement a return-to-path mechanism that preserves the user's
intended destination across authentication flows (login, magic link,
cross-domain redirects)
- Uses layered persistence: Jotai atom (in-memory), sessionStorage with
TTL (tab-switch resilience), URL query parameter (cross-domain
propagation)
- Includes path validation to prevent open redirects, automatic cleanup
after successful login, and comprehensive test coverage
- Replaces the unused `previousUrlState` with a robust
`returnToPathState` system

## Test plan

- [ ] Visit a deep link (e.g. `/objects/tasks`) while logged out —
should redirect to login, then back to `/objects/tasks` after logging in
- [ ] Visit an OAuth authorize link while logged out — should redirect
to login, then to the authorize page
- [ ] Test magic link flow: click sign-in link that opens new tab —
should still redirect to original destination
- [ ] Test cross-domain: visit `app.twenty.com/objects/tasks` — should
preserve path through workspace domain redirect
- [ ] Verify auth/onboarding paths are excluded from being saved as
return paths
- [ ] Verify return-to-path is cleared after successful navigation
- [ ] All 215 existing `usePageChangeEffectNavigateLocation` tests pass


Made with [Cursor](https://cursor.com)
2026-03-02 19:00:48 +01:00
Abdullah.andGitHub 20a2c3836e feat: introduce role selector when inviting members to a workspace (#18085)
This PR adds an explicit role selector to the "Invite by email" flow,
requires a role choice before sending, and stores the selected role with
each invitation. The backend now accepts and persists `roleId` on
invitations and applies it when the invite is accepted, while keeping it
optional to avoid breaking existing clients and legacy invites.

---

### Frontend

- **Settings → Members → Invite by email**
- New **Role** dropdown (same `Select` pattern as member/API key role
selectors) between the email input and Invite button.
- Roles are loaded via `SettingsRolesQueryEffect` and
`settingsAllRolesSelector`; only roles with `canBeAssignedToUsers` are
shown.
- Role is **required**: form validates `roleId` (e.g.
`z.string().min(1)`) and the Invite button is disabled until a role is
selected and emails are valid.
- `WorkspaceInviteTeam` receives `roles` as a prop from the parent;
layout is responsive (e.g. stacked on small viewports).
- **Pending invitations table**
- New **Role** column showing the invitation’s role label (or "Unknown
role" for legacy invites without `roleId`), using the same roles source
for lookup.
- **Onboarding invite step**
- When sending invites during onboarding, the workspace **default role**
is used when available (`currentWorkspace?.defaultRole?.id`), so no role
selector is added there.
- **GraphQL**
- `sendInvitations` mutation accepts optional `roleId`;
`findWorkspaceInvitations` and resend mutation responses include
`roleId` on `WorkspaceInvitation`. Frontend types (e.g.
`WorkspaceInvitation`, hook variables) updated accordingly.

---

### Backend

- **API**
- `SendInvitationsInput` has an **optional** `roleId` (UUID, nullable).
The resolver normalises `null` to `undefined` so existing callers and
legacy flows are not broken.
- **Validation (when `roleId` is provided)**
- Role checks are centralised in **RoleValidationService**
(`RoleValidationModule`, in `metadata-modules/role-validation/`). It
validates that the role exists in the workspace and has
`canBeAssignedToUsers`, and throws a permissions-style error otherwise.
This avoids circular dependencies (e.g. `RoleModule` imports
`UserWorkspaceModule`, so invite/accept flows cannot depend on
`RoleModule`).
- **Send flow:** `WorkspaceInvitationResolver` and
`WorkspaceInvitationService.sendInvitations` both call
`RoleValidationService.validateRoleAssignableToUsersOrThrow` when
`roleId` is present (resolver before calling the service; service again
before creating tokens so that **resend** also validates the stored role
and fails fast if the role was deleted or made unassignable).
- **Accept flow:**
`UserWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace` uses the
same service in `resolveRoleIdForNewMember` when an invitation provides
a `roleId`, then falls back to `workspace.defaultRoleId` when not.
Role/default is resolved and validated before any user/workspace/member
creation.
- **Persistence**
- Invitation app tokens store `roleId` in `context` next to `email`
(`context: { email, roleId? }`). `generateInvitationToken` and
`createWorkspaceInvitation` accept an optional `roleId` and only add it
to `context` when defined.
- **Resend**
- Resend passes the existing invitation’s `context.roleId` into
`sendInvitations`. The service validates that role (when present) before
creating the new token, so if the role was deleted or made unassignable,
resend fails with a clear error instead of sending a broken link.
- **Response shape**
- `SendInvitationsOutput.result` remains `WorkspaceInvitation[]`. When
`usePersonalInvitation` is false we only push full invitation records
(from `castAppTokenToWorkspaceInvitationUtil`), so the result always
matches the GraphQL type (`id`, `email`, `roleId`, `expiresAt`).
- **Modules**
- `WorkspaceInvitationModule` and `UserWorkspaceModule` import
**RoleValidationModule** (not `RoleModule`) and inject
**RoleValidationService** for validation. `RoleModule` imports
`RoleValidationModule` and `RoleService` delegates to
`RoleValidationService` for the same validation where the module graph
allows.

---

### Backward compatibility

- **Optional `roleId`**: Clients that don’t send `roleId` (or send
`null`) are unchanged; invitations are created without a role and the
accept flow uses the workspace default role.
- **Legacy invitations**: App tokens with only `context.email` still
work; `context.roleId` is optional and the UI can show e.g. "Unknown
role" for those in the pending-invitations table.
2026-03-02 18:58:32 +01:00
nitinandGitHub 1eb284c87f Fix command menu text/number inputs to commit on blur and cancel cleanly on Escape (#18283)
closes https://github.com/twentyhq/twenty/issues/18264




https://github.com/user-attachments/assets/7b576a00-78bc-46a2-9528-d8b3bcbdd530




https://github.com/user-attachments/assets/4102468e-e85f-46a0-8b23-e7abd77bfc95



### PR description -
This fixes flaky persistence in command menu text and number inputs.

- moved commit logic to onBlur (single commit path)
- Enter now blurs, so it uses the same commit path
- Escape now cancels edit (restores draft + exits) without persisting
- removed dependency on input click-outside commit timing

### Outcome -

- clicking anywhere outside the input now reliably persists edits
- Escape consistently discards edits
2026-03-02 15:30:51 +00:00
Charles BochetandGitHub c4140f85df chore(twenty-front): migrate small modules from Emotion to Linaria (PR 1/10) (#18314)
## Emotion → Linaria migration — PR 1 of 10

First batch of the `twenty-front` migration from Emotion (runtime
CSS-in-JS) to Linaria (zero-runtime, build-time extraction via
wyw-in-js). Covers **100 files** across 10 small standalone modules —
chosen as the lowest-risk starting point.

### Modules migrated

spreadsheet-import (28) · navigation-menu-item (17) · views (14) ·
billing (10) · blocknote-editor (7) · advanced-text-editor (7) ·
favorites (7) · navigation (4) · information-banner (3) ·
sign-in-background-mock (3)

### Migration pattern

Every file follows the same mechanical transformation:

| Emotion | Linaria |
|---|---|
| `import styled from '@emotion/styled'` | `import { styled } from
'@linaria/react'` |
| `${({ theme }) => theme.font.color.primary}` |
`${themeCssVariables.font.color.primary}` |
| `${({ theme }) => theme.spacing(4)}` |
`${themeCssVariables.spacing[4]}` |
| `const theme = useTheme()` | `const { theme } =
useContext(ThemeContext)` |
| `import { type Theme } from '@emotion/react'` | `import { type
ThemeType } from 'twenty-ui/theme'` |

`themeCssVariables` is a build-time object where every leaf is a
`var(--t-xxx)` CSS custom property reference, evaluated statically by
wyw-in-js. Runtime theme access (icon sizes, colors passed as props)
uses `useContext(ThemeContext)`.

### Gotchas encountered & fixed

- **Interpolation return types** — wyw-in-js requires `string | number`,
never `false`/`undefined`. Replaced `condition && 'css'` with `condition
? 'css' : ''`.
- **`css` tag inside `styled` templates** — Linaria `css` returns a
class name, not CSS text. Replaced with plain template strings.
- **`styled(Component)` needs `className`** — added `className` prop to
`NavigationDrawerSection`, `DropdownMenuItemsContainer`, and `Heading`.
- **`shouldForwardProp` not supported** — Linaria filters invalid DOM
props automatically for HTML elements. For custom components, used
wrapper divs where needed.
- **`FormFieldPlaceholderStyles`** — converted from Emotion `css`
function to a static string using `themeCssVariables`.
2026-03-02 16:33:40 +01:00
Charles BochetandGitHub 9c4b0f526c Refactor chip component hierarchy: AvatarChip → AvatarOrIcon (#18313)
## Summary

Cleans up the chip component hierarchy in `twenty-ui`:

- **Fix twenty-ui Storybook** — The `wyw-in-js` Vite plugin crashed on
`/@react-refresh` virtual module. Fixed by setting `enforce: 'pre'` so
it runs before the React refresh plugin injects virtual imports.
- **Rename `AvatarChip` → `AvatarOrIcon`** — The old name was
misleading. This component is not a chip — it's a polymorphic renderer
that displays either an `Avatar` (image/initials) or an `Icon` (plain or
with colored background). It's typically slotted into `Chip`/`LinkChip`
as `leftComponent`.
- **Move `rightComponentDivider` to `Chip`/`LinkChip`** — The vertical
separator between chip content and a right action (e.g. a close button)
is a chip layout concern, not an avatar concern. Added
`rightComponentDivider` boolean prop to `Chip` and `LinkChip`.
- **Remove `MultipleAvatarChip`** — Zero consumers in the codebase. The
command menu implements its own overlapping avatar layout.
- **Migrate raw icon usages** — `CalendarEventDetails` and `FileIcon`
(small size) now use `AvatarOrIcon` for consistent Chip icon rendering.
- **Enhance stories** — Full `CatalogDecorator` coverage for `Chip` and
`LinkChip` showing all variants, sizes, accents, and states.

## Component hierarchy

```
AvatarOrIcon (twenty-ui)
  ├── No Icon → renders Avatar (image or initials)
  ├── Icon + background → renders icon in colored square
  └── Icon only → renders plain icon
  Used as leftComponent/rightComponent in Chip or standalone

Chip (twenty-ui)
  ├── leftComponent (typically AvatarOrIcon)
  ├── label (with overflow tooltip)
  ├── rightComponentDivider (optional vertical separator)
  └── rightComponent (e.g. close icon via AvatarOrIcon)

LinkChip (twenty-ui)
  └── Wraps Chip inside a react-router <Link>

RecordChip (twenty-front)
  └── Composes Chip/LinkChip + AvatarOrIcon with record data
```

## `Chip` API additions

| Prop | Type | Description |
|------|------|-------------|
| `rightComponentDivider` | `boolean` | Renders a vertical separator
before `rightComponent` |

## Stories

<img width="1032" height="576" alt="image"
src="https://github.com/user-attachments/assets/fe7c7666-9b16-4545-b87e-1b53e22d462d"
/>
2026-03-02 15:48:49 +01:00
1491 changed files with 32091 additions and 11261 deletions
@@ -0,0 +1,80 @@
name: Spawn Twenty Docker Image
description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Pulls the specified semver image tag from Docker Hub.
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
required: false
default: 'twentyhq/twenty'
github-token:
description: 'GitHub token for cross-repo checkout. Required when calling from an external repository.'
required: false
default: ${{ github.token }}
outputs:
server-url:
description: 'URL where the Twenty server can be reached'
value: http://localhost:3000
access-token:
description: 'Admin access token for the Twenty instance'
value: ${{ steps.admin-token.outputs.access-token }}
runs:
using: 'composite'
steps:
- name: Validate version
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::twenty-version must be a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
- name: Checkout docker compose files
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ inputs.twenty-version }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
sparse-checkout-cone-mode: false
path: .twenty-spawn
- name: Prepare environment
shell: bash
working-directory: ./.twenty-spawn/packages/twenty-docker
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ inputs.twenty-version }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env
- name: Start Twenty instance
shell: bash
working-directory: ./.twenty-spawn/packages/twenty-docker
run: |
docker compose up -d --wait || {
echo "::error::Docker compose failed to start or health checks timed out"
docker compose logs
exit 1
}
echo "Twenty instance is ready at http://localhost:3000"
- name: Set admin access token
id: admin-token
shell: bash
run: |
ACCESS_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik"
echo "::add-mask::$ACCESS_TOKEN"
echo "access-token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
deploy-main:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
deploy-tag:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
+1 -1
View File
@@ -16,7 +16,7 @@ permissions:
jobs:
changed-files:
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
outputs:
any_changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 45
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
services:
postgres:
image: twentycrm/twenty-postgres-spilo
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
@@ -50,7 +50,7 @@ jobs:
ci-create-app-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-test]
steps:
- name: Fail job if any needs failed
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -56,7 +56,7 @@ jobs:
ci-emails-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, emails-test]
steps:
- name: Fail job if any needs failed
+11 -11
View File
@@ -14,8 +14,8 @@ concurrency:
env:
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-ubuntu-latest-8-cores-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
jobs:
changed-files-check:
@@ -42,7 +42,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
steps:
@@ -68,7 +68,7 @@ jobs:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
front-sb-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: front-sb-build
strategy:
fail-fast: false
@@ -115,7 +115,7 @@ jobs:
# path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# merge-reports-and-check-coverage:
# timeout-minutes: 30
# runs-on: depot-ubuntu-24.04
# runs-on: ubuntu-latest
# needs: front-sb-test
# env:
# PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
@@ -143,7 +143,7 @@ jobs:
timeout-minutes: 30
if: false
needs: front-sb-build
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
@@ -169,7 +169,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=4096'
TASK_CACHE_KEY: front-task-${{ matrix.task }}
@@ -211,7 +211,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
NODE_OPTIONS: "--max-old-space-size=10240"
steps:
@@ -236,7 +236,7 @@ jobs:
# path: packages/twenty-front/build
# retention-days: 1
e2e-test:
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, front-build]
if: |
always() &&
@@ -349,7 +349,7 @@ jobs:
ci-front-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs:
[
changed-files-check,
@@ -366,7 +366,7 @@ jobs:
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, e2e-test]
steps:
- name: Fail job if any needs failed
+1 -1
View File
@@ -25,7 +25,7 @@ defaults:
jobs:
create_pr:
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -15,7 +15,7 @@ defaults:
jobs:
tag_and_release:
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
steps:
- name: Check PR Author
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
@@ -50,7 +50,7 @@ jobs:
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
@@ -94,7 +94,7 @@ jobs:
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -143,7 +143,7 @@ jobs:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: server-setup
steps:
- name: Fetch custom Github Actions and base branch history
@@ -164,7 +164,7 @@ jobs:
server-integration-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: server-setup
strategy:
fail-fast: false
@@ -250,7 +250,7 @@ jobs:
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, server-setup, server-test, server-integration-test]
steps:
- name: Fail job if any needs failed
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=4096'
strategy:
@@ -47,7 +47,7 @@ jobs:
ci-shared-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, shared-test]
steps:
- name: Fail job if any needs failed
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -92,7 +92,7 @@ jobs:
ci-test-docker-compose-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
+2 -2
View File
@@ -22,7 +22,7 @@ concurrency:
jobs:
danger-js:
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
- uses: actions/checkout@v4
@@ -35,7 +35,7 @@ jobs:
congratulate:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.action == 'closed' && github.event.pull_request.merged == true
steps:
- uses: actions/checkout@v4
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -65,7 +65,7 @@ jobs:
ci-website-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, website-build]
steps:
- name: Fail job if any needs failed
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -95,7 +95,7 @@ jobs:
needs: server-setup
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, validate]
@@ -120,7 +120,7 @@ jobs:
ci-zapier-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, zapier-test]
steps:
- name: Fail job if any needs failed
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
@@ -96,7 +96,7 @@ jobs:
claude-cross-repo:
if: github.event_name == 'repository_dispatch'
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
+1 -1
View File
@@ -34,7 +34,7 @@ concurrency:
jobs:
pull_docs_translations:
name: Pull docs translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -21,7 +21,7 @@ concurrency:
jobs:
push_docs:
name: Push documentation to Crowdin
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -32,7 +32,7 @@ concurrency:
jobs:
pull_translations:
name: Pull translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
extract_translations:
name: Extract and upload translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
)
)
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Trigger preview environment workflow
uses: peter-evans/repository-dispatch@v2
@@ -0,0 +1,58 @@
diff --git a/esm/cache.js b/esm/cache.js
index 07cf6d7dd99effb9c3464b620ba67a7f445224f5..248bb527923499a6be8065ee7a3613b55819c58c 100644
--- a/esm/cache.js
+++ b/esm/cache.js
@@ -69,17 +69,20 @@ export class TransformCacheCollection {
this.invalidate(cacheName, filename);
});
}
- invalidateIfChanged(filename, content) {
+ invalidateIfChanged(filename, content, _visited) {
+ const visited = _visited || new Set();
+ if (visited.has(filename)) {
+ return false;
+ }
+ visited.add(filename);
const fileEntrypoint = this.get('entrypoints', filename);
- // We need to check all dependencies of the file
- // because they might have changed as well.
if (fileEntrypoint) {
for (const [, dependency] of fileEntrypoint.dependencies) {
const dependencyFilename = dependency.resolved;
if (dependencyFilename) {
const dependencyContent = fs.readFileSync(dependencyFilename, 'utf8');
- this.invalidateIfChanged(dependencyFilename, dependencyContent);
+ this.invalidateIfChanged(dependencyFilename, dependencyContent, visited);
}
}
}
diff --git a/lib/cache.js b/lib/cache.js
index 0762ed7d3c39b31000f7aa7d8156da15403c8e64..6955410cd3c9ec53cf7a01c8346abc4c47fff791 100644
--- a/lib/cache.js
+++ b/lib/cache.js
@@ -77,17 +77,20 @@ class TransformCacheCollection {
this.invalidate(cacheName, filename);
});
}
- invalidateIfChanged(filename, content) {
+ invalidateIfChanged(filename, content, _visited) {
+ const visited = _visited || new Set();
+ if (visited.has(filename)) {
+ return false;
+ }
+ visited.add(filename);
const fileEntrypoint = this.get('entrypoints', filename);
- // We need to check all dependencies of the file
- // because they might have changed as well.
if (fileEntrypoint) {
for (const [, dependency] of fileEntrypoint.dependencies) {
const dependencyFilename = dependency.resolved;
if (dependencyFilename) {
const dependencyContent = _nodeFs.default.readFileSync(dependencyFilename, 'utf8');
- this.invalidateIfChanged(dependencyFilename, dependencyContent);
+ this.invalidateIfChanged(dependencyFilename, dependencyContent, visited);
}
}
}
+2 -2
View File
@@ -90,7 +90,7 @@ npx nx run twenty-front:graphql:generate --configuration=metadata
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Emotion (styling), Vite
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
@@ -175,7 +175,7 @@ IMPORTANT: Use Context7 for code generation, setup or configuration steps, or li
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Emotion** for styling with styled-components pattern
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
+1 -1
View File
@@ -109,7 +109,7 @@ Below are a few features we have implemented to date:
- [TypeScript](https://www.typescriptlang.org/)
- [Nx](https://nx.dev/)
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Linaria](https://linaria.dev/) and [Lingui](https://lingui.dev/)
-283
View File
@@ -1,283 +0,0 @@
# npm-Based App Distribution for Twenty
*Technical Design Document -- February 2026*
## Overview
Add npm registry support for distributing Twenty apps (public and private), with per-AppRegistration registry overrides, direct tarball upload as escape hatch, and version upgrade detection.
## Assumptions
- The marketplace install flow (currently a TODO in the resolver and frontend) will be implemented separately. This plan provides the infrastructure that install flow will call into.
- The existing `app:dev` flow (individual file uploads via CLI) remains unchanged.
- The existing GitHub-based marketplace discovery remains as a curated fallback.
## Architecture
```
Developer
/ | \
npm publish | twenty app:push
/ | \
npmjs.com Private Reg Server REST Upload
| | |
v v v
[Discovery Layer] [Direct Upload]
npm search API |
GitHub curated list |
| |
v |
MarketplaceService |
(sourcePackage in DTO) |
| |
v v
AppPackageResolverService <--------+
.npmrc generation
yarn add / tarball extract
|
v
ApplicationSyncService (existing)
WorkspaceMigrationRunnerService
|
v
AppRegistration + Application entities
```
## Phase 1: Entity and Config Changes
### 1a. Extend AppRegistrationEntity
Add four columns to `application-registration.entity.ts`:
- **`sourcePackage`** (text, nullable) -- npm package name, e.g. `"twenty-app-fireflies"` or `"@myorg/twenty-app-crm"`. Null for tarball-only or OAuth-only apps.
- **`tarballFileId`** (uuid, nullable) -- FK to a FileEntity storing a directly-uploaded `.tar.gz`. Null when the app comes from npm.
- **`registryUrl`** (text, nullable) -- per-registration npm registry override. Null means "use the server default `APP_REGISTRY_URL`." This is how a single server can pull public apps from npmjs.com while pulling `@mycompany/*` apps from GitHub Packages.
- **`latestAvailableVersion`** (text, nullable) -- cached latest version from the registry, updated periodically. Compared against `Application.version` to surface upgrade availability.
**Source resolution priority:**
1. `sourcePackage` is set → resolve from npm via `yarn add`
2. `tarballFileId` is set → extract from file storage
3. Neither → OAuth-only app, no server-side code
### 1b. Extend ApplicationEntity.sourceType
Widen the `sourceType` union from `'local'` to `'local' | 'npm' | 'tarball'`:
- `'local'` -- existing behavior (CLI `app:dev`, individual file uploads, workspace-custom)
- `'npm'` -- installed from an npm registry via `yarn add`
- `'tarball'` -- installed from a directly-uploaded tarball
This lets the system distinguish how an app was installed, which matters for upgrade logic (npm apps can check the registry for newer versions; tarball apps cannot).
### 1c. Add server-wide config variables
Add a new `APP_REGISTRY_CONFIG` group to ConfigVariablesGroup:
- **`APP_REGISTRY_URL`** (string, default `https://registry.npmjs.org`) -- default npm registry URL
- **`APP_REGISTRY_TOKEN`** (string, optional, sensitive) -- auth token for the default registry
### 1d. Generate migration
TypeORM migration adding `sourcePackage`, `tarballFileId`, `registryUrl`, `latestAvailableVersion` to `core.applicationRegistration`.
## Phase 2: App Package Resolver Service
### 2a. Create AppPackageResolverService
New service with core method:
```
resolvePackage(appRegistration, options?: { targetVersion? }) → ResolvedPackage | null
```
Returns `{ manifestPath, packageJsonPath, filesDir }` or null for OAuth-only apps.
**Resolution logic:**
```
if sourcePackage:
1. Determine registry: appRegistration.registryUrl ?? APP_REGISTRY_URL
2. Determine auth token for the resolved registry
3. Generate temporary .npmrc in an isolated working directory
4. Run: yarn add <sourcePackage>@<targetVersion ?? latest>
5. Read manifest from node_modules/<sourcePackage>/.twenty/output/manifest.json
6. Return paths
if tarballFileId:
1. Download tarball from FileStorageService
2. Extract to temporary directory
3. Read manifest from extracted files
4. Return paths
else:
return null (OAuth-only)
```
### 2b. Isolated working directories
Each resolution runs in a temporary directory under `{os.tmpdir()}/twenty-app-resolver/{uuid}/`. This avoids contaminating the server's own `node_modules` and isolates apps from each other. Cleaned up after files are copied to storage.
### 2c. .npmrc generation
For scoped packages (`@scope/twenty-app-*`):
```
@scope:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=TOKEN
```
For unscoped packages with a non-default registry:
```
registry=https://my-verdaccio.internal:4873
//my-verdaccio.internal:4873/:_authToken=TOKEN
```
### 2d. Post-resolution file transfer
After resolving, copies files into the app's storage path using the existing FileStorageService layout:
```
{workspaceId}/{applicationUniversalIdentifier}/
built-logic-function/...
built-front-component/...
dependencies/package.json
dependencies/yarn.lock
public-asset/...
source/...
```
This reuses the same FileFolder enum paths that `app:dev` uses, so downstream ApplicationSyncService works unchanged.
## Phase 3: Marketplace Discovery via npm
### 3a. Update MarketplaceService
Add npm-based discovery alongside the existing GitHub path:
- Query npm search API: `GET {registryUrl}/-/v1/search?text=keywords:twenty-app&size=250`
- Map each result to MarketplaceAppDTO using package.json metadata
**Merge strategy:**
1. Fetch from npm search API (apps with `keywords: ["twenty-app"]`)
2. Fetch from GitHub (existing curated list)
3. Merge by `universalIdentifier` -- GitHub entries override npm entries (allowing curation)
4. Cache merged result with existing 1-hour TTL
### 3b. Add sourcePackage to MarketplaceAppDTO
The DTO needs a `sourcePackage: string | null` field so the install flow knows which npm package to resolve. For npm-discovered apps, this is the package name. For GitHub-only apps, this is null.
## Phase 4: Version Upgrade Support
### 4a. Create AppUpgradeService
**Periodic version check (npm-sourced apps only):**
- Fetches `{registryUrl}/{sourcePackage}/latest` from the npm registry
- Stores result in `AppRegistration.latestAvailableVersion`
- Frontend compares against `Application.version` to show "Update available"
**Upgrade trigger:**
1. Resolve the new version via AppPackageResolverService
2. Sync via existing ApplicationSyncService (triggers workspace migration for schema changes)
3. Update Application.version
**Rollback strategy:** If sync fails (e.g., migration validation error), re-resolve the previous version and re-sync. This is possible because npm retains all published versions.
### 4b. Version check scheduling
Lightweight cron or check-on-access pattern. Iterates over AppRegistrations where `sourcePackage IS NOT NULL` and calls `checkForUpdates()`. Frequency: once per hour, matching the existing marketplace cache TTL.
## Phase 5: SDK CLI Commands
### 5a. Finalize `twenty app:build`
Ensure `.twenty/output/` is npm-publishable. The build step generates a `package.json` in the output directory:
```json
{
"name": "twenty-app-fireflies",
"version": "1.2.0",
"keywords": ["twenty-app"],
"twenty": {
"universalIdentifier": "a4df0c0f-c65e-44e5-8436-24814182d4ac"
},
"files": ["manifest.json", "built-logic-function", "built-front-component", "public-asset"]
}
```
The developer then publishes with standard `npm publish` -- no custom command needed.
### 5b. `twenty app:pack` (new command)
```
twenty app:pack [appPath]
```
- Runs `app:build` if `.twenty/output/` doesn't exist or is stale
- Uses existing TarballService to create `{name}-{version}.tar.gz`
- Outputs the file path for manual distribution
### 5c. `twenty app:push` (new command)
```
twenty app:push [appPath] --server <url> --token <token>
```
- Runs `app:pack` to produce the tarball
- Reads universalIdentifier from manifest to find or create the AppRegistration
- Uploads via `POST /api/app-registrations/upload-tarball`
- Reports success with the registration ID
- Reuses `twenty auth:login` credentials if `--server` is not specified
## Phase 6: Server Tarball Upload Endpoint
### 6a. REST controller
```
POST /api/app-registrations/upload-tarball
Content-Type: multipart/form-data
Body: tarball file + optional universalIdentifier
```
**Validation:**
- Max file size: 50MB
- Must be a valid `.tar.gz`
- Extracted contents must contain `manifest.json` with a valid `universalIdentifier`
- The `universalIdentifier` must not conflict with an existing registration owned by a different user
**Flow:**
1. Extract tarball to temp directory
2. Validate manifest structure
3. Find or create AppRegistration by universalIdentifier
4. Store tarball in FileStorageService under `FileFolder.AppTarball`
5. Set `tarballFileId` on the AppRegistration
6. Return the AppRegistration entity
### 6b. Add FileFolder.AppTarball
New enum value `AppTarball = 'app-tarball'` in FileFolder.
## Key Design Decisions
| Decision | Rationale |
|---|---|
| Per-AppRegistration registry override | `registryUrl` on the entity allows mixing registries. Public apps from npmjs.com, private from GitHub Packages/Verdaccio. Server-wide `APP_REGISTRY_URL` is the fallback. |
| npm publish is standard | No custom publish infra. Free versioning, README, `npm audit`, download stats, proven auth model. |
| Tarball as escape hatch | Air-gapped environments, CI pipelines, one-off installs. Cannot auto-upgrade. |
| sourceType distinction | `'npm' \| 'tarball' \| 'local'` lets the system know which upgrade path is available. Only npm apps can check for newer versions. |
| Backward compatible | `app:dev` flow unchanged. GitHub marketplace unchanged. All new fields nullable. |
| Upgrade rollback | Re-resolve previous version from npm on failure. Safe because npm never deletes published versions. |
## Edge Cases
- **npm unreachable**: Timeout after 30s, throw clear error. App remains at current installed version.
- **Package name conflicts**: The `universalIdentifier` in the `twenty` field of `package.json` is the source of truth, not the npm package name. Two packages with the same universalIdentifier conflict at the AppRegistration level (unique index).
- **Scoped vs unscoped packages**: Both work. Scoped packages naturally route to a private registry via `.npmrc` scope mapping.
- **Multiple workspaces, same server**: AppRegistration is server-level (core schema). Application is workspace-level. One AppRegistration can be installed in multiple workspaces at different versions.
Binary file not shown.
+3 -3
View File
@@ -276,7 +276,7 @@
"generators": {
"@nx/react": {
"application": {
"style": "@emotion/styled",
"style": "@linaria/react",
"linter": "eslint",
"bundler": "vite",
"compiler": "swc",
@@ -284,7 +284,7 @@
"projectNameAndRootFormat": "derived"
},
"library": {
"style": "@emotion/styled",
"style": "@linaria/react",
"linter": "eslint",
"bundler": "vite",
"compiler": "swc",
@@ -292,7 +292,7 @@
"projectNameAndRootFormat": "derived"
},
"component": {
"style": "@emotion/styled"
"style": "@linaria/react"
}
}
},
+5 -3
View File
@@ -2,14 +2,13 @@
"private": true,
"dependencies": {
"@apollo/client": "^3.7.17",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@floating-ui/react": "^0.24.3",
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@radix-ui/colors": "^3.0.0",
"@sniptt/guards": "^0.2.0",
"@tabler/icons-react": "^3.31.0",
"@wyw-in-js/babel-preset": "^1.0.6",
"@wyw-in-js/vite": "^0.7.0",
"archiver": "^7.0.1",
"danger-plugin-todos": "^1.3.1",
@@ -41,6 +40,7 @@
"lodash.snakecase": "^4.1.1",
"lodash.upperfirst": "^4.3.1",
"microdiff": "^1.3.2",
"next-with-linaria": "^1.3.0",
"planer": "^1.2.0",
"pluralize": "^8.0.0",
"react": "^18.2.0",
@@ -202,7 +202,9 @@
"typescript": "5.9.2",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16"
"@types/qs": "6.9.16",
"@wyw-in-js/transform@npm:0.6.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch",
"@wyw-in-js/transform@npm:0.7.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch"
},
"version": "0.2.1",
"nx": {},
@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty/*
!.twenty/output/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
@@ -0,0 +1 @@
24.5.0
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,12 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
@@ -0,0 +1,51 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -0,0 +1,27 @@
{
"name": "apollo-enrich",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"eslint": "^9.32.0",
"react": "^18.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,54 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: 'ac1d2ed1-8835-4bd4-9043-28b46fdda465',
displayName: 'Apollo enrichment',
description: 'Data enrichment with Apollo to keep your data accurate',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
settingsCustomTabFrontComponentUniversalIdentifier: '50d59f7c-eada-4731-aacd-8e45371e1040',
applicationVariables: {
APOLLO_CLIENT_ID: {
universalIdentifier: '5852219e-7757-463e-9e7c-80980203794c',
isSecret: true,
value: '',
description: 'Apollo Client ID',
},
APOLLO_CLIENT_SECRET: {
universalIdentifier: 'a032349d-9458-4381-8505-82547276434a',
isSecret: true,
value: '',
description: 'Apollo Client Secret',
},
APOLLO_OAUTH_URL: {
universalIdentifier: '1d42411c-5809-4093-873a-8121b1302475',
isSecret: false,
value: '',
description: 'Apollo OAuth URL',
},
APOLLO_REDIRECT_URI: {
universalIdentifier: 'c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f',
isSecret: false,
value: '',
description: 'Apollo OAuth redirect URI',
},
APOLLO_REGISTERED_URL: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620770',
isSecret: false,
value: '',
description: 'Apollo registered URL',
},
APOLLO_ACCESS_TOKEN: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620771',
isSecret: true,
value: '',
description: 'Apollo access token',
},
APOLLO_REFRESH_TOKEN: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620772',
isSecret: true,
value: '',
description: 'Apollo refresh token',
},
},
});
@@ -0,0 +1,16 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'da15cfc6-3657-457d-8757-4ba11b5bb6e1',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.NUMBER,
name: 'apolloFoundedYear',
label: 'Founded Year',
description: 'Year the company was founded, from Apollo enrichment',
icon: 'IconCalendar',
});
@@ -0,0 +1,16 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: '505532f5-1fc5-4a58-8074-ba9b48650dbc',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.TEXT,
name: 'apolloIndustry',
label: 'Apollo Industry',
description: 'Industry classification from Apollo enrichment',
icon: 'IconBuildingFactory',
});
@@ -0,0 +1,16 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'be15e062-b065-48b4-979c-65b9a50e0cb1',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.TEXT,
name: 'apolloShortDescription',
label: 'Apollo Description',
description: 'Short company description from Apollo enrichment',
icon: 'IconFileDescription',
});
@@ -0,0 +1,16 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.CURRENCY,
name: 'apolloTotalFunding',
label: 'Total Funding',
description: 'Total funding raised by the company, from Apollo enrichment',
icon: 'IconCash',
});
@@ -0,0 +1,220 @@
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { OAuthApplicationVariables } from 'src/logic-functions/get-oauth-application-variables';
import { VERIFY_PAGE_PATH } from 'src/logic-functions/get-verify-page';
import { defineFrontComponent } from 'twenty-sdk';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
`;
const StyledSectionTitle = styled.h3`
color: #333;
font-family: 'Inter', sans-serif;
font-size: 15px;
font-weight: 600;
margin: 0 0 4px 0;
`;
const StyledSectionSubtitle = styled.p`
color: #818181;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 400;
margin: 0 0 12px 0;
`;
const StyledCard = styled.div`
align-items: center;
background: #fff;
border: 1px solid #ebebeb;
border-radius: 8px;
display: flex;
gap: 12px;
padding: 16px;
`;
const StyledIconContainer = styled.div`
align-items: center;
background: #f5f5f5;
border-radius: 8px;
color: #666;
display: flex;
flex-shrink: 0;
height: 40px;
justify-content: center;
width: 40px;
`;
const StyledTextContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const StyledTitle = styled.span`
color: #333;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
`;
const StyledDescription = styled.span`
color: #818181;
font-family: 'Inter', sans-serif;
font-size: 13px;
`;
const StyledLink = styled.a`
align-items: center;
background: #5e5adb;
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 4px;
box-sizing: border-box;
color: #fafafa;
cursor: pointer;
display: inline-flex;
flex-shrink: 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
gap: 4px;
height: 32px;
justify-content: center;
padding: 0 12px;
text-decoration: none;
transition: background 0.1s ease;
white-space: nowrap;
&:hover {
background: #4b47b8;
}
&:active {
background: #3c3996;
}
&:focus {
outline: none;
}
`;
const StyledConnectedStatus = styled.span`
align-items: center;
background: #10b981;
border-radius: 4px;
color: #fff;
display: inline-flex;
flex-shrink: 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
gap: 6px;
height: 32px;
padding: 0 12px;
white-space: nowrap;
`;
const StyledIcon = styled.img`
height: 24px;
width: 24px;
`;
const APOLLO_ICON_URL = 'https://twenty-icons.com/apollo.io';
const fetchOAuthApplicationVariables = async (): Promise<OAuthApplicationVariables> => {
const backEndUrl = `${process.env.TWENTY_API_URL}/s/oauth/application-variables`;
const response = await fetch(backEndUrl, {
method: 'GET',
});
const data = await response.json();
return data;
};
const buildOAuthUrl = (oauthApplicationVariables: OAuthApplicationVariables): string => {
const { apolloOAuthUrl, apolloClientId, apolloRegisteredUrl } = oauthApplicationVariables;
const redirectUri = `${apolloRegisteredUrl}auth/oauth-propagator/callback`;
const state = encodeURIComponent(`${process.env.TWENTY_API_URL}/s${VERIFY_PAGE_PATH}`);
return `${apolloOAuthUrl}?client_id=${apolloClientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code`;
};
const ApolloOAuthCta = () => {
const [oauthApplicationVariables, setOAuthApplicationVariables] =
useState<OAuthApplicationVariables | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchOAuthApplicationVariables()
.then(setOAuthApplicationVariables)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) {
return (
<StyledContainer>
<StyledSectionTitle>Connect to Apollo</StyledSectionTitle>
<StyledSectionSubtitle>Enrich your contacts with Apollo data</StyledSectionSubtitle>
<StyledCard>
<StyledIconContainer>
<StyledIcon src={APOLLO_ICON_URL} alt="Apollo" />
</StyledIconContainer>
<StyledTextContainer>
<StyledTitle>Apollo OAuth</StyledTitle>
<StyledDescription>Loading...</StyledDescription>
</StyledTextContainer>
</StyledCard>
</StyledContainer>
);
}
if (error || !oauthApplicationVariables) {
return null;
}
const isConnected = Boolean(oauthApplicationVariables.apolloAccessToken);
const oauthUrl = buildOAuthUrl(oauthApplicationVariables);
return (
<StyledContainer>
<StyledSectionTitle>Connect to Apollo</StyledSectionTitle>
<StyledSectionSubtitle>Enrich your contacts with Apollo data</StyledSectionSubtitle>
<StyledCard>
<StyledIconContainer>
<StyledIcon src={APOLLO_ICON_URL} alt="Apollo" />
</StyledIconContainer>
<StyledTextContainer>
<StyledTitle>Apollo OAuth</StyledTitle>
<StyledDescription>
{isConnected
? 'Your Apollo account is connected'
: 'Connect your Apollo account to enrich contacts'}
</StyledDescription>
</StyledTextContainer>
{isConnected ? (
<StyledConnectedStatus>
Connected
</StyledConnectedStatus>
) : (
<StyledLink href={oauthUrl} rel="noopener noreferrer">
Connect
</StyledLink>
)}
</StyledCard>
</StyledContainer>
);
};
export default defineFrontComponent({
universalIdentifier: '50d59f7c-eada-4731-aacd-8e45371e1040',
name: 'apollo-oauth-cta',
description: 'CTA button to connect to Apollo Enrichment via OAuth',
component: ApolloOAuthCta,
});
@@ -0,0 +1,105 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
import { MetadataApiClient } from 'twenty-sdk/generated';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
type ApolloTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
created_at: number;
};
const getAuthenticationTokenPairs = async (
code: string,
clientId: string,
clientSecret: string,
): Promise<ApolloTokenResponse> => {
const formData = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code: code,
redirect_uri: 'https://hjsm0q38-3000.uks1.devtunnels.ms/auth/oauth-propagator/callback',
});
const response = await fetch('https://app.apollo.io/api/v1/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to exchange code for tokens: ${response.status} - ${errorText}`);
}
return response.json();
};
const handler = async (event: RoutePayload): Promise<any> => {
const { queryStringParameters: { code } } = event;
if (!code) {
throw new Error('Code is required');
}
const apolloClientId = process.env.APOLLO_CLIENT_ID ?? '';
const apolloClientSecret = process.env.APOLLO_CLIENT_SECRET ?? '';
const applicationId = process.env.APPLICATION_ID ?? '';
const metadataClient = new MetadataApiClient({});
const tokenPairs = await getAuthenticationTokenPairs(
code,
apolloClientId,
apolloClientSecret,
);
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
key: 'APOLLO_ACCESS_TOKEN',
value: tokenPairs.access_token,
applicationId,
},
},
});
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
key: 'APOLLO_REFRESH_TOKEN',
value: tokenPairs.refresh_token,
applicationId,
},
},
});
return {tokenPairs};
};
export default defineLogicFunction({
universalIdentifier: '7ccc63a7-ece1-44c0-adbe-805a1baea03a',
name: 'get-authentication-token-pairs',
description: 'Returns the Apollo authentication token pairs',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: OAUTH_TOKEN_PAIRS_PATH,
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -0,0 +1,32 @@
import { defineLogicFunction } from 'twenty-sdk';
export type OAuthApplicationVariables = {
apolloClientId: string;
apolloRegisteredUrl: string;
apolloOAuthUrl: string;
apolloAccessToken: string;
apolloRefreshToken: string;
};
const handler = async (): Promise<OAuthApplicationVariables> => {
const apolloClientId = process.env.APOLLO_CLIENT_ID ?? '';
const apolloRegisteredUrl = process.env.APOLLO_REGISTERED_URL ?? '';
const apolloOAuthUrl = process.env.APOLLO_OAUTH_URL ?? '';
const apolloAccessToken = process.env.APOLLO_ACCESS_TOKEN ?? '';
const apolloRefreshToken = process.env.APOLLO_REFRESH_TOKEN ?? '';
return { apolloClientId, apolloRegisteredUrl, apolloOAuthUrl, apolloAccessToken, apolloRefreshToken };
};
export default defineLogicFunction({
universalIdentifier: 'b7c3e8f1-9d4a-4e2b-8f6c-1a5d3e7b9c2f',
name: 'get-oauth-application-variables',
description: 'Returns the Apollo OAuth authorization URL',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/oauth/application-variables',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -0,0 +1,90 @@
import { defineLogicFunction } from "twenty-sdk";
export const VERIFY_PAGE_PATH = '/oauth/verify';
const buildVerifyPageHtml = (applicationId: string): string => `<!DOCTYPE html>
<html>
<head>
<title>Apollo OAuth - Verifying...</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; }
.loading { color: #6b7280; }
.success { color: #10b981; }
.error { color: #ef4444; }
.spinner { border: 3px solid #f3f4f6; border-top: 3px solid #3b82f6; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
<script>
(async function() {
const applicationId = ${JSON.stringify(applicationId)};
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const baseUrl = window.location.origin;
function showError(message) {
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('spinner').style.display = 'none';
document.getElementById('title').textContent = '✗ Connection Failed';
document.getElementById('title').className = 'error';
document.getElementById('status').textContent = message;
});
if (window.opener) {
window.opener.postMessage({ type: 'APOLLO_OAUTH_ERROR', error: message }, '*');
}
}
if (!code) {
showError('Authorization code is missing. Please try connecting again.');
return;
}
try {
const response = await fetch(
baseUrl + '/s/oauth/token-pairs?code=' + encodeURIComponent(code),
{
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error('Failed to get tokens: ' + response.status + ' - ' + errorText);
}
const tokens = await response.json();
window.location.href = 'http://apple.localhost:3001/settings/applications/' + applicationId + '#custom';
} catch (error) {
showError(error.message);
}
})();
</script>
</head>
<body>
<div class="spinner" id="spinner"></div>
<h1 class="loading" id="title">Connecting to Apollo...</h1>
<p id="status">Please wait while we complete the connection.</p>
</body>
</html>`;
const handler = async (): Promise<string> => {
const applicationId = process.env.APPLICATION_ID ?? '';
return buildVerifyPageHtml(applicationId);
};
export default defineLogicFunction({
universalIdentifier: '4d74950a-d9c1-4c66-a799-89c1aea4e6b0',
name: 'get-verify-page',
description: 'Returns the Apollo OAuth verify page',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: VERIFY_PAGE_PATH,
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -0,0 +1,234 @@
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
type CompanyRecord = {
id: string;
name?: string;
domainName?: {
primaryLinkUrl?: string;
primaryLinkLabel?: string;
};
};
type ApolloOrganization = {
name?: string;
website_url?: string;
linkedin_url?: string;
twitter_url?: string;
estimated_num_employees?: number;
annual_revenue?: number;
total_funding?: number;
street_address?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
short_description?: string;
industry?: string;
founded_year?: number;
};
type ApolloEnrichResponse = {
organization?: ApolloOrganization;
};
const extractDomain = (
domainName?: CompanyRecord['domainName'],
): string | undefined => {
const url = domainName?.primaryLinkUrl;
if (!url) {
return undefined;
}
try {
const hostname = new URL(
url.startsWith('http') ? url : `https://${url}`,
).hostname;
return hostname.replace(/^www\./, '');
} catch {
return url.replace(/^(https?:\/\/)?(www\.)?/, '').split('/')[0];
}
};
const fetchApolloEnrichment = async (
domain: string,
): Promise<ApolloOrganization | undefined> => {
const response = await fetch(
`https://api.apollo.io/api/v1/organizations/enrich?domain=${encodeURIComponent(domain)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.APOLLO_ACCESS_TOKEN ?? ''}`,
},
},
);
const data: ApolloEnrichResponse = await response.json();
return data.organization;
};
const buildCompanyUpdateData = (
apolloOrganization: ApolloOrganization,
): Record<string, unknown> => {
const updateData: Record<string, unknown> = {};
if (apolloOrganization.name) {
updateData.name = apolloOrganization.name;
}
if (apolloOrganization.estimated_num_employees) {
updateData.employees = apolloOrganization.estimated_num_employees;
}
if (apolloOrganization.linkedin_url) {
updateData.linkedinLink = {
primaryLinkUrl: apolloOrganization.linkedin_url,
primaryLinkLabel: 'LinkedIn',
};
}
if (apolloOrganization.twitter_url) {
updateData.xLink = {
primaryLinkUrl: apolloOrganization.twitter_url,
primaryLinkLabel: 'X',
};
}
if (apolloOrganization.annual_revenue) {
updateData.annualRecurringRevenue = {
amountMicros: apolloOrganization.annual_revenue * 1_000_000,
currencyCode: 'USD',
};
}
const hasAddress =
apolloOrganization.street_address ||
apolloOrganization.city ||
apolloOrganization.state ||
apolloOrganization.country;
if (hasAddress) {
updateData.address = {
addressStreet1: apolloOrganization.street_address ?? '',
addressCity: apolloOrganization.city ?? '',
addressState: apolloOrganization.state ?? '',
addressPostcode: apolloOrganization.postal_code ?? '',
addressCountry: apolloOrganization.country ?? '',
};
}
if (apolloOrganization.industry) {
updateData.apolloIndustry = apolloOrganization.industry;
}
if (apolloOrganization.short_description) {
updateData.apolloShortDescription = apolloOrganization.short_description;
}
if (apolloOrganization.founded_year) {
updateData.apolloFoundedYear = apolloOrganization.founded_year;
}
if (apolloOrganization.total_funding) {
updateData.apolloTotalFunding = {
amountMicros: apolloOrganization.total_funding * 1_000_000,
currencyCode: 'USD',
};
}
return updateData;
};
const updateCompanyInTwenty = async (
companyId: string,
updateData: Record<string, unknown>,
): Promise<void> => {
const client = new CoreApiClient();
const result = await client.mutation({
updateCompany: {
__args: {
id: companyId,
data: updateData,
},
id: true,
},
});
if (!result.updateCompany) {
throw new Error(`Failed to update company ${companyId}: no result`);
}
};
type CompanyUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<CompanyRecord>
>;
const handler = async (
event: CompanyUpdateEvent,
): Promise<object | undefined> => {
const { recordId, properties } = event;
const { after: companyAfter } = properties;
const domain = extractDomain(companyAfter?.domainName);
if (!domain) {
return { skipped: true, reason: 'no domain found on company' };
}
const apolloOrganization = await fetchApolloEnrichment(domain);
if (!apolloOrganization) {
return {
skipped: true,
reason: `no Apollo data found for ${domain}`,
};
}
const updateData = buildCompanyUpdateData(apolloOrganization);
if (Object.keys(updateData).length === 0) {
return { skipped: true, reason: 'no enrichment data to apply' };
}
await updateCompanyInTwenty(recordId, updateData);
const result = {
enriched: true,
companyId: recordId,
domain,
updatedFields: Object.keys(updateData),
};
return result;
};
export default defineLogicFunction({
universalIdentifier: '6248b3fe-a8af-404a-8e38-19df98f73d81',
name: 'on-company-updated',
description:
'Enriches company data from Apollo when the company domain is updated',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'company.updated',
updatedFields: ['domainName'],
},
});
@@ -0,0 +1,13 @@
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: '08292efc-d7ba-4ec3-ab95-e7c33bd3a3bc',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
@@ -0,0 +1,13 @@
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: 'af7cd86e-149e-466a-8d60-312b6e46d604',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
@@ -0,0 +1,15 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b8faae3f-e174-43fa-ab94-715712ae26cb';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Apollo enrich default function role',
description: 'Apollo enrich default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: true,
});
@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
File diff suppressed because it is too large Load Diff
@@ -1,2 +1,3 @@
.yarn/install-state.gz
.env
.twenty
@@ -9,14 +9,16 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"create-entity": "twenty app add",
"dev": "twenty app dev",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
"auth": "twenty auth login"
"twenty": "twenty",
"auth": "twenty auth:login",
"dev": "twenty app:dev",
"build": "twenty app:build",
"typecheck": "twenty app:typecheck",
"uninstall": "twenty app:uninstall",
"entity:add": "twenty entity:add"
},
"dependencies": {
"twenty-sdk": "0.2.4"
"twenty-sdk": "0.6.3"
},
"devDependencies": {
"@types/node": "^24.7.2"
@@ -1,67 +1,55 @@
import type {
FunctionConfig,
DatabaseEventPayload,
ObjectRecordCreateEvent,
CronPayload,
import {
defineLogicFunction,
type CronPayload,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
} from 'twenty-sdk';
import Twenty, { type Person } from '../../generated';
import { CoreApiClient as Twenty, type CoreSchema } from 'twenty-sdk/generated';
type CreateNewPostCardParams =
| { name?: string }
| DatabaseEventPayload<ObjectRecordCreateEvent<Person>>
| DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Person>>
| CronPayload;
export const main = async (params: CreateNewPostCardParams) => {
try {
const client = new Twenty();
const handler = async (params: CreateNewPostCardParams) => {
const client = new Twenty();
const name =
'name' in params
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
const name =
'name' in params
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name,
},
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name,
},
name: true,
id: true,
},
});
name: true,
id: true,
},
});
console.log('createPostCard result', createPostCard);
console.log('createPostCard result', createPostCard);
return createPostCard;
} catch (error) {
console.error(error);
throw error;
}
return createPostCard;
};
export const config: FunctionConfig = {
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
},
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron',
pattern: '0 0 1 1 *', // Every year 1st of January
},
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent',
eventName: 'person.created',
},
],
};
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
},
cronTriggerSettings: {
pattern: '0 0 1 1 *',
},
databaseEventTriggerSettings: {
eventName: 'person.created',
},
});
@@ -1,6 +1,6 @@
import { type ApplicationConfig } from 'twenty-sdk';
import { defineApplication } from 'twenty-sdk';
const config: ApplicationConfig = {
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Hello World',
description: 'A simple hello world app',
@@ -14,6 +14,4 @@ const config: ApplicationConfig = {
},
},
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
};
export default config;
});
@@ -1,112 +1,89 @@
import { type Note } from '../../generated';
import { defineObject, FieldType } from 'twenty-sdk';
import {
type AddressField,
Field,
FieldType,
type FullNameField,
Object,
OnDeleteAction,
Relation,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
const POST_CARD_STATUS = {
DRAFT: 'DRAFT',
SENT: 'SENT',
DELIVERED: 'DELIVERED',
RETURNED: 'RETURNED',
} as const;
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
@Object({
export default defineObject({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
labelPlural: 'Post cards',
description: ' A post card object',
description: 'A post card object',
icon: 'IconMail',
})
export class PostCard {
@Field({
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
})
content: string;
@Field({
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldType.FULL_NAME,
label: 'Recipient name',
icon: 'IconUser',
})
recipientName: FullNameField;
@Field({
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldType.ADDRESS,
label: 'Recipient address',
icon: 'IconHome',
})
recipientAddress: AddressField;
@Field({
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
})
status: PostCardStatus;
@Relation({
universalIdentifier: 'c9e2b4f4-b9ad-4427-9b42-9971b785edfe',
type: RelationType.ONE_TO_MANY,
label: 'Notes',
icon: 'IconComment',
inverseSideTargetUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note.universalIdentifier,
onDelete: OnDeleteAction.CASCADE,
})
notes: Note[];
@Field({
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
})
deliveredAt?: Date;
}
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldType.TEXT,
name: 'content',
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldType.FULL_NAME,
name: 'recipientName',
label: 'Recipient name',
icon: 'IconUser',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldType.ADDRESS,
name: 'recipientAddress',
label: 'Recipient address',
icon: 'IconHome',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
name: 'status',
label: 'Status',
icon: 'IconSend',
defaultValue: `'${POST_CARD_STATUS.DRAFT}'`,
options: [
{
id: 'a1b2c3d4-0001-4000-8000-000000000001',
value: POST_CARD_STATUS.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: 'a1b2c3d4-0002-4000-8000-000000000002',
value: POST_CARD_STATUS.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: 'a1b2c3d4-0003-4000-8000-000000000003',
value: POST_CARD_STATUS.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: 'a1b2c3d4-0004-4000-8000-000000000004',
value: POST_CARD_STATUS.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
name: 'deliveredAt',
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
},
],
});
@@ -1,6 +1,6 @@
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
import { defineRole, PermissionFlag } from 'twenty-sdk';
export const functionRole: RoleConfig = {
export default defineRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
description: 'Default role for function Twenty client',
@@ -14,7 +14,7 @@ export const functionRole: RoleConfig = {
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
@@ -23,11 +23,11 @@ export const functionRole: RoleConfig = {
],
fieldPermissions: [
{
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
fieldUniversalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
};
});
File diff suppressed because it is too large Load Diff
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Use StyledComponents
Style the components with [styled-components](https://emotion.sh/docs/styled).
Style the components with [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Bad
@@ -136,7 +136,7 @@ That's expected as user is unauthorized when logged out since its identity is no
Comment out checker plugin in `packages/twenty-ui/vite-config.ts` like in example below
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### استخدام مكونات منسقة
قم بتنسيق المكونات باستخدام [styled-components](https://emotion.sh/docs/styled).
قم بتنسيق المكونات باستخدام [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ سيء
@@ -145,7 +145,7 @@ npx nx worker twenty-server
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -67,11 +67,11 @@ const EmailField: React.FC<{
```
```tsx
/* ✅ - Dobré, explicitně definován samostatný typ (OwnProps) pro
/* ✅ - Dobré, explicitně definován samostatný typ (OwnProps) pro
* rekvizity komponenty
* - Tato metoda automaticky nezahrnuje rekvizitu children. Pokud
* ji chcete zahrnout, musíte ji specifikovat v OwnProps.
*/
*/
type EmailFieldProps = {
value: string;
};
@@ -96,7 +96,7 @@ const MyComponent = (props: OwnProps) => {
```tsx
/* ✅ - Dobré, explicitně uvádí všechny rekvizity
* - Zvyšuje čitelnost a udržovatelnost
*/
*/
const MyComponent = ({ prop1, prop2, prop3 }: MyComponentProps) => {
return <OtherComponent {...{ prop1, prop2, prop3 }} />;
};
@@ -123,7 +123,7 @@ const value = process.env.MY_VALUE ?? 'default';
### Používejte volitelné zřetězení `?.`
```tsx
// ❌ Špatné
// ❌ Špatné
onClick && onClick();
// ✅ Dobré
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Používejte StyledComponents
Styling komponenty s [styled-components](https://emotion.sh/docs/styled).
Styling komponenty s [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Špatné
@@ -146,7 +146,7 @@ Zakomentujte plugin checker v `packages/twenty-ui/vite-config.ts` jako v příkl
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Verwenden Sie StyledComponents
Stylen Sie die Komponenten mit [styled-components](https://emotion.sh/docs/styled).
Stylen Sie die Komponenten mit [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Schlecht
@@ -145,7 +145,7 @@ Kommentieren Sie das Checker-Plugin in `packages/twenty-ui/vite-config.ts` wie i
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Usar StyledComponents
Estiliza los componentes con [styled-components](https://emotion.sh/docs/styled).
Estiliza los componentes con [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Bad
@@ -145,7 +145,7 @@ Comente el plugin checker en `packages/twenty-ui/vite-config.ts` como en el ejem
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Utilisez StyledComponents
Styliser les composants avec [styled-components](https://emotion.sh/docs/styled).
Styliser les composants avec [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Bad
@@ -145,7 +145,7 @@ Commentez le plugin de vérification dans `packages/twenty-ui/vite-config.ts` co
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Usa StyledComponents
Stile i componenti con [styled-components](https://emotion.sh/docs/styled).
Stile i componenti con [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Male
@@ -146,7 +146,7 @@ Commenta il plugin checker in `packages/twenty-ui/vite-config.ts` come mostrato
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### StyledComponentsを使用する
コンポーネントを[styled-components](https://emotion.sh/docs/styled)でスタイル設定する。
コンポーネントを[Linaria styled](https://github.com/callstack/linaria)でスタイル設定する。
```tsx
// ❌ Bad
@@ -152,7 +152,7 @@ npx nx worker twenty-server
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### StyledComponents 사용
[styled-components](https://emotion.sh/docs/styled)로 구성 요소를 스타일링하십시오.
[Linaria styled](https://github.com/callstack/linaria)로 구성 요소를 스타일링하십시오.
```tsx
// ❌ Bad
@@ -145,7 +145,7 @@ npx nx worker twenty-server
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Use StyledComponents
Estilize os componentes com [styled-components](https://emotion.sh/docs/styled).
Estilize os componentes com [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Bad
@@ -145,7 +145,7 @@ Comente o plugin checker em `packages/twenty-ui/vite-config.ts` como no exemplo
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Folosește ComponentaStilizată
Stilizează componentele cu [styled-components](https://emotion.sh/docs/styled).
Stilizează componentele cu [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Rău
@@ -146,7 +146,7 @@ Comentați pluginul checker în `packages/twenty-ui/vite-config.ts`, ca în exem
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### Использование StyledComponents
Стилизуйте компоненты с помощью [styled-components](https://emotion.sh/docs/styled).
Стилизуйте компоненты с помощью [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Плохо
@@ -146,7 +146,7 @@ npx nx worker twenty-server
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -68,7 +68,7 @@ const EmailField: React.FC<{
```tsx
/* ✅ - İyi, bileşenin prop'ları için ayrı bir tür (OwnProps) açıkça tanımlanmıştır
* - Bu yöntem çocuk prop'unu otomatik olarak içermez.
* - Bu yöntem çocuk prop'unu otomatik olarak içermez.
* Onu dahil etmek istiyorsanız, OwnProps'ta belirtmeniz gerekir.
*/
type EmailFieldProps = {
@@ -193,7 +193,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### StyledComponents kullanın
Bileşenleri [styled-components](https://emotion.sh/docs/styled) ile stillendirin.
Bileşenleri [Linaria styled](https://github.com/callstack/linaria) ile stillendirin.
```tsx
// ❌ Kötü
@@ -145,7 +145,7 @@ Aşağıdaki örnekte olduğu gibi `packages/twenty-ui/vite-config.ts` içindeki
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -194,7 +194,7 @@ setHotkeyScopeAndMemorizePreviousScope(
### 使用StyledComponents
使用[styled-components](https://emotion.sh/docs/styled)对组件进行样式化。
使用[Linaria styled](https://github.com/callstack/linaria)对组件进行样式化。
```tsx
// ❌ Bad
@@ -145,7 +145,7 @@ npx nx worker twenty-server
```
plugins: [
react({ jsxImportSource: '@emotion/react' }),
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
@@ -0,0 +1,115 @@
import { expect, test as base } from '@playwright/test';
import { LoginPage } from '../../lib/pom/loginPage';
const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
const loginAndSelectWorkspace = async (loginPage: LoginPage, page: any) => {
await page.waitForLoadState('networkidle');
await loginPage.clickLoginWithEmailIfVisible();
await loginPage.typeEmail(process.env.DEFAULT_LOGIN!);
await loginPage.clickContinueButton();
await loginPage.typePassword(process.env.DEFAULT_PASSWORD!);
await page.waitForLoadState('networkidle');
await loginPage.clickSignInButton();
await page.waitForLoadState('networkidle');
const workspaceButton = page.getByText('Apple', { exact: true });
await workspaceButton.waitFor({ state: 'visible', timeout: 15000 }).catch(
() => {
// Single workspace mode — no workspace selection
},
);
if (await workspaceButton.isVisible()) {
await workspaceButton.click();
}
await page.waitForFunction(
() =>
!window.location.href.includes('verify') &&
!window.location.href.includes('welcome'),
{ timeout: 15000 },
);
};
test.describe('Return-to-path after login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('should redirect to deep link after login', async ({
page,
loginPage,
}) => {
const deepLink = '/settings/accounts';
await test.step('Navigate to deep link while logged out', async () => {
await page.goto(deepLink);
await page.waitForURL('**/welcome');
await page.waitForLoadState('domcontentloaded');
});
await test.step('Log in and select workspace', async () => {
await loginAndSelectWorkspace(loginPage, page);
});
await test.step(
'Verify redirected to original deep link',
async () => {
await page.waitForURL(`**${deepLink}`, {
timeout: 30000,
waitUntil: 'commit',
});
expect(new URL(page.url()).pathname).toBe(deepLink);
},
);
await test.step(
'Verify return-to-path query param was consumed',
async () => {
const url = new URL(page.url());
expect(url.searchParams.has('returnToPath')).toBe(false);
},
);
});
test('should preserve path with query params across login', async ({
page,
loginPage,
}) => {
const targetPath =
'/authorize?clientId=test-client-id&redirectUrl=https%3A%2F%2Fexample.com%2Fcallback';
await test.step(
'Navigate to path with query params while logged out',
async () => {
await page.goto(targetPath);
await page.waitForURL('**/welcome');
await page.waitForLoadState('domcontentloaded');
},
);
await test.step('Log in and select workspace', async () => {
await loginAndSelectWorkspace(loginPage, page);
});
await test.step(
'Verify redirected to original path with query params',
async () => {
await page.waitForURL('**/authorize**', { timeout: 15000 });
const url = new URL(page.url());
expect(url.pathname).toBe('/authorize');
expect(url.searchParams.get('clientId')).toBe('test-client-id');
expect(url.searchParams.get('redirectUrl')).toBe(
'https://example.com/callback',
);
},
);
});
});
+5
View File
@@ -58,6 +58,11 @@ const config: StorybookConfig = {
return mergeConfig(viteConfig, {
logLevel: 'warn',
resolve: {
alias: {
'@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs',
},
},
});
},
+7 -10
View File
@@ -1,4 +1,3 @@
import { ThemeProvider } from '@emotion/react';
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { type Preview } from '@storybook/react-vite';
@@ -71,15 +70,13 @@ const preview: Preview = {
return (
<I18nProvider i18n={i18n}>
<ThemeProvider theme={theme}>
<ThemeContextProvider theme={theme}>
<ClickOutsideListenerContext.Provider
value={{ excludedClickOutsideId: undefined }}
>
<Story />
</ClickOutsideListenerContext.Provider>
</ThemeContextProvider>
</ThemeProvider>
<ThemeContextProvider theme={theme}>
<ClickOutsideListenerContext.Provider
value={{ excludedClickOutsideId: undefined }}
>
<Story />
</ClickOutsideListenerContext.Provider>
</ThemeContextProvider>
</I18nProvider>
);
},
+3 -3
View File
@@ -1,12 +1,12 @@
const getDefaultUrl = () => {
if (
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1'
window.location.hostname.endsWith('localhost') ||
window.location.hostname.endsWith('127.0.0.1')
) {
// In development environment front and backend usually run on separate ports
// we set the default value to localhost:3000.
// In dev context, we use env vars to overwrite it
return 'http://localhost:3000';
return `http://${window.location.hostname}:3000`;
} else {
// Outside of localhost we assume that they run on the same port
// because the backend will serve the frontend
-5
View File
@@ -1,5 +0,0 @@
import { type ThemeType } from 'twenty-ui/theme';
declare module '@emotion/react' {
export interface Theme extends ThemeType {}
}
@@ -3035,6 +3035,7 @@ export type MutationSaveImapSmtpCaldavAccountArgs = {
export type MutationSendInvitationsArgs = {
emails: Array<Scalars['String']>;
roleId?: InputMaybe<Scalars['UUID']>;
};
@@ -5451,6 +5452,7 @@ export type WorkspaceInvitation = {
email: Scalars['String'];
expiresAt: Scalars['DateTime'];
id: Scalars['UUID'];
roleId?: Maybe<Scalars['UUID']>;
};
export type WorkspaceInviteHashValid = {
@@ -5708,7 +5710,7 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{
export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean };
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }> };
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> };
export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -5720,7 +5722,7 @@ export type FindOneApplicationQueryVariables = Exact<{
}>;
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }> } };
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> } };
export type UploadFileMutationVariables = Exact<{
file: Scalars['Upload'];
@@ -6089,21 +6091,21 @@ export type FindOneFrontComponentQueryVariables = Exact<{
export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, builtComponentChecksum: string, isHeadless: boolean, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null };
export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string };
export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string };
export type CreateOneLogicFunctionMutationVariables = Exact<{
input: CreateLogicFunctionFromSourceInput;
}>;
export type CreateOneLogicFunctionMutation = { __typename?: 'Mutation', createOneLogicFunction: { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type CreateOneLogicFunctionMutation = { __typename?: 'Mutation', createOneLogicFunction: { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type DeleteOneLogicFunctionMutationVariables = Exact<{
input: LogicFunctionIdInput;
}>;
export type DeleteOneLogicFunctionMutation = { __typename?: 'Mutation', deleteOneLogicFunction: { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type DeleteOneLogicFunctionMutation = { __typename?: 'Mutation', deleteOneLogicFunction: { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type ExecuteOneLogicFunctionMutationVariables = Exact<{
input: ExecuteOneLogicFunctionInput;
@@ -6129,14 +6131,14 @@ export type FindManyAvailablePackagesQuery = { __typename?: 'Query', getAvailabl
export type FindManyLogicFunctionsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManyLogicFunctionsQuery = { __typename?: 'Query', findManyLogicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }> };
export type FindManyLogicFunctionsQuery = { __typename?: 'Query', findManyLogicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> };
export type FindOneLogicFunctionQueryVariables = Exact<{
input: LogicFunctionIdInput;
}>;
export type FindOneLogicFunctionQuery = { __typename?: 'Query', findOneLogicFunction: { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type FindOneLogicFunctionQuery = { __typename?: 'Query', findOneLogicFunction: { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string } };
export type GetLogicFunctionSourceCodeQueryVariables = Exact<{
input: LogicFunctionIdInput;
@@ -7172,19 +7174,20 @@ export type ResendWorkspaceInvitationMutationVariables = Exact<{
}>;
export type ResendWorkspaceInvitationMutation = { __typename?: 'Mutation', resendWorkspaceInvitation: { __typename?: 'SendInvitations', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> } };
export type ResendWorkspaceInvitationMutation = { __typename?: 'Mutation', resendWorkspaceInvitation: { __typename?: 'SendInvitations', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> } };
export type SendInvitationsMutationVariables = Exact<{
emails: Array<Scalars['String']> | Scalars['String'];
roleId?: InputMaybe<Scalars['UUID']>;
}>;
export type SendInvitationsMutation = { __typename?: 'Mutation', sendInvitations: { __typename?: 'SendInvitations', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> } };
export type SendInvitationsMutation = { __typename?: 'Mutation', sendInvitations: { __typename?: 'SendInvitations', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> } };
export type GetWorkspaceInvitationsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> };
export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> };
export type DeletedWorkspaceMemberQueryFragmentFragment = { __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } };
@@ -7394,6 +7397,9 @@ export const LogicFunctionFieldsFragmentDoc = gql`
handlerName
toolInputSchema
isTool
cronTriggerSettings
databaseEventTriggerSettings
httpRouteTriggerSettings
applicationId
createdAt
updatedAt
@@ -16582,6 +16588,7 @@ export const ResendWorkspaceInvitationDocument = gql`
... on WorkspaceInvitation {
id
email
roleId
expiresAt
}
}
@@ -16615,14 +16622,15 @@ export type ResendWorkspaceInvitationMutationHookResult = ReturnType<typeof useR
export type ResendWorkspaceInvitationMutationResult = Apollo.MutationResult<ResendWorkspaceInvitationMutation>;
export type ResendWorkspaceInvitationMutationOptions = Apollo.BaseMutationOptions<ResendWorkspaceInvitationMutation, ResendWorkspaceInvitationMutationVariables>;
export const SendInvitationsDocument = gql`
mutation SendInvitations($emails: [String!]!) {
sendInvitations(emails: $emails) {
mutation SendInvitations($emails: [String!]!, $roleId: UUID) {
sendInvitations(emails: $emails, roleId: $roleId) {
success
errors
result {
... on WorkspaceInvitation {
id
email
roleId
expiresAt
}
}
@@ -16645,6 +16653,7 @@ export type SendInvitationsMutationFn = Apollo.MutationFunction<SendInvitationsM
* const [sendInvitationsMutation, { data, loading, error }] = useSendInvitationsMutation({
* variables: {
* emails: // value for 'emails'
* roleId: // value for 'roleId'
* },
* });
*/
@@ -16660,6 +16669,7 @@ export const GetWorkspaceInvitationsDocument = gql`
findWorkspaceInvitations {
id
email
roleId
expiresAt
}
}
@@ -52,9 +52,11 @@ jest.mocked(useDefaultHomePagePath).mockReturnValue({
});
jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace');
jest.mocked(useIsCurrentLocationOnAWorkspace).mockReturnValue({
isOnAWorkspace: true,
});
const setupMockIsOnAWorkspace = (isOnAWorkspace: boolean) => {
jest.mocked(useIsCurrentLocationOnAWorkspace).mockReturnValue({
isOnAWorkspace,
});
};
jest.mock('react-router-dom');
const setupMockUseParams = (objectNamePlural?: string) => {
@@ -68,12 +70,14 @@ const setupMockState = (
objectNamePlural?: string,
verifyEmailRedirectPath?: string,
calendarBookingPageId?: string | null,
returnToPath?: string,
) => {
jest
.mocked(useAtomStateValue)
.mockReturnValueOnce(calendarBookingPageId ?? 'mock-calendar-id')
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }])
.mockReturnValueOnce(verifyEmailRedirectPath);
.mockReturnValueOnce(verifyEmailRedirectPath)
.mockReturnValueOnce(returnToPath ?? '');
};
// prettier-ignore
@@ -83,9 +87,11 @@ const testCases: {
isWorkspaceSuspended: boolean;
onboardingStatus: OnboardingStatus | undefined;
res: string | undefined;
isOnAWorkspace?: boolean;
objectNamePluralFromParams?: string;
objectNamePluralFromMetadata?: string;
verifyEmailRedirectPath?: string;
returnToPath?: string;
}[] = [
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
@@ -320,6 +326,15 @@ const testCases: {
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
// returnToPath: should redirect to saved path instead of defaultHomePagePath
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/authorize?clientId=abc', res: '/authorize?clientId=abc' },
{ loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/objects/tasks', res: '/objects/tasks' },
{ loc: AppPath.Index, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/settings/api-keys', res: '/settings/api-keys' },
// isOnAWorkspace:false — on default domain, don't redirect to returnToPath or defaultHomePagePath from auth pages
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined },
{ loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined },
];
describe('usePageChangeEffectNavigateLocation', () => {
@@ -330,17 +345,25 @@ describe('usePageChangeEffectNavigateLocation', () => {
onboardingStatus,
isWorkspaceSuspended,
isLoggedIn,
isOnAWorkspace,
objectNamePluralFromParams,
objectNamePluralFromMetadata,
verifyEmailRedirectPath,
returnToPath,
res,
}) => {
setupMockIsMatchingLocation(loc);
setupMockOnboardingStatus(onboardingStatus);
setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended);
setupMockIsLogged(isLoggedIn);
setupMockIsOnAWorkspace(isOnAWorkspace ?? true);
setupMockUseParams(objectNamePluralFromParams);
setupMockState(objectNamePluralFromMetadata, verifyEmailRedirectPath);
setupMockState(
objectNamePluralFromMetadata,
verifyEmailRedirectPath,
undefined,
returnToPath,
);
expect(usePageChangeEffectNavigateLocation()).toEqual(res);
},
@@ -355,7 +378,10 @@ describe('usePageChangeEffectNavigateLocation', () => {
.length) +
['nonExistingObjectInParam', 'existingObjectInParam:false'].length +
['caseWithRedirectionToVerifyEmailRedirectPath', 'caseWithout']
.length,
.length +
['returnToPath:verify', 'returnToPath:signInUp', 'returnToPath:index']
.length +
['notOnWorkspace:verify', 'notOnWorkspace:signInUp'].length,
);
});
});
@@ -1,10 +1,11 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { IconCopy, IconExclamationCircle } from 'twenty-ui/display';
import { useContext } from 'react';
import { ThemeContext } from 'twenty-ui/theme';
export const useCopyToClipboard = () => {
const theme = useTheme();
const { theme } = useContext(ThemeContext);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
@@ -1,5 +1,8 @@
import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState';
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { returnToPathState } from '@/auth/states/returnToPathState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
@@ -7,6 +10,8 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath';
import { isNonEmptyString } from '@sniptt/guards';
import { useLocation, useParams } from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -14,6 +19,12 @@ import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { OnboardingStatus } from '~/generated-metadata/graphql';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const readReturnToPathFromUrlSearchParams = (): string | null => {
const value = new URLSearchParams(window.location.search).get('returnToPath');
return value && isValidReturnToPath(value) ? value : null;
};
export const usePageChangeEffectNavigateLocation = () => {
const isLoggedIn = useIsLogged();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
@@ -27,22 +38,6 @@ export const usePageChangeEffectNavigateLocation = () => {
const someMatchingLocationOf = (appPaths: AppPath[]): boolean =>
appPaths.some((appPath) => isMatchingLocation(location, appPath));
const onGoingUserCreationPaths = [
AppPath.Invite,
AppPath.SignInUp,
AppPath.VerifyEmail,
AppPath.Verify,
];
const onboardingPaths = [
AppPath.CreateWorkspace,
AppPath.CreateProfile,
AppPath.SyncEmails,
AppPath.InviteTeam,
AppPath.PlanRequired,
AppPath.PlanRequiredSuccess,
AppPath.BookCallDecision,
AppPath.BookCall,
];
const objectNamePlural = useParams().objectNamePlural ?? '';
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
@@ -53,10 +48,15 @@ export const usePageChangeEffectNavigateLocation = () => {
verifyEmailRedirectPathState,
);
const returnToPath = useAtomStateValue(returnToPathState);
const resolvedReturnToPath = isNonEmptyString(returnToPath)
? returnToPath
: readReturnToPathFromUrlSearchParams();
if (
(!isLoggedIn || (isLoggedIn && !isOnAWorkspace)) &&
!someMatchingLocationOf([
...onGoingUserCreationPaths,
...ONGOING_USER_CREATION_PATHS,
AppPath.ResetPassword,
])
) {
@@ -135,15 +135,19 @@ export const usePageChangeEffectNavigateLocation = () => {
if (
onboardingStatus === OnboardingStatus.COMPLETED &&
someMatchingLocationOf([...onboardingPaths, ...onGoingUserCreationPaths]) &&
someMatchingLocationOf([
...ONBOARDING_PATHS,
...ONGOING_USER_CREATION_PATHS,
]) &&
!isMatchingLocation(location, AppPath.ResetPassword) &&
isLoggedIn
isLoggedIn &&
isOnAWorkspace
) {
return defaultHomePagePath;
return resolvedReturnToPath ?? defaultHomePagePath;
}
if (isMatchingLocation(location, AppPath.Index) && isLoggedIn) {
return defaultHomePagePath;
return resolvedReturnToPath ?? defaultHomePagePath;
}
if (
+1
View File
@@ -3,6 +3,7 @@ body {
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--t-background-tertiary);
}
html {
-2
View File
@@ -1,7 +1,5 @@
import ReactDOM from 'react-dom/client';
import '@emotion/react';
import { App } from '@/app/components/App';
import 'react-loading-skeleton/dist/skeleton.css';
import 'twenty-ui/style.css';
@@ -1,12 +1,12 @@
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useTheme } from '@emotion/react';
import { ANIMATION } from 'twenty-ui/theme';
import { useContext } from 'react';
import { ANIMATION, ThemeContext } from 'twenty-ui/theme';
import { MainNavigationDrawerItemsSkeletonLoader } from '~/loading/components/MainNavigationDrawerItemsSkeletonLoader';
const StyledAnimatedContainer = styled(motion.div)`
@@ -48,7 +48,7 @@ const StyledSkeletonTitleContainer = styled.div`
export const LeftPanelSkeletonLoader = () => {
const isMobile = useIsMobile();
const theme = useTheme();
const { theme } = useContext(ThemeContext);
return (
<StyledAnimatedContainer

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