Compare commits

..
Author SHA1 Message Date
Abdul Rahman e5782ab95c Refactor SidePanelEdit components to streamline props and imports
- Moved `SidePanelGroup` and `SidePanelList` imports to the correct location in `SidePanelEditLinkItemView.tsx`.
- Removed the `moveToFolderHasSubMenu` prop from `SidePanelEditOrganizeActions` and adjusted its usage accordingly.
- Simplified the handling of the `hasSubMenu` property in the `SelectableListItem` component.
2026-03-24 05:13:12 +05:30
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
Thomas TrompetteandGitHub c6d4162b73 Fix: Background color selected row + border bottom settings tab (#18870)
<img width="1285" height="73" alt="Capture d’écran 2026-03-23 à 18 58
09"
src="https://github.com/user-attachments/assets/a01cfc8e-6492-4ebe-a47a-b504a73e616c"
/>
<img width="574" height="59" alt="Capture d’écran 2026-03-23 à 18 58
39"
src="https://github.com/user-attachments/assets/4947ec02-e151-48fb-87e9-86dbc0ed4fe9"
/>
2026-03-23 19:22:28 +00:00
93de331428 i18n - translations (#18873)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-03-23 19:36:47 +01:00
Abdul RahmanandGitHub 24055527c8 Rename "New sidebar item" to "New menu item" (#18859) 2026-03-23 18:21:41 +00:00
EtienneandGitHub c58bf9cf06 Fix e2e test (#18866)
Three feature flags changed the UI:
IS_NAVIGATION_MENU_ITEM_ENABLED — Sidebar no longer has a default
"People" link → navigate via URL instead
IS_COMMAND_MENU_ITEM_ENABLED — Button label is now "Create new Person"
(interpolated from object name) instead of static "Create new record"
IS_JUNCTION_RELATIONS_ENABLED — Company is now a junction relation
("Previous Companies") displayed inline, no longer a boxed
dynamic-relation-widget on the record page
2026-03-23 16:56:15 +00:00
Baptiste DevessierandGitHub 07a4cf2d26 Ensure command backfills all relations as Field widget (#18858)
\+ add missing Field widget for workflow relations

Copies the logic of the injectRelationWidgetsIntoLayout front-end
function
2026-03-23 17:49:05 +01:00
Raphaël BosiandGitHub 1cfdd2e8ed Update BackfillCommandMenuItemsCommand with workflow backfill (#18848)
Updated upgrade command to also backfill command menu items for
workflows.
This has been done in the same command because we need to enable the
feature flag once both operations are complete: the creation of the
standard command menu items and the creation of workflow command menu
items.
2026-03-23 17:48:50 +01:00
Félix MalfaitGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>EtienneEtienne
6f4f7a1198 fix: add security headers to file serving endpoints to prevent stored XSS (#18857)
## Summary

- File serving endpoints (`GET file/:fileFolder/:id` and `GET
public-assets/...`) were piping S3/local file streams directly to the
response without any HTTP headers, allowing a stored XSS attack via
uploaded HTML files rendered inline on the CRM origin.
- Adds `Content-Type`, `Content-Disposition`, and
`X-Content-Type-Options: nosniff` headers to all file serving responses.
Only known-safe MIME types (images, PDF, plain text, audio, video) are
served inline; everything else (HTML, SVG, XML, etc.) forces
`Content-Disposition: attachment` to trigger download instead of
rendering.
- New `setFileResponseHeaders` utility with an explicit allowlist of
inline-safe MIME types.

## Test plan

- [x] Unit tests pass (9 tests including 2 new ones: header assertions
and attachment-disposition for HTML)
- [x] Lint clean (`lint:diff-with-main`)
- [x] Typecheck clean (`nx typecheck twenty-server`)
- [ ] Manual: upload an HTML file via `uploadWorkflowFile`, access the
returned URL — should download instead of rendering
- [ ] Manual: upload a PNG image, access the URL — should render inline
with correct `Content-Type: image/png`
- [ ] Manual: verify `X-Content-Type-Options: nosniff` header is present
on all file responses


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Etienne <[email protected]>
Co-authored-by: Etienne <[email protected]>
2026-03-23 16:19:16 +01:00
Charles BochetandGitHub 0626f3e469 Fix deploy: remove stale @ai-sdk/groq from yarn.lock (#18863)
## Summary

- The front deploy to S3 is failing because `@ai-sdk/groq` was removed
from `packages/twenty-server/package.json` but the `yarn.lock` was never
updated
2026-03-23 15:39:35 +01:00
94 changed files with 1594 additions and 501 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'
+144
View File
@@ -0,0 +1,144 @@
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: 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
@@ -6,8 +6,14 @@ const query = `query FindOnePerson($objectRecordId: UUID!) {
person(
filter: {or: [{deletedAt: {is: NULL}}, {deletedAt: {is: NOT_NULL}}], id: {eq: $objectRecordId}}
) {
company {
name
previousCompanies {
edges {
node {
company {
name
}
}
}
}
emails {
primaryEmail
@@ -43,8 +49,8 @@ const query = `query FindOnePerson($objectRecordId: UUID!) {
}`
test('Create and update record', async ({ page }) => {
await page.getByRole('link', { name: 'People' }).click();
await page.getByRole('button', { name: 'Create new record' }).click();
await page.goto('/objects/people');
await page.getByRole('button', { name: 'Create new Person' }).click();
// Generate a random email for testing
const randomEmail = `testuser_${Math.random().toString(36).substring(2, 10)}@example.com`;
@@ -107,29 +113,17 @@ test('Create and update record', async ({ page }) => {
await options.getByText('Hybrid').first().click({force: true});
recordFieldList.getByText('Work Preference').first().click({force: true});
// Fill company relation
const companyRelationWidget = page.getByTestId(/dynamic-relation-widget-.+-Company/);
await expect(companyRelationWidget).toBeVisible();
// Fill previous companies
await recordFieldList.getByText('Previous Companies').first().click({force: true});
await recordFieldList.getByText('Previous Companies').nth(1).click({force: true});
await page.getByPlaceholder('Search').fill('VMw');
await page.getByRole('listbox').first().getByText('VMware').click({force: true});
await page.keyboard.press('Escape');
await companyRelationWidget.hover();
await companyRelationWidget.locator('.tabler-icon-pencil').click();
await page.getByRole('textbox', { name: 'Search' }).fill('VMw');
await expect(page.getByRole('option', { name: 'VMware' })).toBeVisible();
const [updatePersonResponse] = await Promise.all([
page.waitForResponse(async (response) => {
if (!response.url().endsWith('/graphql')) {
return false;
}
const requestBody = response.request().postDataJSON();
return requestBody.operationName === 'UpdateOnePerson';
}),
await page.getByRole('option', { name: 'VMware' }).click({force: true})
]);
const body = await updatePersonResponse.json()
const newPersonId = body.data.updatePerson.id;
// Open full record page to get person ID
await page.getByRole('button', { name: /^Open/ }).click();
await page.waitForURL(/\/object\/person\//);
const newPersonId = page.url().match(/\/object\/person\/([a-f0-9-]+)/)?.[1];
// Check data was saved
const { authToken } = await getAccessAuthToken(page);
@@ -155,6 +149,6 @@ test('Create and update record', async ({ page }) => {
expect(findOnePersonReponseBody.data.person.linkedinLink.primaryLinkUrl).toBe('linkedin.com/johndoe');
expect(findOnePersonReponseBody.data.person.phones.primaryPhoneNumber).toBe('611223344');
expect(findOnePersonReponseBody.data.person.workPreference).toEqual(['HYBRID']);
expect(findOnePersonReponseBody.data.person.company.name).toBe('VMware');
expect(findOnePersonReponseBody.data.person.previousCompanies.edges[0].node.company.name).toBe('VMware');
});
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nuwe sleutel"
msgid "New Key"
msgstr "Nuwe Sleutel"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nuwe Wagwoord"
msgid "New record"
msgstr "Nuwe rekord"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "مفتاح جديد"
msgid "New Key"
msgstr "مفتاح جديد"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "كلمة سر جديدة"
msgid "New record"
msgstr "سجل جديد"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nova clau"
msgid "New Key"
msgstr "Nova Clau"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nova contrasenya"
msgid "New record"
msgstr "Nou registre"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nový klíč"
msgid "New Key"
msgstr "Nový klíč"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nové heslo"
msgid "New record"
msgstr "Nový záznam"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Ny nøgle"
msgid "New Key"
msgstr "Ny Nøgle"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nyt Password"
msgid "New record"
msgstr "Ny post"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Neuer Schlüssel"
msgid "New Key"
msgstr "Neuer Schlüssel"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Neues Passwort"
msgid "New record"
msgstr "Neuer Datensatz"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Νέο κλειδί"
msgid "New Key"
msgstr "Νέο Κλειδί"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Νέος Κωδικός"
msgid "New record"
msgstr "Νέα εγγραφή"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9479,6 +9479,15 @@ msgstr "New key"
msgid "New Key"
msgstr "New Key"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr "New menu item"
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9505,15 +9514,6 @@ msgstr "New Password"
msgid "New record"
msgstr "New record"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr "New sidebar item"
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nueva clave"
msgid "New Key"
msgstr "Nueva clave"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nueva contraseña"
msgid "New record"
msgstr "Nuevo registro"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Uusi avain"
msgid "New Key"
msgstr "Uusi avain"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Uusi salasana"
msgid "New record"
msgstr "Uusi tietue"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nouvelle clé"
msgid "New Key"
msgstr "Nouvelle clé"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nouveau mot de passe"
msgid "New record"
msgstr "Nouvel enregistrement"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "מפתח חדש"
msgid "New Key"
msgstr "מפתח חדש"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "סיסמה חדשה"
msgid "New record"
msgstr "רשומה חדשה"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Új kulcs"
msgid "New Key"
msgstr "Új Kulcs"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Új Jelszó"
msgid "New record"
msgstr "Új rekord"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nuova chiave"
msgid "New Key"
msgstr "Nuova chiave"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nuova password"
msgid "New record"
msgstr "Nuovo record"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "新しいキー"
msgid "New Key"
msgstr "新しいキー"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "新しいパスワード"
msgid "New record"
msgstr "新しいレコード"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "새 키"
msgid "New Key"
msgstr "새 키"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "새 비밀번호"
msgid "New record"
msgstr "새 레코드"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nieuwe sleutel"
msgid "New Key"
msgstr "Nieuwe Sleutel"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nieuw wachtwoord"
msgid "New record"
msgstr "Nieuw record"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Ny nøkkel"
msgid "New Key"
msgstr "Ny Nøkkel"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nytt Passord"
msgid "New record"
msgstr "Ny post"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nowy klucz"
msgid "New Key"
msgstr "Nowy Klucz"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nowe Hasło"
msgid "New record"
msgstr "Nowy rekord"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
@@ -9479,6 +9479,15 @@ msgstr ""
msgid "New Key"
msgstr ""
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9505,15 +9514,6 @@ msgstr ""
msgid "New record"
msgstr ""
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nova chave"
msgid "New Key"
msgstr "Nova Chave"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nova Senha"
msgid "New record"
msgstr "Novo registro"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Nova chave"
msgid "New Key"
msgstr "Nova Chave"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Nova Palavra-passe"
msgid "New record"
msgstr "Novo registo"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Cheie nouă"
msgid "New Key"
msgstr "Cheie nouă"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Parolă nouă"
msgid "New record"
msgstr "Înregistrare nouă"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
Binary file not shown.
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Нови кључ"
msgid "New Key"
msgstr "Нови кључ"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Нова лозинка"
msgid "New record"
msgstr "Нови запис"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9486,6 +9486,15 @@ msgstr "Ny nyckel"
msgid "New Key"
msgstr "Ny Nyckel"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9512,15 +9521,6 @@ msgstr "Nytt Lösenord"
msgid "New record"
msgstr "Ny post"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Yeni anahtar"
msgid "New Key"
msgstr "Yeni Anahtar"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Yeni Parola"
msgid "New record"
msgstr "Yeni kayıt"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Новий ключ"
msgid "New Key"
msgstr "Новий ключ"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Новий пароль"
msgid "New record"
msgstr "Новий запис"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "Khóa mới"
msgid "New Key"
msgstr "Khóa Mới"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "Mật khẩu Mới"
msgid "New record"
msgstr "Bản ghi mới"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "新密钥"
msgid "New Key"
msgstr "新密钥"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "新密码"
msgid "New record"
msgstr "新记录"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
+9 -9
View File
@@ -9484,6 +9484,15 @@ msgstr "新密鑰"
msgid "New Key"
msgstr "新密鑰"
#. js-lingui-id: K2wBR9
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New menu item"
msgstr ""
#. js-lingui-id: ROxeMs
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
#: src/pages/settings/admin-panel/SettingsAdminNewAiModel.tsx
@@ -9510,15 +9519,6 @@ msgstr "新密碼"
msgid "New record"
msgstr "新記錄"
#. js-lingui-id: x6Ckh1
#: src/modules/side-panel/hooks/useSidePanelMenu.ts
#: src/modules/navigation-menu-item/edit/side-panel/hooks/useNavigationMenuItemEditOrganizeActions.ts
#: src/modules/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage.ts
#: src/modules/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton.tsx
#: src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx
msgid "New sidebar item"
msgstr ""
#. js-lingui-id: gWnSyg
#: src/pages/settings/ai/SettingsSkillForm.tsx
#: src/pages/settings/ai/SettingsSkillForm.tsx
@@ -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 = ({
@@ -15,19 +15,18 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/common/constants/FolderIconDefault';
import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { preloadNavigationMenuItemDndKit } from '@/navigation-menu-item/display/dnd/preloadNavigationMenuItemDndKit';
import {
type NavigationMenuItemClickParams,
useNavigationMenuItemSectionItems,
} from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { WorkspaceSectionContainer } from '@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionContainer';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { preloadNavigationMenuItemDndKit } from '@/navigation-menu-item/display/dnd/preloadNavigationMenuItemDndKit';
import { WorkspaceSectionContainer } from '@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionContainer';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
@@ -35,6 +34,7 @@ import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types';
const StyledRightIconsContainer = styled.div`
align-items: center;
@@ -166,7 +166,7 @@ export const WorkspaceSection = () => {
event?.stopPropagation();
navigateSidePanel({
page: SidePanelPages.NavigationMenuAddItem,
pageTitle: t`New sidebar item`,
pageTitle: t`New menu item`,
pageIcon: IconColumnInsertRight,
resetNavigationStack: true,
});
@@ -32,7 +32,7 @@ export const WorkspaceSectionAddMenuItemButton = () => {
setSelectedNavigationMenuItemInEditMode(null);
navigateSidePanel({
page: SidePanelPages.NavigationMenuAddItem,
pageTitle: t`New sidebar item`,
pageTitle: t`New menu item`,
pageIcon: IconColumnInsertRight,
resetNavigationStack: true,
});
@@ -34,7 +34,7 @@ export const useOpenAddItemToFolderPage = () => {
});
navigateSidePanel({
page: SidePanelPages.NavigationMenuAddItem,
pageTitle: t`New sidebar item`,
pageTitle: t`New menu item`,
pageIcon: IconColumnInsertRight,
resetNavigationStack,
});
@@ -5,14 +5,14 @@ import { ensureAbsoluteUrl } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { extractDomainFromUrl } from '@/navigation-menu-item/display/link/utils/extractDomainFromUrl';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import {
type OrganizeActionsProps,
SidePanelEditOrganizeActions,
} from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditOrganizeActions';
import { SidePanelEditOwnerSection } from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditOwnerSection';
import { getOrganizeActionsSelectableItemIds } from '@/navigation-menu-item/edit/side-panel/utils/getOrganizeActionsSelectableItemIds';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { TextInput } from '@/ui/input/components/TextInput';
type SidePanelEditLinkItemViewProps = OrganizeActionsProps & {
@@ -93,7 +93,6 @@ export const SidePanelEditLinkItemView = ({
onAddAfter={onAddAfter}
showMoveToFolder
onMoveToFolder={onOpenFolderPicker}
moveToFolderHasSubMenu
/>
<SidePanelEditOwnerSection applicationId={selectedItem.applicationId} />
</SidePanelList>
@@ -26,7 +26,6 @@ export type OrganizeActionsProps = {
type SidePanelEditOrganizeActionsProps = OrganizeActionsProps & {
showMoveToFolder?: boolean;
onMoveToFolder?: () => void;
moveToFolderHasSubMenu?: boolean;
};
export const SidePanelEditOrganizeActions = ({
@@ -39,7 +38,6 @@ export const SidePanelEditOrganizeActions = ({
onAddAfter,
showMoveToFolder = false,
onMoveToFolder,
moveToFolderHasSubMenu = false,
}: SidePanelEditOrganizeActionsProps) => {
const { t } = useLingui();
@@ -78,7 +76,7 @@ export const SidePanelEditOrganizeActions = ({
Icon={IconFolderSymlink}
label={t`Move to folder`}
id={SidePanelNavigationItemActions.MOVE_TO_FOLDER}
hasSubMenu={moveToFolderHasSubMenu}
hasSubMenu
onClick={onMoveToFolder}
/>
</SelectableListItem>
@@ -2,15 +2,15 @@ import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconColumnInsertRight } from 'twenty-ui/display';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { type OrganizeActionsProps } from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditOrganizeActions';
import { useNavigationMenuItemMoveRemove } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemMoveRemove';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { addMenuItemInsertionContextState } from '@/navigation-menu-item/common/states/addMenuItemInsertionContextState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { type AddMenuItemInsertionContext } from '@/navigation-menu-item/common/types/AddMenuItemInsertionContext';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { useNavigationMenuItemMoveRemove } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemMoveRemove';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { type OrganizeActionsProps } from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditOrganizeActions';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { SidePanelPages } from 'twenty-shared/types';
@@ -119,7 +119,7 @@ export const useNavigationMenuItemEditOrganizeActions =
setAddMenuItemInsertionContext(context);
navigateSidePanel({
page: SidePanelPages.NavigationMenuAddItem,
pageTitle: t`New sidebar item`,
pageTitle: t`New menu item`,
pageIcon: IconColumnInsertRight,
resetNavigationStack: true,
});
@@ -15,7 +15,7 @@ const StyledTr = styled.div<{
div.table-cell,
div.table-cell-0-0 {
&:not(:first-of-type) {
background-color: ${themeCssVariables.background.tertiary};
background-color: ${themeCssVariables.accent.quaternary};
border-bottom: 1px solid ${themeCssVariables.border.color.medium};
border-color: ${themeCssVariables.border.color.medium};
}
@@ -1,7 +1,7 @@
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { isNonEmptyString } from '@sniptt/guards';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
type AssertFieldWidgetOrThrow = (
widget: PageLayoutWidget,
@@ -18,8 +18,7 @@ export const assertFieldWidgetOrThrow: AssertFieldWidgetOrThrow = (
);
if (
!isNonEmptyString(widget.configuration.__typename) ||
widget.configuration.__typename !== 'FieldConfiguration'
widget.configuration.configurationType !== WidgetConfigurationType.FIELD
) {
throw new Error(
`Expected FieldConfiguration but got ${widget.configuration.__typename}`,
@@ -1,11 +1,11 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/common/states/addToNavPayloadRegistryState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/common/states/addToNavPayloadRegistryState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown';
import { emitSidePanelOpenEvent } from '@/ui/layout/side-panel/utils/emitSidePanelOpenEvent';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
@@ -60,7 +60,7 @@ export const useSidePanelMenu = () => {
} else if (isLayoutCustomizationModeEnabled) {
navigateSidePanel({
page: SidePanelPages.NavigationMenuAddItem,
pageTitle: t`New sidebar item`,
pageTitle: t`New menu item`,
pageIcon: IconColumnInsertRight,
resetNavigationStack: true,
});
@@ -12,6 +12,7 @@ import { styled } from '@linaria/react';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { TabButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { TabListDropdown } from './TabListDropdown';
import { TabListFromUrlOptionalEffect } from './TabListFromUrlOptionalEffect';
@@ -22,6 +23,16 @@ const StyledContainer = styled.div`
position: relative;
user-select: none;
width: 100%;
&::after {
background-color: ${themeCssVariables.border.color.light};
bottom: 0;
content: '';
height: 1px;
left: 0;
position: absolute;
right: 0;
}
`;
const StyledDropdownContainer = styled.div`
@@ -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 = {
@@ -1,25 +1,40 @@
import { InjectRepository } from '@nestjs/typeorm';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { DataSource, type QueryRunner, Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import {
WorkflowVersionStatus,
type WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import {
type WorkflowManualTrigger,
WorkflowTriggerType,
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
@Command({
name: 'upgrade:1-20:backfill-command-menu-items',
description:
'Backfill missing standard command menu items for existing workspaces and enable IS_COMMAND_MENU_ITEM_ENABLED feature flag',
'Backfill missing standard and workflow command menu items for existing workspaces and enable IS_COMMAND_MENU_ITEM_ENABLED feature flag',
})
export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@@ -27,10 +42,13 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
@@ -42,7 +60,7 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of missing standard command menu items for workspace ${workspaceId}`,
`${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of command menu items for workspace ${workspaceId}`,
);
const isFeatureFlagAlreadyEnabled =
@@ -59,6 +77,52 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await this.backfillStandardCommandMenuItems(
workspaceId,
isDryRun,
queryRunner,
);
await this.backfillWorkflowCommandMenuItems(
workspaceId,
isDryRun,
queryRunner,
);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Rolling back backfill of command menu items for workspace ${workspaceId}: ${error.message}`,
);
throw error;
} finally {
await queryRunner.release();
}
if (!isDryRun) {
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED],
workspaceId,
);
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed backfill of command menu items for workspace ${workspaceId}`,
);
}
private async backfillStandardCommandMenuItems(
workspaceId: string,
isDryRun: boolean,
queryRunner: QueryRunner,
): Promise<void> {
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -90,30 +154,21 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
),
);
const numberOfCommandMenuItemsToCreate = commandMenuItemsToCreate.length;
if (numberOfCommandMenuItemsToCreate === 0) {
if (commandMenuItemsToCreate.length === 0) {
this.logger.log(
`No missing standard command menu items for workspace ${workspaceId}, enabling feature flag only`,
`No missing standard command menu items for workspace ${workspaceId}`,
);
if (!isDryRun) {
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED],
workspaceId,
);
}
return;
}
this.logger.log(
`Found ${numberOfCommandMenuItemsToCreate} missing standard command menu item(s) for workspace ${workspaceId}`,
`Found ${commandMenuItemsToCreate.length} missing standard command menu item(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create ${numberOfCommandMenuItemsToCreate} command menu item(s) and enable IS_COMMAND_MENU_ITEM_ENABLED for workspace ${workspaceId}`,
`[DRY RUN] Would create ${commandMenuItemsToCreate.length} standard command menu item(s) for workspace ${workspaceId}`,
);
return;
@@ -132,6 +187,7 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
queryRunner,
},
);
@@ -145,13 +201,206 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
);
}
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED],
workspaceId,
);
this.logger.log(
`Successfully backfilled ${commandMenuItemsToCreate.length} standard command menu item(s) and enabled IS_COMMAND_MENU_ITEM_ENABLED for workspace ${workspaceId}`,
`Successfully backfilled ${commandMenuItemsToCreate.length} standard command menu item(s) for workspace ${workspaceId}`,
);
}
private async backfillWorkflowCommandMenuItems(
workspaceId: string,
isDryRun: boolean,
queryRunner: QueryRunner,
): Promise<void> {
const authContext = buildSystemAuthContext(workspaceId);
await this.twentyORMGlobalManager.executeInWorkspaceContext(async () => {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepository<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const activeWorkflowVersions = await workflowVersionRepository.find({
where: { status: WorkflowVersionStatus.ACTIVE },
});
const manualTriggerVersions = activeWorkflowVersions.filter(
(version) =>
isDefined(version.trigger) &&
version.trigger.type === WorkflowTriggerType.MANUAL,
);
if (manualTriggerVersions.length === 0) {
this.logger.log(
`No active workflow versions with manual triggers for workspace ${workspaceId}`,
);
return;
}
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
]);
const existingWorkflowVersionIds = new Set(
Object.values(existingFlatCommandMenuItemMaps.byUniversalIdentifier)
.filter(isDefined)
.map((item) => item.workflowVersionId)
.filter(isDefined),
);
const workflowRepository =
await this.twentyORMGlobalManager.getRepository<WorkflowWorkspaceEntity>(
workspaceId,
'workflow',
{ shouldBypassPermissionChecks: true },
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const flatCommandMenuItemsToCreate: FlatCommandMenuItem[] = [];
for (const workflowVersion of manualTriggerVersions) {
if (existingWorkflowVersionIds.has(workflowVersion.id)) {
continue;
}
const workflow = await workflowRepository.findOne({
where: { id: workflowVersion.workflowId },
});
const label =
workflow && isNonEmptyString(workflow.name)
? workflow.name
: 'Manual Trigger';
const trigger = workflowVersion.trigger as WorkflowManualTrigger;
const { availabilityType, availabilityObjectMetadataId } =
await this.resolveManualTriggerAvailability(trigger, workspaceId);
const id = uuidv4();
const now = new Date().toISOString();
flatCommandMenuItemsToCreate.push({
id,
universalIdentifier: id,
workflowVersionId: workflowVersion.id,
frontComponentId: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: null,
label,
shortLabel: label,
icon: trigger.settings.icon ?? null,
isPinned: trigger.settings.isPinned ?? false,
position: 0,
hotKeys: null,
availabilityType,
availabilityObjectMetadataId: availabilityObjectMetadataId ?? null,
availabilityObjectMetadataUniversalIdentifier: null,
conditionalAvailabilityExpression: null,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
createdAt: now,
updatedAt: now,
});
}
if (flatCommandMenuItemsToCreate.length === 0) {
this.logger.log(
`No missing workflow command menu items for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${flatCommandMenuItemsToCreate.length} missing workflow command menu item(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create ${flatCommandMenuItemsToCreate.length} workflow command menu item(s) for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: flatCommandMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
queryRunner,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to backfill workflow command menu items:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to backfill workflow command menu items for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully backfilled ${flatCommandMenuItemsToCreate.length} workflow command menu item(s) for workspace ${workspaceId}`,
);
}, authContext);
}
private async resolveManualTriggerAvailability(
trigger: WorkflowManualTrigger,
workspaceId: string,
): Promise<{
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId: string | undefined;
}> {
const availability = trigger.settings.availability;
if (!isDefined(availability) || availability.type === 'GLOBAL') {
return {
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: undefined,
};
}
const { objectIdByNameSingular } =
await this.workflowCommonWorkspaceService.getFlatEntityMaps(workspaceId);
const objectId = objectIdByNameSingular[availability.objectNameSingular];
if (!isDefined(objectId)) {
this.logger.warn(
`Object metadata not found for "${availability.objectNameSingular}" in workspace ${workspaceId}, falling back to GLOBAL`,
);
return {
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: undefined,
};
}
return {
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: objectId,
};
}
}
@@ -1,24 +1,84 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import {
CoreObjectNameSingular,
FieldMetadataType,
PageLayoutTabLayoutMode,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { FieldDisplayMode } from 'src/engine/metadata-modules/page-layout-widget/enums/field-display-mode.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import {
GRID_POSITIONS,
VERTICAL_LIST_LAYOUT_POSITIONS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const HOME_TAB_POSITION = 10;
const isActivityTargetField = (
fieldName: string,
objectNameSingular: string,
): boolean =>
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldName === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldName === 'taskTargets');
const isJunctionRelationField = (field: FlatFieldMetadata): boolean => {
const settings = field.settings as
| { junctionTargetFieldId?: string }
| undefined;
return (
isDefined(settings?.junctionTargetFieldId) &&
typeof settings?.junctionTargetFieldId === 'string' &&
settings.junctionTargetFieldId.length > 0
);
};
const isRelationTargetAvailable = (
targetObject: FlatObjectMetadata | undefined,
): boolean => {
if (!isDefined(targetObject)) {
return false;
}
if (targetObject.isRemote) {
return false;
}
if (
targetObject.isSystem &&
targetObject.nameSingular !== CoreObjectNameSingular.WorkspaceMember
) {
return false;
}
return true;
};
@Command({
name: 'upgrade:1-20:backfill-field-widgets',
description:
'Backfill FIELD widgets for standard object relation fields in existing page layouts',
'Backfill FIELD widgets for relation fields in existing page layouts',
})
export class BackfillFieldWidgetsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@@ -43,50 +103,254 @@ export class BackfillFieldWidgetsCommand extends ActiveOrSuspendedWorkspacesMigr
`${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of FIELD widgets for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
const { twentyStandardFlatApplication, workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
]);
const standardFieldWidgets = Object.values(
standardAllFlatEntityMaps.flatPageLayoutWidgetMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(widget) =>
widget.configuration?.configurationType ===
WidgetConfigurationType.FIELD,
);
// Build a set of fieldMetadataIds that already have a FIELD widget
const existingFieldWidgetFieldIds = new Set<string>();
if (standardFieldWidgets.length === 0) {
this.logger.log(
`No FIELD widgets found in standard configs for workspace ${workspaceId}`,
);
return;
for (const widget of Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
)) {
if (
isDefined(widget) &&
widget.configuration?.configurationType ===
WidgetConfigurationType.FIELD &&
isDefined(widget.configuration.fieldMetadataId)
) {
existingFieldWidgetFieldIds.add(widget.configuration.fieldMetadataId);
}
}
const { flatPageLayoutWidgetMaps: existingWidgetMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatPageLayoutWidgetMaps',
]);
// Build object ID → objectMetadata map
const objectById = new Map<string, FlatObjectMetadata>();
const widgetsToCreate = standardFieldWidgets.filter(
(widget) =>
!isDefined(
existingWidgetMaps.byUniversalIdentifier[widget.universalIdentifier],
),
);
for (const obj of Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)) {
if (isDefined(obj)) {
objectById.set(obj.id, obj);
}
}
if (widgetsToCreate.length === 0) {
// Build object ID → RECORD_PAGE layout map
const recordPageLayoutByObjectId = new Map<
string,
{ id: string; universalIdentifier: string }
>();
for (const layout of Object.values(
flatPageLayoutMaps.byUniversalIdentifier,
)) {
if (
isDefined(layout) &&
layout.type === PageLayoutType.RECORD_PAGE &&
isDefined(layout.objectMetadataId)
) {
recordPageLayoutByObjectId.set(layout.objectMetadataId, {
id: layout.id,
universalIdentifier: layout.universalIdentifier,
});
}
}
// Build pageLayoutId → home tab map
const homeTabByPageLayoutId = new Map<
string,
{
id: string;
universalIdentifier: string;
widgetCount: number;
}
>();
for (const tab of Object.values(
flatPageLayoutTabMaps.byUniversalIdentifier,
)) {
if (
isDefined(tab) &&
tab.position === HOME_TAB_POSITION &&
tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST
) {
homeTabByPageLayoutId.set(tab.pageLayoutId, {
id: tab.id,
universalIdentifier: tab.universalIdentifier,
widgetCount: tab.widgetIds?.length ?? 0,
});
}
}
// Group fields by objectMetadataId
const fieldsByObjectId = new Map<string, FlatFieldMetadata[]>();
// Map morphId → all field IDs sharing that morphId (for dedup)
const fieldIdsByMorphId = new Map<string, string[]>();
for (const field of Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)) {
if (!isDefined(field) || !isDefined(field.objectMetadataId)) {
continue;
}
const list = fieldsByObjectId.get(field.objectMetadataId) ?? [];
list.push(field);
fieldsByObjectId.set(field.objectMetadataId, list);
if (
field.type === FieldMetadataType.MORPH_RELATION &&
isDefined(field.morphId)
) {
const morphFieldIds = fieldIdsByMorphId.get(field.morphId) ?? [];
morphFieldIds.push(field.id);
fieldIdsByMorphId.set(field.morphId, morphFieldIds);
}
}
const now = new Date().toISOString();
const standardWidgetsToCreate: FlatPageLayoutWidget[] = [];
const customWidgetsToCreate: FlatPageLayoutWidget[] = [];
const processedMorphIds = new Set<string>();
for (const object of objectById.values()) {
const pageLayout = recordPageLayoutByObjectId.get(object.id);
if (!isDefined(pageLayout)) {
continue;
}
const homeTab = homeTabByPageLayoutId.get(pageLayout.id);
if (!isDefined(homeTab)) {
continue;
}
const flatApplication: FlatApplication = object.isCustom
? workspaceCustomFlatApplication
: twentyStandardFlatApplication;
const targetList: FlatPageLayoutWidget[] = object.isCustom
? customWidgetsToCreate
: standardWidgetsToCreate;
const fields = fieldsByObjectId.get(object.id) ?? [];
let nextWidgetIndex = homeTab.widgetCount;
for (const field of fields) {
if (
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION
) {
continue;
}
if (isActivityTargetField(field.name, object.nameSingular)) {
continue;
}
if (isJunctionRelationField(field)) {
continue;
}
if (field.type === FieldMetadataType.RELATION) {
const targetObject = isDefined(field.relationTargetObjectMetadataId)
? objectById.get(field.relationTargetObjectMetadataId)
: undefined;
if (!isRelationTargetAvailable(targetObject)) {
continue;
}
}
// For morph relations, skip if any sibling field (same morphId)
// already has a widget or was already processed in this run
if (
field.type === FieldMetadataType.MORPH_RELATION &&
isDefined(field.morphId)
) {
if (processedMorphIds.has(field.morphId)) {
continue;
}
const siblingFieldIds = fieldIdsByMorphId.get(field.morphId) ?? [];
const alreadyHasWidget = siblingFieldIds.some((id) =>
existingFieldWidgetFieldIds.has(id),
);
if (alreadyHasWidget) {
continue;
}
processedMorphIds.add(field.morphId);
}
if (existingFieldWidgetFieldIds.has(field.id)) {
continue;
}
const widget: FlatPageLayoutWidget = {
id: v4(),
universalIdentifier: v4(),
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
workspaceId,
pageLayoutTabId: homeTab.id,
pageLayoutTabUniversalIdentifier: homeTab.universalIdentifier,
title: field.label,
type: WidgetType.FIELD,
gridPosition: { ...GRID_POSITIONS.FULL_WIDTH },
position: {
...VERTICAL_LIST_LAYOUT_POSITIONS.SECOND,
index: nextWidgetIndex,
},
configuration: {
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: field.id,
fieldDisplayMode: FieldDisplayMode.CARD,
},
universalConfiguration: {
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: field.universalIdentifier,
fieldDisplayMode: FieldDisplayMode.CARD,
},
objectMetadataId: object.id,
objectMetadataUniversalIdentifier: object.universalIdentifier,
createdAt: now,
updatedAt: now,
deletedAt: null,
conditionalDisplay: null,
overrides: null,
};
targetList.push(widget);
existingFieldWidgetFieldIds.add(field.id);
nextWidgetIndex++;
}
}
const totalWidgets =
standardWidgetsToCreate.length + customWidgetsToCreate.length;
if (totalWidgets === 0) {
this.logger.log(
`All FIELD widgets already exist for workspace ${workspaceId}, skipping`,
);
@@ -95,44 +359,73 @@ export class BackfillFieldWidgetsCommand extends ActiveOrSuspendedWorkspacesMigr
}
this.logger.log(
`Found ${widgetsToCreate.length} FIELD widget(s) to create for workspace ${workspaceId}`,
`Found ${totalWidgets} FIELD widget(s) to create for workspace ${workspaceId} (${standardWidgetsToCreate.length} standard, ${customWidgetsToCreate.length} custom)`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create ${widgetsToCreate.length} FIELD widget(s) for workspace ${workspaceId}`,
`[DRY RUN] Would create ${totalWidgets} FIELD widget(s) for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: {
flatEntityToCreate: widgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
if (standardWidgetsToCreate.length > 0) {
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: {
flatEntityToCreate: standardWidgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to create FIELD widgets:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to create FIELD widgets for workspace ${workspaceId}`,
);
if (result.status === 'fail') {
this.logger.error(
`Failed to create standard FIELD widgets:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to create standard FIELD widgets for workspace ${workspaceId}`,
);
}
}
if (customWidgetsToCreate.length > 0) {
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: {
flatEntityToCreate: customWidgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to create custom FIELD widgets:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to create custom FIELD widgets for workspace ${workspaceId}`,
);
}
}
this.logger.log(
`Successfully created ${widgetsToCreate.length} FIELD widget(s) for workspace ${workspaceId}`,
`Successfully created ${totalWidgets} FIELD widget(s) for workspace ${workspaceId}`,
);
}
}
@@ -31,6 +31,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
@Module({
imports: [
@@ -52,6 +53,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ApplicationRegistrationModule,
WorkspaceMigrationModule,
FeatureFlagModule,
WorkflowCommonModule,
],
providers: [
IdentifyPermissionFlagMetadataCommand,
@@ -178,6 +178,8 @@ export class ApplicationInstallService {
);
}
// TODO: mimeType should be defined, default to application/octet-stream, which won't be displayed
// inline by the browser (forced download) due to Content-Disposition security headers.
await this.fileStorageService.writeFile({
sourceFile: content,
mimeType: undefined,
@@ -32,6 +32,10 @@ const createMockStream = (): Readable => {
return stream;
};
const createMockResponse = () => ({
setHeader: jest.fn(),
});
describe('FileController', () => {
let controller: FileController;
let fileService: FileService;
@@ -75,15 +79,16 @@ describe('FileController', () => {
});
describe('getFileById', () => {
it('should call fileService.getFileStreamById and pipe the result', async () => {
it('should call fileService.getFileStreamById and pipe the result with headers', async () => {
const mockStream = createMockStream();
jest
.spyOn(fileService, 'getFileStreamById')
.mockResolvedValue(mockStream);
jest.spyOn(fileService, 'getFileStreamById').mockResolvedValue({
stream: mockStream,
mimeType: 'image/png',
});
const mockRequest = { workspaceId: 'workspace-id' } as any;
const mockResponse = {} as any;
const mockResponse = createMockResponse() as any;
await controller.getFileById(
mockResponse,
@@ -97,9 +102,49 @@ describe('FileController', () => {
workspaceId: 'workspace-id',
fileFolder: FileFolder.CorePicture,
});
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'image/png',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'X-Content-Type-Options',
'nosniff',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'inline',
);
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
});
it('should force attachment disposition for non-safe MIME types', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStreamById').mockResolvedValue({
stream: mockStream,
mimeType: 'text/html',
});
const mockRequest = { workspaceId: 'workspace-id' } as any;
const mockResponse = createMockResponse() as any;
await controller.getFileById(
mockResponse,
mockRequest,
FileFolder.Workflow,
'file-123',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/html',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'attachment',
);
});
it('should throw FileException with FILE_NOT_FOUND when file is not found', async () => {
jest
.spyOn(fileService, 'getFileStreamById')
@@ -111,7 +156,7 @@ describe('FileController', () => {
);
const mockRequest = { workspaceId: 'workspace-id' } as any;
const mockResponse = {} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getFileById(
@@ -131,7 +176,7 @@ describe('FileController', () => {
.mockRejectedValue(new Error('Storage unavailable'));
const mockRequest = { workspaceId: 'workspace-id' } as any;
const mockResponse = {} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getFileById(
@@ -150,18 +195,19 @@ describe('FileController', () => {
});
describe('getPublicAssets', () => {
it('should call fileService.getFileStreamByPath and pipe the result', async () => {
it('should call fileService.getFileStreamByPath and pipe with headers', async () => {
const mockStream = createMockStream();
jest
.spyOn(fileService, 'getFileStreamByPath')
.mockResolvedValue(mockStream);
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'image/png',
});
const mockRequest = {
params: { path: ['images', 'logo.png'] },
} as any;
const mockResponse = {} as any;
const mockResponse = createMockResponse() as any;
await controller.getPublicAssets(
mockResponse,
@@ -176,21 +222,34 @@ describe('FileController', () => {
fileFolder: FileFolder.PublicAsset,
filepath: 'images/logo.png',
});
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'image/png',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'X-Content-Type-Options',
'nosniff',
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'inline',
);
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
});
it('should handle single-segment path', async () => {
const mockStream = createMockStream();
jest
.spyOn(fileService, 'getFileStreamByPath')
.mockResolvedValue(mockStream);
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
stream: mockStream,
mimeType: 'image/x-icon',
});
const mockRequest = {
params: { path: ['favicon.ico'] },
} as any;
const mockResponse = {} as any;
const mockResponse = createMockResponse() as any;
await controller.getPublicAssets(
mockResponse,
@@ -221,7 +280,7 @@ describe('FileController', () => {
params: { path: ['missing-asset.png'] },
} as any;
const mockResponse = {} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getPublicAssets(
@@ -244,7 +303,7 @@ describe('FileController', () => {
params: { path: ['broken-asset.png'] },
} as any;
const mockResponse = {} as any;
const mockResponse = createMockResponse() as any;
await expect(
controller.getPublicAssets(
@@ -28,6 +28,7 @@ import {
SupportedFileFolder,
} from 'src/engine/core-modules/file/guards/file-by-id.guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { setFileResponseHeaders } from 'src/engine/core-modules/file/utils/set-file-response-headers.utils';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@@ -48,21 +49,23 @@ export class FileController {
const filepath = join(...req.params.path);
try {
const fileStream = await this.fileService.getFileStreamByPath({
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
workspaceId,
applicationId,
fileFolder: FileFolder.PublicAsset,
filepath,
});
fileStream.on('error', () => {
setFileResponseHeaders(res, mimeType);
stream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});
fileStream.pipe(res);
stream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
@@ -93,20 +96,22 @@ export class FileController {
const workspaceId = (req as any)?.workspaceId;
try {
const fileStream = await this.fileService.getFileStreamById({
const { stream, mimeType } = await this.fileService.getFileStreamById({
fileId,
workspaceId,
fileFolder,
});
fileStream.on('error', () => {
setFileResponseHeaders(res, mimeType);
stream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});
fileStream.pipe(res);
stream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
@@ -45,7 +45,15 @@ export class FileService {
applicationId: string;
filepath: string;
fileFolder: FileFolder;
}) {
}): Promise<{ stream: Readable; mimeType: string }> {
const file = await this.fileRepository.findOneOrFail({
where: {
path: `${fileFolder}/${filepath}`,
workspaceId,
applicationId,
},
});
const application = await this.applicationRepository.findOneOrFail({
where: {
id: applicationId,
@@ -53,12 +61,17 @@ export class FileService {
},
});
return this.fileStorageService.readFile({
const stream = await this.fileStorageService.readFile({
resourcePath: filepath,
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
});
return {
stream,
mimeType: file.mimeType,
};
}
async getFileStreamById({
@@ -69,7 +82,7 @@ export class FileService {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): Promise<Readable> {
}): Promise<{ stream: Readable; mimeType: string }> {
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
@@ -85,12 +98,17 @@ export class FileService {
},
});
return this.fileStorageService.readFile({
const stream = await this.fileStorageService.readFile({
resourcePath: removeFileFolderFromFileEntityPath(file.path),
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
});
return {
stream,
mimeType: file.mimeType,
};
}
async getFileContentById({
@@ -0,0 +1,31 @@
import { type Response } from 'express';
const INLINE_SAFE_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/tiff',
'application/pdf',
'text/plain',
'audio/mpeg',
'audio/wav',
'audio/ogg',
'video/mp4',
'video/webm',
'video/ogg',
'image/x-icon',
]);
export const setFileResponseHeaders = (res: Response, mimeType: string) => {
const contentType = mimeType || 'application/octet-stream';
const disposition = INLINE_SAFE_MIME_TYPES.has(contentType)
? 'inline'
: 'attachment';
res.setHeader('Content-Type', contentType);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', disposition);
};
@@ -191,7 +191,7 @@ export class EmailComposerService {
const attachments: MessageAttachment[] = [];
for (const fileMetadata of files) {
const stream = await this.fileService.getFileStreamById({
const { stream } = await this.fileService.getFileStreamById({
fileId: fileMetadata.id,
workspaceId,
fileFolder: FileFolder.Workflow,
@@ -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}[]`;
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard page layout metadata related entity ids 1`] = `
{
@@ -340,21 +340,24 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
},
},
"workflowRunRecordPage": {
"id": "00000000-0000-0000-0000-000000000093",
"id": "00000000-0000-0000-0000-000000000094",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000096",
"id": "00000000-0000-0000-0000-000000000098",
"widgets": {
"workflowRun": {
"id": "00000000-0000-0000-0000-000000000097",
"id": "00000000-0000-0000-0000-000000000099",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000094",
"id": "00000000-0000-0000-0000-000000000095",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000095",
"id": "00000000-0000-0000-0000-000000000096",
},
"workflow": {
"id": "00000000-0000-0000-0000-000000000097",
},
},
},
@@ -364,10 +367,10 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
"id": "00000000-0000-0000-0000-000000000088",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000091",
"id": "00000000-0000-0000-0000-000000000092",
"widgets": {
"workflowVersion": {
"id": "00000000-0000-0000-0000-000000000092",
"id": "00000000-0000-0000-0000-000000000093",
},
},
},
@@ -377,6 +380,9 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
"fields": {
"id": "00000000-0000-0000-0000-000000000090",
},
"workflow": {
"id": "00000000-0000-0000-0000-000000000091",
},
},
},
},
@@ -1,8 +1,11 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
GRID_POSITIONS,
TAB_PROPS,
VERTICAL_LIST_LAYOUT_POSITIONS,
WIDGET_PROPS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import {
@@ -19,6 +22,15 @@ const WORKFLOW_RUN_PAGE_TABS = {
universalIdentifier: '20202020-ac08-4008-8008-a0bcf10a8811',
...WIDGET_PROPS.fields,
},
workflow: {
universalIdentifier: '20202020-ac08-4008-8008-a0bcf10a8813',
title: 'Workflow',
type: WidgetType.FIELD,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: VERTICAL_LIST_LAYOUT_POSITIONS.THIRD,
fieldUniversalIdentifier:
STANDARD_OBJECTS.workflowRun.fields.workflow.universalIdentifier,
},
},
},
flow: {
@@ -1,8 +1,11 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
GRID_POSITIONS,
TAB_PROPS,
VERTICAL_LIST_LAYOUT_POSITIONS,
WIDGET_PROPS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import {
@@ -19,6 +22,15 @@ const WORKFLOW_VERSION_PAGE_TABS = {
universalIdentifier: '20202020-ac07-4007-8007-a0bcf10a7711',
...WIDGET_PROPS.fields,
},
workflow: {
universalIdentifier: '20202020-ac07-4007-8007-a0bcf10a7712',
title: 'Workflow',
type: WidgetType.FIELD,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: VERTICAL_LIST_LAYOUT_POSITIONS.SECOND,
fieldUniversalIdentifier:
STANDARD_OBJECTS.workflowVersion.fields.workflow.universalIdentifier,
},
},
},
flow: {
@@ -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',
]);
});
});