Compare commits

...
Author SHA1 Message Date
Charles Bochet b5b9efe451 chore: remove redundant undefined from backgroundColor type
Made-with: Cursor
2026-03-24 01:13:19 +01:00
Charles BochetandGitHub 69fe6fcadd chore: add explicit return type to getBackgroundColor (#18878)
Trivial typing improvement to trigger CI Front + CI UI visual regression
pipelines.
2026-03-24 01:10:22 +01:00
Charles BochetandGitHub 0ded15b363 Add visual regression CI for twenty-ui (#18877)
## Summary

- New workflow `ci-visual-regression.yaml` that runs on PRs touching
`twenty-ui` or `twenty-shared`
- Builds `twenty-ui` storybook and uploads the tarball as a GitHub
Actions artifact
- Dispatches to `twentyhq/ci-privileged` which handles the pixel-diff
comparison and posts a PR comment

### Flow

```
twenty CI (this PR)          ci-privileged                  pixel-perfect
─────────────────          ──────────────                  ──────────────
Build storybook
Upload artifact
Dispatch ──────────────►  Download artifact
                          Upload to S3 (OIDC)
                          POST /import-from-storage ────►  Import build
                          POST /diffs/run ──────────────►  Screenshots + diff
                          ◄──────────────────────────────  Diff report JSON
                          Post PR comment
```

Companion PRs:
- https://github.com/twentyhq/ci-privileged/pull/1 (ci-privileged
workflow)
- https://github.com/twentyhq/twenty-infra/pull/497 (OIDC trust + Helm
cleanup)
- https://github.com/twentyhq/pixel-perfect/pull/5 (API simplification)

## Test plan

