App feedbacks fix option id required in apps (#19386)

fixes
https://discord.com/channels/1130383047699738754/1488226371032453292
This commit is contained in:
martmull
2026-04-07 15:53:16 +02:00
committed by GitHub
parent 6e23ca35e6
commit 8702300b07
36 changed files with 333 additions and 5062 deletions
@@ -51,28 +51,28 @@ export default defineObject({
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{
id: 'a1b2c3d4-0001-4000-8000-000000000001',
id: '8ab3abad-02e7-4670-9283-983d7fac7fe4',
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: 'a1b2c3d4-0002-4000-8000-000000000002',
id: '2bcdd195-6c99-4d69-84b2-e2838ee54467',
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: 'a1b2c3d4-0003-4000-8000-000000000003',
id: '918ff60c-c26e-4fae-8eba-3fbce04dc48b',
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: 'a1b2c3d4-0004-4000-8000-000000000004',
id: '3c91a653-5d31-4023-be6c-a69c68d21233',
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
@@ -10,25 +10,31 @@ export default defineField({
description: 'Post card category',
options: [
{
id: 'c1d2e3f4-0001-4000-8000-000000000001',
id: 'cd751c81-787d-4581-bc51-efe43f0050a7',
value: 'PERSONAL',
label: 'Personal',
color: 'blue',
position: 0,
},
{
id: 'c1d2e3f4-0002-4000-8000-000000000002',
id: 'eec437ca-5beb-41a9-a826-c9a5eca2eef4',
value: 'BUSINESS',
label: 'Business',
color: 'green',
position: 1,
},
{
id: 'c1d2e3f4-0003-4000-8000-000000000003',
id: 'a5baa37d-1047-4972-b6b8-7faae0e3eac1',
value: 'PROMOTIONAL',
label: 'Promotional',
color: 'orange',
position: 2,
},
{
value: 'OTHER',
label: 'Other',
color: 'gray',
position: 3,
},
],
});
@@ -5,6 +5,7 @@ enum PostCardStatus {
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
LOST = 'LOST',
}
export const POST_CARD_UNIVERSAL_IDENTIFIER =
@@ -53,33 +54,40 @@ export default defineObject({
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{
id: 'a1b2c3d4-0001-4000-8000-000000000001',
id: '1b008e19-1e59-4a07-b187-65a20e547c4e',
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: 'a1b2c3d4-0002-4000-8000-000000000002',
id: '452b9d40-889c-4342-9697-98319394db04',
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: 'a1b2c3d4-0003-4000-8000-000000000003',
id: 'c2ed0b8c-a3ed-4383-aef9-e0441267bcfe',
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: 'a1b2c3d4-0004-4000-8000-000000000004',
id: 'c57a5e08-7ef7-49b8-87e6-32d720d22802',
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
{
// No id — exercises addMissingFieldOptionIds in the object branch
value: PostCardStatus.LOST,
label: 'Lost',
position: 4,
color: 'red',
},
],
name: 'status',
},
@@ -1,42 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
env:
TWENTY_VERSION: latest
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
@@ -1,38 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts
@@ -1 +0,0 @@
24.5.0
@@ -1,19 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,14 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -1,14 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -1,14 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -1,11 +0,0 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
Run `yarn twenty help` to list all available commands.
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -1,35 +0,0 @@
{
"name": "my-twenty-app",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "0.8.0-canary.8",
"twenty-sdk": "0.8.0-canary.8"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
@@ -1,70 +0,0 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(
`Build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
});
afterAll(async () => {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
});
it('should find the installed app in the applications list', async () => {
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
@@ -1,45 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
);
});
@@ -1,15 +0,0 @@
import { defineApplication } from 'twenty-sdk';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -1,4 +0,0 @@
export const APP_DISPLAY_NAME = 'My twenty app';
export const APP_DESCRIPTION = '';
export const APPLICATION_UNIVERSAL_IDENTIFIER = '83a4b244-c80d-4923-b0d5-ae2406e83072';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'a1a28134-04d1-43ac-b8cc-b547e8cf97ba';
@@ -1,16 +0,0 @@
import { defineRole } from 'twenty-sdk';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,42 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
@@ -1,21 +0,0 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
},
});
File diff suppressed because it is too large Load Diff
@@ -1729,7 +1729,6 @@ enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED
IS_AI_ENABLED
IS_COMMAND_MENU_ITEM_ENABLED
IS_MARKETPLACE_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED
IS_PUBLIC_DOMAIN_ENABLED
@@ -1429,7 +1429,7 @@ export interface PublicFeatureFlag {
__typename: 'PublicFeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export interface ClientConfigMaintenanceMode {
startAt: Scalars['DateTime']
@@ -9140,7 +9140,6 @@ export const enumFeatureFlagKey = {
IS_JSON_FILTER_ENABLED: 'IS_JSON_FILTER_ENABLED' as const,
IS_AI_ENABLED: 'IS_AI_ENABLED' as const,
IS_COMMAND_MENU_ITEM_ENABLED: 'IS_COMMAND_MENU_ITEM_ENABLED' as const,
IS_MARKETPLACE_ENABLED: 'IS_MARKETPLACE_ENABLED' as const,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' as const,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' as const,
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
@@ -1736,7 +1736,6 @@ export enum FeatureFlagKey {
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
@@ -29,10 +29,6 @@ export const SettingsApplications = () => {
PermissionFlagType.API_KEYS_AND_WEBHOOKS,
);
const isMarketplaceEnabled = useIsFeatureEnabled(
'IS_MARKETPLACE_ENABLED' as FeatureFlagKey,
);
const isMarketplaceSettingTabVisible = useIsFeatureEnabled(
FeatureFlagKey.IS_MARKETPLACE_SETTING_TAB_VISIBLE,
);
@@ -42,32 +38,6 @@ export const SettingsApplications = () => {
APPLICATIONS_TAB_LIST_ID,
);
const { data } = useQuery(FindManyApplicationsDocument);
const applications = data?.findManyApplications ?? [];
if (!isMarketplaceEnabled) {
return (
<SubMenuTopBarContainer
title={t`Applications`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`Applications` },
]}
>
<SettingsPageContainer>
{applications.length > 0 && (
<SettingsApplicationsTable applications={applications} />
)}
{hasDeveloperAccess && <SettingsApplicationsDeveloperTab />}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
}
const tabs = [
...(isMarketplaceSettingTabVisible
? [{ id: 'marketplace', title: t`Marketplace`, Icon: IconDownload }]
@@ -2,6 +2,7 @@ import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-de
import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { defineEntitiesTests } from './tests/entities.tests';
import { defineFieldOptionIdsTests } from './tests/field-option-ids.tests';
import { defineManifestTests } from './tests/manifest.tests';
describe('rich-app dev', () => {
@@ -23,4 +24,5 @@ describe('rich-app dev', () => {
defineManifestTests(RICH_APP_PATH);
defineEntitiesTests(RICH_APP_PATH);
defineFieldOptionIdsTests(RICH_APP_PATH);
});
@@ -251,25 +251,32 @@ export const EXPECTED_MANIFEST: Manifest = {
options: [
{
color: 'blue',
id: 'c1d2e3f4-0001-4000-8000-000000000001',
id: 'cd751c81-787d-4581-bc51-efe43f0050a7',
label: 'Personal',
position: 0,
value: 'PERSONAL',
},
{
color: 'green',
id: 'c1d2e3f4-0002-4000-8000-000000000002',
id: 'eec437ca-5beb-41a9-a826-c9a5eca2eef4',
label: 'Business',
position: 1,
value: 'BUSINESS',
},
{
color: 'orange',
id: 'c1d2e3f4-0003-4000-8000-000000000003',
id: 'a5baa37d-1047-4972-b6b8-7faae0e3eac1',
label: 'Promotional',
position: 2,
value: 'PROMOTIONAL',
},
{
color: 'gray',
id: '877336e4-6591-599f-8cd1-4c7dfae623d7',
label: 'Other',
position: 3,
value: 'OTHER',
},
],
type: FieldType.SELECT,
universalIdentifier: 'b602dbd9-e511-49ce-b6d3-b697218dc69c',
@@ -1043,32 +1050,39 @@ export const EXPECTED_MANIFEST: Manifest = {
options: [
{
color: 'gray',
id: 'a1b2c3d4-0001-4000-8000-000000000001',
id: '1b008e19-1e59-4a07-b187-65a20e547c4e',
label: 'Draft',
position: 0,
value: 'DRAFT',
},
{
color: 'orange',
id: 'a1b2c3d4-0002-4000-8000-000000000002',
id: '452b9d40-889c-4342-9697-98319394db04',
label: 'Sent',
position: 1,
value: 'SENT',
},
{
color: 'green',
id: 'a1b2c3d4-0003-4000-8000-000000000003',
id: 'c2ed0b8c-a3ed-4383-aef9-e0441267bcfe',
label: 'Delivered',
position: 2,
value: 'DELIVERED',
},
{
color: 'orange',
id: 'a1b2c3d4-0004-4000-8000-000000000004',
id: 'c57a5e08-7ef7-49b8-87e6-32d720d22802',
label: 'Returned',
position: 3,
value: 'RETURNED',
},
{
color: 'red',
id: '5248e3c4-23f8-512d-9bea-5c2df85bf83c',
label: 'Lost',
position: 4,
value: 'LOST',
},
],
type: FieldType.SELECT,
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
@@ -0,0 +1,123 @@
import { join } from 'path';
import { readJson } from '@/cli/utilities/file/fs-utils';
import {
type FieldManifest,
type Manifest,
type ObjectFieldManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { validate as isUuid } from 'uuid';
const POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER =
'54b589ca-eeed-4950-a176-358418b85c05';
const POST_CARD_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'87b675b8-dd8c-4448-b4ca-20e5a2234a1e';
const POST_CARD_CATEGORY_FIELD_UNIVERSAL_IDENTIFIER =
'b602dbd9-e511-49ce-b6d3-b697218dc69c';
export const defineFieldOptionIdsTests = (appPath: string): void => {
const manifestOutputPath = join(appPath, '.twenty/output/manifest.json');
let manifest: Manifest;
beforeAll(async () => {
manifest = await readJson<Manifest>(manifestOutputPath);
});
describe('select option id injection (object inline branch)', () => {
let statusField: ObjectFieldManifest | undefined;
beforeAll(() => {
const postCard = manifest.objects.find(
(object) =>
object.universalIdentifier === POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER,
);
statusField = postCard?.fields.find(
(field) =>
field.universalIdentifier ===
POST_CARD_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
);
});
it('should find the inline status SELECT field with all 5 options', () => {
expect(statusField).toBeDefined();
expect(statusField?.type).toBe(FieldMetadataType.SELECT);
expect(statusField?.options).toHaveLength(5);
});
it('should give every option a defined, valid UUID id', () => {
for (const option of statusField?.options ?? []) {
expect(option.id).toBeDefined();
expect(typeof option.id).toBe('string');
expect(isUuid(option.id)).toBe(true);
}
});
});
describe('select option id injection (standalone field branch)', () => {
let categoryField: FieldManifest | undefined;
beforeAll(() => {
categoryField = manifest.fields.find(
(field) =>
field.universalIdentifier ===
POST_CARD_CATEGORY_FIELD_UNIVERSAL_IDENTIFIER,
);
});
it('should find the standalone category SELECT field with all 4 options', () => {
expect(categoryField).toBeDefined();
expect(categoryField?.type).toBe(FieldMetadataType.SELECT);
expect(categoryField?.options).toHaveLength(4);
});
it('should give every option a defined, valid UUID id', () => {
for (const option of categoryField?.options ?? []) {
expect(option.id).toBeDefined();
expect(typeof option.id).toBe('string');
expect(isUuid(option.id)).toBe(true);
}
});
it('should inject a fresh UUID for the OTHER option that has no source id', () => {
const otherOption = categoryField?.options?.find(
(option) => option.value === 'OTHER',
);
expect(otherOption).toBeDefined();
expect(otherOption?.id).toBeDefined();
expect(isUuid(otherOption?.id ?? '')).toBe(true);
});
});
describe('select option id injection (manifest-wide invariant)', () => {
it('should ensure every SELECT/MULTI_SELECT option in the manifest has a defined id', () => {
const collectSelectOptions = (
field: ObjectFieldManifest | FieldManifest,
) => {
if (
field.type !== FieldMetadataType.SELECT &&
field.type !== FieldMetadataType.MULTI_SELECT
) {
return [];
}
return field.options ?? [];
};
const allOptions = [
...manifest.objects.flatMap((object) =>
object.fields.flatMap(collectSelectOptions),
),
...manifest.fields.flatMap(collectSelectOptions),
];
expect(allOptions.length).toBeGreaterThan(0);
for (const option of allOptions) {
expect(option.id).toBeDefined();
expect(isUuid(option.id)).toBe(true);
}
});
});
};
@@ -37,6 +37,7 @@ import {
} from 'twenty-shared/application';
import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function';
import { assertUnreachable } from 'twenty-shared/utils';
import { addMissingFieldOptionIds } from '@/cli/utilities/build/manifest/utils/add-missing-field-option-ids';
const loadSources = async (appPath: string): Promise<string[]> => {
return await glob(['**/*.ts', '**/*.tsx'], {
@@ -145,7 +146,7 @@ export const buildManifest = async (
const objectManifest: ObjectManifest = {
...extract.config,
fields: objectFieldsWithDefaults,
fields: objectFieldsWithDefaults.map(addMissingFieldOptionIds),
labelIdentifierFieldMetadataUniversalIdentifier,
};
@@ -161,7 +162,8 @@ export const buildManifest = async (
appPath,
filePath,
});
fields.push(extract.config);
const fieldConfig = addMissingFieldOptionIds(extract.config);
fields.push(fieldConfig);
errors.push(...extract.errors);
fieldsFilePaths.push(relativePath);
break;
@@ -0,0 +1,118 @@
import { addMissingFieldOptionIds } from '@/cli/utilities/build/manifest/utils/add-missing-field-option-ids';
import { type FieldManifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { validate } from 'uuid';
const buildSelectField = (
options: FieldManifest<FieldMetadataType.SELECT>['options'],
): FieldManifest<FieldMetadataType.SELECT> => ({
universalIdentifier: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
objectUniversalIdentifier: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb',
name: 'status',
label: 'Status',
type: FieldMetadataType.SELECT,
options,
});
describe('addMissingFieldOptionIds', () => {
it('should add a UUID v4 id to every option that is missing one for a SELECT field', () => {
const fieldManifest = buildSelectField([
{ color: 'green', label: 'Open', value: 'OPEN', position: 1 },
{ color: 'red', label: 'Closed', value: 'CLOSED', position: 2 },
]);
const result = addMissingFieldOptionIds(fieldManifest);
expect(result.options).toHaveLength(2);
for (const option of result.options ?? []) {
expect(validate(option.id)).toBe(true);
}
});
it('should preserve existing ids on options that already have one', () => {
const existingId = 'cccccccc-cccc-4ccc-cccc-cccccccccccc';
const fieldManifest = buildSelectField([
{
id: existingId,
color: 'green',
label: 'Open',
value: 'OPEN',
position: 1,
},
{ color: 'red', label: 'Closed', value: 'CLOSED', position: 2 },
]);
const result = addMissingFieldOptionIds(fieldManifest);
expect(result.options?.[0].id).toBe(existingId);
expect(validate(result.options?.[1].id)).toBe(true);
expect(result.options?.[1].id).not.toBe(existingId);
});
it('should also process MULTI_SELECT fields', () => {
const fieldManifest: FieldManifest<FieldMetadataType.MULTI_SELECT> = {
universalIdentifier: 'dddddddd-dddd-4ddd-dddd-dddddddddddd',
objectUniversalIdentifier: 'eeeeeeee-eeee-4eee-eeee-eeeeeeeeeeee',
name: 'tags',
label: 'Tags',
type: FieldMetadataType.MULTI_SELECT,
options: [
{ color: 'blue', label: 'Urgent', value: 'URGENT', position: 1 },
],
};
const result = addMissingFieldOptionIds(fieldManifest);
expect(validate(result.options?.[0].id)).toBe(true);
});
it('should return the field manifest unchanged when type is not SELECT or MULTI_SELECT', () => {
const fieldManifest: FieldManifest<FieldMetadataType.TEXT> = {
universalIdentifier: 'ffffffff-ffff-4fff-ffff-ffffffffffff',
objectUniversalIdentifier: '11111111-1111-4111-1111-111111111111',
name: 'description',
label: 'Description',
type: FieldMetadataType.TEXT,
};
const result = addMissingFieldOptionIds(fieldManifest);
expect(result).toBe(fieldManifest);
});
it('should return the field manifest unchanged when options is undefined', () => {
const fieldManifest = buildSelectField(undefined);
const result = addMissingFieldOptionIds(fieldManifest);
expect(result).toBe(fieldManifest);
});
it('should not mutate the original field manifest or its options', () => {
const originalOptions = [
{ color: 'green' as const, label: 'Open', value: 'OPEN', position: 1 },
{ color: 'red' as const, label: 'Closed', value: 'CLOSED', position: 2 },
];
const fieldManifest = buildSelectField([...originalOptions]);
addMissingFieldOptionIds(fieldManifest);
expect(fieldManifest.options).toEqual(originalOptions);
for (const option of fieldManifest.options ?? []) {
expect(option.id).toBeUndefined();
}
});
it('should generate distinct ids for different missing options', () => {
const fieldManifest = buildSelectField([
{ color: 'green', label: 'A', value: 'A', position: 1 },
{ color: 'red', label: 'B', value: 'B', position: 2 },
{ color: 'blue', label: 'C', value: 'C', position: 3 },
]);
const result = addMissingFieldOptionIds(fieldManifest);
const ids = result.options?.map((option) => option.id) ?? [];
expect(new Set(ids).size).toBe(ids.length);
});
});
@@ -0,0 +1,38 @@
import type {
FieldManifest,
ObjectFieldManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { v5 } from 'uuid';
const OPTION_ID_NAMESPACE = 'a80ff791-b940-4c47-a522-2bb478515415';
export const addMissingFieldOptionIds = <
T extends FieldManifest | ObjectFieldManifest,
>(
fieldManifest: T,
): T => {
if (
fieldManifest.type !== FieldMetadataType.SELECT &&
fieldManifest.type !== FieldMetadataType.MULTI_SELECT
) {
return fieldManifest;
}
if (fieldManifest.options === undefined || fieldManifest.options === null) {
return fieldManifest;
}
return {
...fieldManifest,
options: fieldManifest.options.map((option) => ({
...option,
id:
option.id ??
v5(
`${option.label}-${fieldManifest.universalIdentifier}`,
OPTION_ID_NAMESPACE,
),
})),
};
};
@@ -21,7 +21,7 @@ export class OAuthDiscoveryController {
async getAuthorizationServerMetadata() {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const frontUrl = this.domainServerConfigService.getFrontUrl().toString();
const frontUrl = this.domainServerConfigService.getBaseUrl().toString();
const cliRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
@@ -233,7 +233,6 @@ describe('WorkspaceEntityManager', () => {
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_AI_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: false,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
@@ -45,11 +45,6 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_MARKETPLACE_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_MARKETPLACE_SETTING_TAB_VISIBLE,
workspaceId: workspaceId,
@@ -3,7 +3,6 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',