Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ee15a60b3 | ||
|
|
ae291c99ba | ||
|
|
7809f83e72 | ||
|
|
27847f6ac6 | ||
|
|
6351c6c1c6 | ||
|
|
20a2c3836e | ||
|
|
1eb284c87f | ||
|
|
c4140f85df | ||
|
|
9c4b0f526c | ||
|
|
37bcb35391 |
@@ -0,0 +1,381 @@
|
||||
# Emotion → Linaria Migration Plan: twenty-front
|
||||
|
||||
## Overview
|
||||
|
||||
Migrate all Emotion (`@emotion/styled`, `@emotion/react`) usages in
|
||||
`packages/twenty-front/src` to Linaria (`@linaria/react`, `@linaria/core`),
|
||||
following the same patterns already established in the `twenty-ui` package.
|
||||
|
||||
Linaria is a **zero-runtime** CSS-in-JS library. Styles are extracted at
|
||||
build time by [wyw-in-js](https://wyw-in-js.dev/) (the Vite plugin is
|
||||
`@wyw-in-js/vite`, already configured in `twenty-front/vite.config.ts`).
|
||||
This means every expression inside a `styled` or `css` template literal
|
||||
must be statically evaluable at build time — no runtime theme objects,
|
||||
no closures over component state, no side-effects.
|
||||
|
||||
**Total files to migrate: ~998**
|
||||
|
||||
| Category | Files | Description |
|
||||
|---|---|---|
|
||||
| styled-only | 694 | Import `@emotion/styled` but not `useTheme` |
|
||||
| styled + useTheme | 224 | Import both `@emotion/styled` and `useTheme` |
|
||||
| useTheme-only | 79 | Import `useTheme` but not `@emotion/styled` |
|
||||
| css / Global only | 1 | Import `css` or `Global` from `@emotion/react` only |
|
||||
|
||||
## Theme Architecture
|
||||
|
||||
Two build-time utilities produce the theme system:
|
||||
|
||||
- **`buildThemeReferencingRootCssVariables`** — walks the theme object and
|
||||
builds a nested mirror where every leaf is a `var(--t-xxx)` string
|
||||
(evaluated at build time by wyw-in-js)
|
||||
- **`prepareThemeForRootCssVariableInjection`** — walks the runtime theme
|
||||
and collects flat `[--css-variable-name, value]` pairs, injected onto
|
||||
`document.documentElement` by `ThemeCssVariableInjectorEffect`
|
||||
|
||||
`themeCssVariables` is the build-time object; every leaf resolves to a CSS
|
||||
`var()` reference. It is safe to use inside `styled` and `css` templates
|
||||
because wyw-in-js can evaluate it statically.
|
||||
|
||||
## Migration Patterns
|
||||
|
||||
### 1. `styled` import
|
||||
|
||||
```diff
|
||||
- import styled from '@emotion/styled';
|
||||
+ import { styled } from '@linaria/react';
|
||||
```
|
||||
|
||||
### 2. Theme access in styled components
|
||||
|
||||
Replace Emotion's `({ theme }) =>` prop-function pattern with static
|
||||
`themeCssVariables` references:
|
||||
|
||||
```diff
|
||||
+ import { themeCssVariables } from 'twenty-ui/theme';
|
||||
|
||||
const StyledTitle = styled.span`
|
||||
- color: ${({ theme }) => theme.font.color.primary};
|
||||
- font-size: ${({ theme }) => theme.font.size.lg};
|
||||
+ color: ${themeCssVariables.font.color.primary};
|
||||
+ font-size: ${themeCssVariables.font.size.lg};
|
||||
`;
|
||||
```
|
||||
|
||||
### 3. Spacing
|
||||
|
||||
`theme.spacing(N)` is a function; in Linaria it becomes an indexed lookup:
|
||||
|
||||
```diff
|
||||
- margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
+ margin-top: ${themeCssVariables.spacing[3]};
|
||||
```
|
||||
|
||||
For multi-arg spacing like `theme.spacing(2, 4)` → `"8px 16px"`:
|
||||
|
||||
```diff
|
||||
- padding: ${({ theme }) => theme.spacing(2, 4)};
|
||||
+ padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[4]};
|
||||
```
|
||||
|
||||
The spacing scale covers integers 0–32 plus `0.5` and `1.5`. Any other
|
||||
fractional values (`0.25`, `0.75`, `1.25`, `2.5`, `3.5`) must be replaced
|
||||
with literal pixel values (e.g. `theme.spacing(2.5)` → `10px`).
|
||||
|
||||
### 4. `useTheme` → `useContext(ThemeContext)`
|
||||
|
||||
For runtime theme access (icon sizes, animation durations, conditional logic
|
||||
outside of styled components):
|
||||
|
||||
```diff
|
||||
- import { useTheme } from '@emotion/react';
|
||||
+ import { useContext } from 'react';
|
||||
+ import { ThemeContext } from 'twenty-ui/theme';
|
||||
|
||||
const MyComponent = () => {
|
||||
- const theme = useTheme();
|
||||
+ const { theme } = useContext(ThemeContext);
|
||||
return <Icon size={theme.icon.size.sm} />;
|
||||
};
|
||||
```
|
||||
|
||||
### 5. `css` template literal
|
||||
|
||||
Linaria's `css` (from `@linaria/core`) returns a **class name string**, not
|
||||
a serialized style object like Emotion's `css`. This has two consequences:
|
||||
|
||||
**Standalone usage** — apply via `className`, not the `css` prop:
|
||||
|
||||
```diff
|
||||
- import { css } from '@emotion/react';
|
||||
+ import { css } from '@linaria/core';
|
||||
|
||||
const myClass = css`
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
- <Link css={myClass} />
|
||||
+ <Link className={myClass} />
|
||||
```
|
||||
|
||||
**Inside `styled` templates** — do NOT nest `css` tags. Linaria's `css`
|
||||
returns a class name, not raw CSS text, so interpolating it inside `styled`
|
||||
produces broken output. Use plain strings instead:
|
||||
|
||||
```diff
|
||||
// WRONG — css`` returns a class name, not CSS text
|
||||
${({ handle }) =>
|
||||
handle === 'left'
|
||||
- ? css`left: ${themeCssVariables.spacing[1]};`
|
||||
- : css`right: ${themeCssVariables.spacing[1]};`}
|
||||
+ ? `left: ${themeCssVariables.spacing[1]};`
|
||||
+ : `right: ${themeCssVariables.spacing[1]};`}
|
||||
```
|
||||
|
||||
### 6. Interpolation return types
|
||||
|
||||
wyw-in-js requires prop interpolation functions to return `string | number`.
|
||||
They must **never** return `false`, `undefined`, or `null`. Replace
|
||||
short-circuit `&&` with ternary expressions:
|
||||
|
||||
```diff
|
||||
// WRONG — returns false when condition is false
|
||||
- ${({ isActive }) => isActive && `background: ${themeCssVariables.color.blue};`}
|
||||
// CORRECT
|
||||
+ ${({ isActive }) => isActive ? `background: ${themeCssVariables.color.blue};` : ''}
|
||||
```
|
||||
|
||||
### 7. Block interpolations (multi-declaration returns)
|
||||
|
||||
Linaria wraps each interpolation result in a single CSS custom property
|
||||
(`var(--xxx)`). An interpolation that returns **multiple CSS declarations**
|
||||
produces invalid CSS. Split into one interpolation per property:
|
||||
|
||||
```diff
|
||||
// WRONG — single interpolation returning multiple declarations
|
||||
- ${({ divider, theme }) => {
|
||||
- const border = `1px solid ${theme.border.color.light}`;
|
||||
- return divider === 'left' ? `border-left: ${border}` : `border-right: ${border}`;
|
||||
- }}
|
||||
|
||||
// CORRECT — one interpolation per property
|
||||
+ border-left: ${({ divider }) =>
|
||||
+ divider === 'left' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
|
||||
+ border-right: ${({ divider }) =>
|
||||
+ divider === 'right' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
|
||||
```
|
||||
|
||||
### 8. CSS var + unit concatenation
|
||||
|
||||
CSS custom properties can't be concatenated with unit suffixes directly
|
||||
(`var(--x)px` is invalid). Use `calc()` to attach units:
|
||||
|
||||
```diff
|
||||
- transition: background ${themeCssVariables.animation.duration.instant}s ease;
|
||||
+ transition: background calc(${themeCssVariables.animation.duration.instant} * 1s) ease;
|
||||
```
|
||||
|
||||
### 9. `styled(Component)` requires `className`
|
||||
|
||||
Linaria's `styled(Component)` works by passing a generated `className` to
|
||||
the wrapped component. The component **must** accept and forward a
|
||||
`className` prop — otherwise the styles are silently lost. If the component
|
||||
doesn't support it, either add `className` support or use a wrapper div.
|
||||
|
||||
Linaria also does **not** support Emotion's `shouldForwardProp` option.
|
||||
Custom props on HTML elements are automatically filtered by Linaria's
|
||||
runtime (via `@emotion/is-prop-valid`). For custom components, all props are
|
||||
forwarded — ensure the wrapped component ignores unknown props gracefully.
|
||||
|
||||
### 10. `type Theme` → `type ThemeType`
|
||||
|
||||
```diff
|
||||
- import { type Theme } from '@emotion/react';
|
||||
+ import { type ThemeType } from 'twenty-ui/theme';
|
||||
```
|
||||
|
||||
### 11. Framer Motion integration
|
||||
|
||||
Linaria doesn't support `styled(motion.div)` — wrapping a motion element
|
||||
with `styled()` causes the component body to be stripped at build time by
|
||||
wyw-in-js. Define the styled component first, then wrap with
|
||||
`motion.create()`:
|
||||
|
||||
```tsx
|
||||
const StyledBarBase = styled.div`
|
||||
background-color: ${themeCssVariables.font.color.primary};
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledBar = motion.create(StyledBarBase);
|
||||
```
|
||||
|
||||
### 12. Dynamic styles via CSS variables
|
||||
|
||||
When a component needs to compute styles from multiple props with complex
|
||||
branching logic (e.g. combining `variant`, `accent`, `disabled`, `focus`),
|
||||
Linaria's prop interpolations become unwieldy. Use a `computeDynamicStyles`
|
||||
helper that returns a `CSSProperties` object injected via `style={}`,
|
||||
referenced from the static CSS with `var()`:
|
||||
|
||||
```tsx
|
||||
const StyledButton = styled.button`
|
||||
background: var(--btn-bg);
|
||||
border-color: var(--btn-border-color);
|
||||
&:hover { background: var(--btn-hover-bg); }
|
||||
`;
|
||||
|
||||
const dynamicStyles = useMemo(() => {
|
||||
const s = computeButtonDynamicStyles(variant, accent, ...);
|
||||
return {
|
||||
'--btn-bg': s.background,
|
||||
'--btn-hover-bg': s.hoverBackground,
|
||||
} as CSSProperties;
|
||||
}, [variant, accent, ...]);
|
||||
|
||||
return <StyledButton style={dynamicStyles} />;
|
||||
```
|
||||
|
||||
### 13. `Global` component
|
||||
|
||||
Replace Emotion's `<Global styles={...} />` with standard CSS or the
|
||||
`ThemeCssVariableInjectorEffect` pattern from twenty-ui.
|
||||
|
||||
### 14. `ThemeProvider`
|
||||
|
||||
The `BaseThemeProvider` already wraps children with both Emotion's
|
||||
`ThemeProvider` and Linaria's `ThemeContextProvider`. Once all Emotion usages
|
||||
are gone, the Emotion `ThemeProvider` wrapper can be removed.
|
||||
|
||||
---
|
||||
|
||||
## PR Breakdown
|
||||
|
||||
Files are grouped to keep each PR around ~100 files with consistent review
|
||||
surface. We start with the simplest, lowest-risk modules.
|
||||
|
||||
### PR 1 (~97 files) — Small standalone modules
|
||||
|
||||
Low-risk modules with mostly simple `styled`-only patterns.
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| spreadsheet-import | 28 |
|
||||
| billing | 10 |
|
||||
| views | 14 |
|
||||
| navigation-menu-item | 14 |
|
||||
| blocknote-editor | 7 |
|
||||
| advanced-text-editor | 7 |
|
||||
| favorites | 7 |
|
||||
| navigation | 4 |
|
||||
| information-banner | 3 |
|
||||
| sign-in-background-mock | 3 |
|
||||
|
||||
### PR 2 (~100 files) — Auth, tiny modules, loading, testing, pages (part 1)
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| auth | 19 |
|
||||
| action-menu | 3 |
|
||||
| object-metadata | 3 |
|
||||
| onboarding | 2 |
|
||||
| workspace | 2 |
|
||||
| file | 2 |
|
||||
| error-handler | 2 |
|
||||
| front-components | 1 |
|
||||
| geo-map | 1 |
|
||||
| hooks | 1 |
|
||||
| loading | 5 |
|
||||
| testing | 5 |
|
||||
| pages (first ~55 files) | ~55 |
|
||||
|
||||
### PR 3 (~97 files) — Pages (remaining) + activities + AI
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| pages (remaining ~16 files) | ~16 |
|
||||
| activities | 53 |
|
||||
| ai | 28 |
|
||||
|
||||
### PR 4 (~100 files) — Command-menu + workflow (part 1)
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| command-menu | 53 |
|
||||
| workflow (first ~47 files) | ~47 |
|
||||
|
||||
### PR 5 (~115 files) — Workflow (remaining) + page-layout
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| workflow (remaining ~32 files) | ~32 |
|
||||
| page-layout | 83 |
|
||||
|
||||
### PR 6 (~100 files) — UI module (part 1)
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| ui (first ~100 files) | ~100 |
|
||||
|
||||
### PR 7 (~85 files) — UI module (remaining) + object-record (start)
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| ui (remaining ~23 files) | ~23 |
|
||||
| object-record (first ~62 files) | ~62 |
|
||||
|
||||
### PR 8 (~100 files) — Object-record (continued)
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| object-record (next ~100 files) | ~100 |
|
||||
|
||||
### PR 9 (~100 files) — Settings (part 1)
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| settings (first ~100 files) | ~100 |
|
||||
|
||||
### PR 10 (~102 files) — Settings (part 2) + final cleanup
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| settings (remaining ~102 files) | ~102 |
|
||||
| css/Global-only file | 1 |
|
||||
|
||||
### Post-migration PR — Remove Emotion
|
||||
|
||||
Once all PRs are merged:
|
||||
|
||||
- Remove `ThemeProvider` from `@emotion/react` in `BaseThemeProvider`
|
||||
- Remove `@emotion/styled` and `@emotion/react` dependencies
|
||||
- Remove `@styled/typescript-styled-plugin` from tsconfig
|
||||
- Clean up any remaining Emotion-related configuration
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| wyw-in-js evaluates at build time; dynamic expressions may fail | Use `themeCssVariables` for static theme values; pass dynamic values as component props or via `style={}` CSS variables |
|
||||
| `theme.spacing(N)` function → `themeCssVariables.spacing[N]` index | Pre-computed for integers 0–32 plus 0.5 and 1.5; other fractional values → literal pixel values |
|
||||
| `useTheme` used for runtime logic (not just styles) | Replace with `useContext(ThemeContext)`, destructure `{ theme }` |
|
||||
| Multi-arg `theme.spacing(a, b, c)` | Split into individual `themeCssVariables.spacing[N]` references |
|
||||
| `css` tag inside `styled` templates | Linaria `css` returns class name, not CSS text; use plain strings inside `styled` |
|
||||
| Interpolation returns `false` / `undefined` | wyw-in-js requires `string \| number`; use ternary `? : ''` instead of `&&` |
|
||||
| Block interpolations (multiple declarations) | Split into one interpolation per CSS property |
|
||||
| `var(--x)px` concatenation | Use `calc(var(--x) * 1px)` |
|
||||
| `styled(motion.div)` stripped by wyw-in-js | Use `motion.create(StyledBase)` pattern |
|
||||
| `styled(Component)` with no `className` prop | Add `className` support to wrapped component or use wrapper div |
|
||||
| Complex multi-prop style branching | Use `computeDynamicStyles` + `style={}` + `var()` references |
|
||||
|
||||
---
|
||||
|
||||
## Validation Checklist (per PR)
|
||||
|
||||
- [ ] `npx nx lint:diff-with-main twenty-front` passes
|
||||
- [ ] `npx nx typecheck twenty-front` passes
|
||||
- [ ] `npx nx test twenty-front` passes
|
||||
- [ ] Visual spot-check of affected components in the app
|
||||
- [ ] No remaining `@emotion/styled` or `@emotion/react` imports in migrated files
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
@@ -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 } };
|
||||
|
||||
@@ -16582,6 +16585,7 @@ export const ResendWorkspaceInvitationDocument = gql`
|
||||
... on WorkspaceInvitation {
|
||||
id
|
||||
email
|
||||
roleId
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
@@ -16615,14 +16619,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 +16650,7 @@ export type SendInvitationsMutationFn = Apollo.MutationFunction<SendInvitationsM
|
||||
* const [sendInvitationsMutation, { data, loading, error }] = useSendInvitationsMutation({
|
||||
* variables: {
|
||||
* emails: // value for 'emails'
|
||||
* roleId: // value for 'roleId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
@@ -16660,6 +16666,7 @@ export const GetWorkspaceInvitationsDocument = gql`
|
||||
findWorkspaceInvitations {
|
||||
id
|
||||
email
|
||||
roleId
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
+32
-6
@@ -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,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 (
|
||||
|
||||
@@ -212,6 +212,11 @@ msgstr "{currentRank} van {totalCount} in {objectLabelPlural}"
|
||||
msgid "{days, plural, one {{days} day} other {{days} days}}"
|
||||
msgstr "{days, plural, one {{days} dag} other {{days} dae}}"
|
||||
|
||||
#. js-lingui-id: Muj+po
|
||||
#: src/pages/settings/members/SettingsWorkspaceMembers.tsx
|
||||
msgid "{daysLeft} days"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: 0KwX9P
|
||||
#. placeholder {0}: (1000).toFixed(decimals)
|
||||
#. placeholder {1}: (1000).toFixed(decimals)
|
||||
@@ -468,6 +473,11 @@ msgstr "0"
|
||||
msgid "0 */1 * * *"
|
||||
msgstr "0 */1 * * *"
|
||||
|
||||
#. js-lingui-id: gphxoA
|
||||
#: src/pages/settings/members/SettingsWorkspaceMembers.tsx
|
||||
msgid "1 day"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: tEdOxj
|
||||
#: src/pages/settings/accounts/SettingsAccountsConfigurationStepEmail.tsx
|
||||
#: src/pages/settings/accounts/SettingsAccountsConfigurationStepEmail.tsx
|
||||
@@ -1139,11 +1149,6 @@ msgstr "Adres 1"
|
||||
msgid "Address 2"
|
||||
msgstr "Adres 2"
|
||||
|
||||
#. js-lingui-id: Eis4ey
|
||||
#: src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx
|
||||
msgid "Adjust the role-related settings"
|
||||
msgstr "Stel die rolverwante instellings aan"
|
||||
|
||||
#. js-lingui-id: U3pytU
|
||||
#: src/modules/settings/members/components/MemberInfosTab.tsx
|
||||
msgid "Admin"
|
||||
@@ -1962,6 +1967,11 @@ msgstr "Toegewys {roleTargetDisplayName}"
|
||||
msgid "Assigned to"
|
||||
msgstr "Toegewys aan"
|
||||
|
||||
#. js-lingui-id: TzM/0+
|
||||
#: src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx
|
||||
msgid "Assigned to users who join via invite link, approved domain, or SSO, and used as fallback when an assigned role is deleted"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: 0dtKl9
|
||||
#: src/modules/settings/roles/role/components/SettingsRole.tsx
|
||||
msgid "Assignment"
|
||||
@@ -4087,8 +4097,15 @@ msgstr "Verstek landkode"
|
||||
msgid "Default palette"
|
||||
msgstr "Verstekpalet"
|
||||
|
||||
#. js-lingui-id: v41VX6
|
||||
#: src/pages/settings/members/SettingsWorkspaceMembers.tsx
|
||||
#: src/modules/workspace/components/WorkspaceInviteTeam.tsx
|
||||
msgid "Default role"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: CGhRMh
|
||||
#: src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx
|
||||
#: src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx
|
||||
msgid "Default Role"
|
||||
msgstr "Verstekrol"
|
||||
|
||||
@@ -9782,7 +9799,6 @@ msgstr "Opsionele geheim gebruik om die HMAC-handtekening vir webhook-vragte te
|
||||
|
||||
#. js-lingui-id: 0zpgxV
|
||||
#: src/modules/ui/layout/dropdown/components/OptionsDropdownMenu.tsx
|
||||
#: src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx
|
||||
#: src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx
|
||||
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx
|
||||
#: src/modules/settings/admin-panel/config-variables/components/ConfigVariableFilterDropdown.tsx
|
||||
@@ -11088,6 +11104,7 @@ msgid "role"
|
||||
msgstr "rol"
|
||||
|
||||
#. js-lingui-id: GDvlUT
|
||||
#: src/pages/settings/members/SettingsWorkspaceMembers.tsx
|
||||
#: src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx
|
||||
#: src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx
|
||||
#: src/pages/settings/ai/SettingsAgentForm.tsx
|
||||
@@ -11912,10 +11929,10 @@ msgstr "Diensverskaffer Besonderhede"
|
||||
msgid "Set {placeholderForEmptyCell}"
|
||||
msgstr "Stel {placeholderForEmptyCell}"
|
||||
|
||||
#. js-lingui-id: YZwx1e
|
||||
#. js-lingui-id: QEVmIH
|
||||
#: src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx
|
||||
msgid "Set a default role for this workspace"
|
||||
msgstr "Stel | ||||