- [ ] Merge companion PRs first and configure secrets/environments
- [ ] Open a test PR touching twenty-ui, verify storybook builds and
dispatch fires
- [ ] Verify visual regression comment appears on the PR
2026-03-24 00:15:45 +01:00
Charles BochetandGitHub 708e53d829 Fix multi-select option removal crashing when records contain removed values (#18871)
## Summary

- Fixes a bug where removing an option from a MULTI_SELECT field fails
with `invalid input value for enum` when existing records contain the
removed value alongside surviving values.
- The root cause was the `ELSE` branch in `updateArrayEnum` which tried
to cast removed enum values (e.g. `DISTRIBUTOR`) to the new enum type
that no longer includes them.
- The fix replaces the `ELSE` cast with a NULL-producing implicit CASE
default and uses `array_agg(...) FILTER (WHERE mapped_value IS NOT
NULL)` to silently strip removed values from existing arrays.

### Before (bug)
```sql
-- ELSE branch tries to cast removed value to new enum → crash
CASE unnest_value::text
  WHEN 'IMPL' THEN 'IMPL'::new_enum
  WHEN 'APP' THEN 'APP'::new_enum
  ELSE unnest_value::text::new_enum  -- 'DISTRIBUTOR' fails here
END
```

### After (fix)
```sql
-- No ELSE: removed values produce NULL, filtered out by array_agg
SELECT array_agg(mapped_value) FILTER (WHERE mapped_value IS NOT NULL)
FROM (
  SELECT CASE unnest_value::text
    WHEN 'IMPL' THEN 'IMPL'::new_enum
    WHEN 'APP' THEN 'APP'::new_enum
  END AS mapped_value
  FROM unnest(old_column) AS unnest_value
) enum_mapping
```
2026-03-23 20:06:34 +00:00
e2c85b5af0 Fix workspace dropdown truncation and center auth titles (#18869)
# Before

<img width="543" height="315" alt="CleanShot 2026-03-23 at 18 37 17"
src="https://github.com/user-attachments/assets/3a8233f8-507d-40e3-b4f0-0aae325bc4a8"
/>


# After

<img width="230" height="167" alt="CleanShot 2026-03-23 at 18 38 06"
src="https://github.com/user-attachments/assets/dda637d8-766a-4dc4-9909-78edef866c88"
/>

+ video: (long & short title)


https://github.com/user-attachments/assets/5373d168-6ffc-495b-909c-27fc1e68e712


also fixed text not centered in onboarding

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-03-23 21:06:14 +01:00
11 changed files with 432 additions and 24 deletions
+34
View File
@@ -151,6 +151,40 @@ jobs:
# npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
# - name: Checking coverage
# run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
visual-regression-dispatch:
needs: front-sb-build
if: github.event_name == 'pull_request'
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static
- name: Package storybook
run: tar -czf /tmp/storybook-twenty-front.tar.gz -C storybook-static .
- name: Upload storybook tarball
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-front-tarball
path: /tmp/storybook-twenty-front.tar.gz
retention-days: 1
- name: Dispatch to ci-privileged
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ github.event.pull_request.number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "twenty-front",
"branch": "${{ github.head_ref }}",
"commit": "${{ github.event.pull_request.head.sha }}"
}
front-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
+146
View File
@@ -0,0 +1,146 @@
name: CI UI
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-ui/**
packages/twenty-shared/**
ui-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }}
run: npx nx ${{ matrix.task }} twenty-ui
ui-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build storybook
run: npx nx storybook:build twenty-ui
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
retention-days: 1
ui-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: ui-sb-build
env:
STORYBOOK_URL: http://localhost:6007
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-ui
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-ui/storybook-static --port 6007 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6007 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-ui
visual-regression-dispatch:
needs: ui-sb-build
if: github.event_name == 'pull_request'
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: storybook-static
- name: Package storybook
run: tar -czf /tmp/storybook-twenty-ui.tar.gz -C storybook-static .
- name: Upload storybook tarball
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui-tarball
path: /tmp/storybook-twenty-ui.tar.gz
retention-days: 1
- name: Dispatch to ci-privileged
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ github.event.pull_request.number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "twenty-ui",
"branch": "${{ github.head_ref }}",
"commit": "${{ github.event.pull_request.head.sha }}"
}
ci-ui-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
ui-task,
ui-sb-build,
ui-sb-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -15,6 +15,7 @@ const StyledTitle = styled.div<Pick<TitleProps, 'noMarginTop'>>`
margin-bottom: ${themeCssVariables.spacing[4]};
margin-top: ${({ noMarginTop }) =>
!noMarginTop ? themeCssVariables.spacing[4] : '0'};
text-align: center;
`;
export const Title = ({
@@ -27,8 +27,9 @@ export const MultiWorkspaceDropdownButton = () => {
return (
<Dropdown
dropdownId={MULTI_WORKSPACE_DROPDOWN_ID}
dropdownOffset={{ y: -30, x: -5 }}
dropdownOffset={{ y: -29, x: -5 }}
clickableComponent={<MultiWorkspaceDropdownClickableComponent />}
clickableComponentWidth="100%"
dropdownComponents={<DropdownComponents />}
onClose={() => {
setMultiWorkspaceDropdown('default');
@@ -4,6 +4,7 @@ import {
StyledContainer,
StyledIconChevronDown,
StyledLabel,
StyledLabelWrapper,
} from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspacesDropdownStyles';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -11,6 +12,7 @@ import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNaviga
import { useContext } from 'react';
import { Avatar } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
export const MultiWorkspaceDropdownClickableComponent = () => {
const { theme } = useContext(ThemeContext);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
@@ -27,9 +29,11 @@ export const MultiWorkspaceDropdownClickableComponent = () => {
placeholder={currentWorkspace?.displayName || ''}
avatarUrl={currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledLabel>{currentWorkspace?.displayName ?? ''}</StyledLabel>
</NavigationDrawerAnimatedCollapseWrapper>
<StyledLabelWrapper>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledLabel>{currentWorkspace?.displayName ?? ''}</StyledLabel>
</NavigationDrawerAnimatedCollapseWrapper>
</StyledLabelWrapper>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledIconChevronDown
size={theme.icon.size.md}
@@ -12,22 +12,27 @@ export const StyledContainer = styled.div<{
cursor: pointer;
display: flex;
gap: ${({ isNavigationDrawerExpanded }) =>
isNavigationDrawerExpanded ? themeCssVariables.spacing[1] : '0'};
height: ${themeCssVariables.spacing[5]};
justify-content: space-between;
isNavigationDrawerExpanded ? themeCssVariables.spacing[2] : '0'};
max-width: 100%;
min-width: 0;
padding: calc(${themeCssVariables.spacing[1]} - 1px);
width: ${({ isNavigationDrawerExpanded }) =>
isNavigationDrawerExpanded ? '100%' : 'auto'};
&:hover {
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
}
`;
export const StyledLabelWrapper = styled.div`
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
`;
export const StyledLabel = styled.div`
align-items: center;
display: flex;
font-weight: ${themeCssVariables.font.weight.medium};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledIconChevronDownContainer = styled.div<{ disabled?: boolean }>`
@@ -28,6 +28,7 @@ const StyledRightActions = styled.div<{ isExpanded: boolean }>`
align-self: ${({ isExpanded }) => (isExpanded ? 'auto' : 'flex-end')};
display: flex;
flex-direction: ${({ isExpanded }) => (isExpanded ? 'row' : 'column')};
flex-shrink: 0;
gap: ${({ isExpanded }) => (isExpanded ? '0' : themeCssVariables.spacing[1])};
margin-left: ${({ isExpanded }) => (isExpanded ? 'auto' : '0')};
transition: gap calc(${themeCssVariables.animation.duration.normal} * 1s) ease;
@@ -44,8 +45,9 @@ const StyledNavigationDrawerCollapseButtonContainer = styled.div`
const StyledWorkspaceDropdownContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
flex: 1 1 auto;
min-height: ${themeCssVariables.spacing[8]};
min-width: 0;
`;
type NavigationDrawerHeaderProps = {
@@ -327,7 +327,6 @@ export class WorkspaceSchemaEnumManagerService {
escapedTable,
escapedOldColumn,
escapedNewColumn,
escapedNewEnumType,
escapedOldEnumType: `${escapedSchema}.${escapeIdentifier(oldEnumTypeName)}`,
caseStatements,
mappedValuesCondition,
@@ -349,7 +348,6 @@ export class WorkspaceSchemaEnumManagerService {
escapedOldColumn,
escapedSchema,
escapedTable,
escapedNewEnumType,
escapedOldEnumType,
caseStatements,
mappedValuesCondition,
@@ -358,7 +356,6 @@ export class WorkspaceSchemaEnumManagerService {
escapedTable: string;
escapedOldColumn: string;
escapedNewColumn: string;
escapedNewEnumType: string;
escapedOldEnumType: string;
caseStatements: string;
mappedValuesCondition: string;
@@ -366,13 +363,14 @@ export class WorkspaceSchemaEnumManagerService {
return `
UPDATE ${escapedSchema}.${escapedTable}
SET ${escapedNewColumn} = (
SELECT array_agg(
CASE unnest_value::text
${caseStatements}
ELSE unnest_value::text::${escapedNewEnumType}
END
)
FROM unnest(${escapedOldColumn}) AS unnest_value
SELECT array_agg(mapped_value) FILTER (WHERE mapped_value IS NOT NULL)
FROM (
SELECT
CASE unnest_value::text
${caseStatements}
END AS mapped_value
FROM unnest(${escapedOldColumn}) AS unnest_value
) enum_mapping
)
WHERE ${escapedOldColumn} IS NOT NULL
AND ${escapedOldColumn} && ARRAY[${mappedValuesCondition}]::${escapedOldEnumType}[]`;
@@ -0,0 +1,214 @@
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
import { findOneOperation } from 'test/integration/graphql/utils/find-one-operation.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
const TEST_OBJECT_NAME_SINGULAR = 'testMultiSelectRemoval';
const TEST_OBJECT_NAME_PLURAL = 'testMultiSelectRemovals';
// Reproduces the bug where removing a multi-select option fails when
// existing records contain both the removed option and surviving options.
describe('remove multi-select option with side effect on records', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
let firstOptionId: string;
let thirdOptionId: string;
beforeAll(async () => {
const { data: objectData } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: TEST_OBJECT_NAME_SINGULAR,
namePlural: TEST_OBJECT_NAME_PLURAL,
labelSingular: 'Test Multi Select Removal',
labelPlural: 'Test Multi Select Removals',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = objectData.createOneObject.id;
const { data: fieldData } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.MULTI_SELECT,
name: 'tagField',
label: 'Tag Field',
isLabelSyncedWithName: false,
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 0,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'blue',
position: 1,
},
{
label: 'Option 3',
value: 'OPTION_3',
color: 'red',
position: 2,
},
],
},
gqlFields: `
id
options
`,
});
createdFieldMetadataId = fieldData.createOneField.id;
const options = fieldData.createOneField.options;
if (!options) {
throw new Error('Options should be defined');
}
const option1 = options.find(
(opt: { value: string }) => opt.value === 'OPTION_1',
);
const option2 = options.find(
(opt: { value: string }) => opt.value === 'OPTION_2',
);
const option3 = options.find(
(opt: { value: string }) => opt.value === 'OPTION_3',
);
if (!option1?.id || !option2?.id || !option3?.id) {
throw new Error('All options and their IDs should be defined');
}
firstOptionId = option1.id;
thirdOptionId = option3.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it('should strip the removed option from existing records that contain both surviving and removed values', async () => {
// Record with OPTION_1 + OPTION_2 (OPTION_2 will be removed)
const { data: mixedRecordData } = await createOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
input: { tagField: ['OPTION_1', 'OPTION_2'] },
gqlFields: `id tagField`,
});
const mixedRecordId = mixedRecordData.createOneResponse.id;
expect(mixedRecordData.createOneResponse.tagField).toEqual([
'OPTION_1',
'OPTION_2',
]);
// Record with only OPTION_2 (the option that will be removed)
const { data: removedOnlyData } = await createOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
input: { tagField: ['OPTION_2'] },
gqlFields: `id tagField`,
});
const removedOnlyRecordId = removedOnlyData.createOneResponse.id;
// Record with only surviving options
const { data: survivingData } = await createOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
input: { tagField: ['OPTION_1', 'OPTION_3'] },
gqlFields: `id tagField`,
});
const survivingRecordId = survivingData.createOneResponse.id;
// Remove OPTION_2 -- keep OPTION_1 and OPTION_3
const { data: updateData, errors: updateErrors } =
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
options: [
{
id: firstOptionId,
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 0,
},
{
id: thirdOptionId,
label: 'Option 3',
value: 'OPTION_3',
color: 'red',
position: 1,
},
],
},
},
gqlFields: `id options`,
});
expect(updateErrors).toBeUndefined();
expect(updateData.updateOneField.options).toHaveLength(2);
// Mixed record: OPTION_2 should be stripped, only OPTION_1 remains
const { data: mixedAfter } = await findOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
gqlFields: `id tagField`,
filter: { id: { eq: mixedRecordId } },
});
expect(mixedAfter.findResponse.tagField).toEqual(['OPTION_1']);
// Removed-only record: all values were removed, field is empty
const { data: removedOnlyAfter } = await findOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
gqlFields: `id tagField`,
filter: { id: { eq: removedOnlyRecordId } },
});
const removedOnlyTagField = removedOnlyAfter.findResponse.tagField;
expect(
removedOnlyTagField === null || removedOnlyTagField.length === 0,
).toBe(true);
// Surviving record: should be unchanged
const { data: survivingAfter } = await findOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
gqlFields: `id tagField`,
filter: { id: { eq: survivingRecordId } },
});
expect(survivingAfter.findResponse.tagField).toEqual([
'OPTION_1',
'OPTION_3',
]);
});
});
@@ -29,7 +29,7 @@ const StyledLayout = styled.div<{
type ComponentStorybookLayoutProps = {
width?: number;
backgroundColor?: string | undefined;
backgroundColor?: string;
height?: number;
children: JSX.Element;
};
@@ -4,7 +4,10 @@ import { themeCssVariables } from '@ui/theme-constants';
import { ComponentStorybookLayout } from '../ComponentStorybookLayout';
const getBackgroundColor = (inverted: boolean, accent: string) => {
const getBackgroundColor = (
inverted: boolean,
accent: string,
): string | undefined => {
if (!inverted) return undefined;
switch (accent) {