feat(applications): add type and options to application variables (#22157)

## Before
<img width="1452" height="709" alt="image"
src="https://github.com/user-attachments/assets/cd384ffa-cbe6-49d5-a807-ca8d580f55a9"
/>

<img width="1074" height="452" alt="image"
src="https://github.com/user-attachments/assets/720d38db-3495-4032-8831-17d24ec6a7e7"
/>

## After

<img width="1421" height="865" alt="image"
src="https://github.com/user-attachments/assets/2275c996-c895-4800-8324-2aa2ddfddd43"
/>

<img width="1348" height="870" alt="image"
src="https://github.com/user-attachments/assets/3e1a891d-6db0-4cbd-870a-2a5bbde4929d"
/>


## Summary

Adds typed application variables with optional select **options**. This
is the other half of #22059, split out from the custom-settings-tab
removal.

## Changes

- **Shared types**: `ApplicationVariable` / `ServerVariables` gain an
optional `type` (a `FieldMetadataType` subset — `TEXT`, `BOOLEAN`,
`NUMBER`, `DATE`, `SELECT`, `MULTI_SELECT`, `RAW_JSON`, `RICH_TEXT`,
`ARRAY`, …) and select `options`. New
`serializeApplicationVariableValue` /
`deserializeApplicationVariableValue` helpers convert typed values
to/from the encrypted string storage.
- **Server**: `type`/`options` columns on `applicationVariable` and
`applicationRegistrationVariable` (entities + DTOs), a fast `2-17`
instance command, manifest processing via the serialization helpers, and
a `QueryDeepPartialEntity` cast where the manifest JSON column is
persisted.
- **Frontend**: a polymorphic `SettingsApplicationVariableInput` that
renders the native `Form*` field component for each type (boolean,
number, date/date-time, select, multi-select, array, raw JSON, rich
text, text); fragment/query updates to fetch `type`/`options`.
- **SDK**: `defineApplication` validates that `SELECT`/`MULTI_SELECT`
variables declare non-empty `options` at build time (since `options` is
kept structurally optional for TypeORM/SDK compatibility).

Variables default to `TEXT` when no type is given, so existing manifests
are unaffected.

## Notes

The generated GraphQL artifacts (`type`/`options` on the variable types)
are regenerated by codegen; that change accompanies this PR.

https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23

---
_Generated by [Claude
Code](https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22157?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-07-03 10:52:22 +02:00
committed by GitHub
parent 90f35658c5
commit 25fe66565c
42 changed files with 1318 additions and 594 deletions
@@ -1,4 +1,4 @@
import { defineApplication } from 'twenty-sdk/define';
import { defineApplication, FieldType } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './src/roles/default-function.role';
export default defineApplication({
@@ -12,6 +12,83 @@ export default defineApplication({
value: 'Alex Karp',
isSecret: false,
},
GREETING_TEXT: {
universalIdentifier: 'ad19edc5-4cc5-4003-a996-aef53a5c8de0',
description: 'Free text shown on the postcard',
type: FieldType.TEXT,
value: 'Hello from Rich App',
},
ENABLE_TRACKING: {
universalIdentifier: 'b9c58bb2-58c3-4c6c-9877-498ea6c03fde',
description: 'Toggle delivery tracking',
type: FieldType.BOOLEAN,
value: true,
},
MAX_POSTCARDS: {
universalIdentifier: '5f4497e4-9030-4085-85eb-2c48b8d53713',
description: 'Maximum postcards per batch',
type: FieldType.NUMBER,
value: 10,
},
DISCOUNT_RATE: {
universalIdentifier: 'd32f810f-06bb-4d29-b0ee-1dc4228f7cb8',
description: 'Bulk discount rate applied at checkout',
type: FieldType.NUMERIC,
value: 2.5,
},
CAMPAIGN_START_DATE: {
universalIdentifier: '5aa4fcec-e8a3-4bc1-9c7f-762e1f9dfb40',
description: 'Date the campaign starts',
type: FieldType.DATE,
value: '2026-01-01',
},
CAMPAIGN_START_AT: {
universalIdentifier: '273d0cfd-d6ab-4148-8816-c4a5d4b1d600',
description: 'Exact moment the campaign starts',
type: FieldType.DATE_TIME,
value: '2026-01-01T09:00:00.000Z',
},
DEFAULT_REGION: {
universalIdentifier: '76c5c321-b6b6-46eb-b4fc-f9f04bb04227',
description: 'Default shipping region',
type: FieldType.SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
{ label: 'Asia-Pacific', value: 'apac' },
],
value: 'eu',
},
ENABLED_CHANNELS: {
universalIdentifier: '706a2b08-8284-4715-8bc0-922a99cb26af',
description: 'Channels the app is allowed to use',
type: FieldType.MULTI_SELECT,
options: [
{ label: 'Email', value: 'email' },
{ label: 'SMS', value: 'sms' },
{ label: 'Postcard', value: 'postcard' },
],
value: ['email', 'postcard'],
},
ALLOWED_TAGS: {
universalIdentifier: 'c1c8a4c9-9130-4ab8-8dce-18d2b50879ed',
description: 'Free-form tags applied to recipients',
type: FieldType.ARRAY,
value: ['vip', 'returning'],
},
PROVIDER_CONFIG: {
universalIdentifier: '183d5285-c70c-4f29-96e0-c68659fbe5ae',
description: 'Raw JSON configuration for the printing provider',
type: FieldType.RAW_JSON,
value: { retries: 3, timeoutMs: 5000 },
},
WELCOME_MESSAGE: {
universalIdentifier: '25a66ff5-8458-498e-9e7e-33ea458a6f3c',
description: 'Rich text welcome message',
type: FieldType.RICH_TEXT,
value: { blocknote: null, markdown: 'Welcome to **Rich App**!' },
},
},
serverVariables: {
POSTCARD_API_KEY: {
@@ -24,6 +101,75 @@ export default defineApplication({
isSecret: false,
isRequired: false,
},
POSTCARD_DELIVERY_SPEED: {
description: 'Delivery speed requested from the provider',
type: FieldType.SELECT,
options: [
{ label: 'Standard', value: 'standard' },
{ label: 'Express', value: 'express' },
],
isSecret: false,
isRequired: true,
},
POSTCARD_DAILY_LIMIT: {
description: 'Maximum postcards the provider will accept per day',
type: FieldType.NUMBER,
isSecret: false,
isRequired: false,
},
POSTCARD_UNIT_PRICE: {
description: 'Price charged by the provider per postcard',
type: FieldType.NUMERIC,
isSecret: false,
isRequired: false,
},
POSTCARD_SANDBOX_MODE: {
description: 'Send postcards through the provider sandbox instead of production',
type: FieldType.BOOLEAN,
isSecret: false,
isRequired: false,
},
POSTCARD_CONTRACT_START_DATE: {
description: 'Date the provider contract starts',
type: FieldType.DATE,
isSecret: false,
isRequired: false,
},
POSTCARD_CONTRACT_RENEWAL_AT: {
description: 'Exact moment the provider contract renews',
type: FieldType.DATE_TIME,
isSecret: false,
isRequired: false,
},
POSTCARD_ENABLED_REGIONS: {
description: 'Regions the provider is allowed to ship to',
type: FieldType.MULTI_SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
{ label: 'Asia-Pacific', value: 'apac' },
],
isSecret: false,
isRequired: false,
},
POSTCARD_WEBHOOK_EVENTS: {
description: 'Provider webhook events the app subscribes to',
type: FieldType.ARRAY,
isSecret: false,
isRequired: false,
},
POSTCARD_PROVIDER_CONFIG: {
description: 'Raw JSON configuration for the printing provider',
type: FieldType.RAW_JSON,
isSecret: false,
isRequired: false,
},
POSTCARD_INVOICE_NOTE: {
description: 'Rich text note appended to provider invoices',
type: FieldType.RICH_TEXT,
isSecret: false,
isRequired: false,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -29,11 +29,18 @@ type ApplicationRegistrationVariable {
description: String!
isSecret: Boolean!
isRequired: Boolean!
type: String!
options: JSON
isFilled: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
"""
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
"""
scalar JSON
type ApplicationRegistration {
id: UUID!
universalIdentifier: String!
@@ -114,11 +121,6 @@ enum RowLevelPermissionPredicateOperand {
VECTOR_SEARCH
}
"""
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
"""
scalar JSON
type ObjectPermission {
objectMetadataId: UUID!
canReadObjectRecords: Boolean
@@ -297,6 +299,8 @@ type ApplicationVariable {
value: String!
description: String!
isSecret: Boolean!
type: String!
options: JSON
}
type AuthToken {
@@ -1763,6 +1767,8 @@ type ApplicationRegistrationVariableDTO {
isSecret: Boolean!
isRequired: Boolean!
isFilled: Boolean!
type: String!
options: JSON
createdAt: DateTime!
updatedAt: DateTime!
}
@@ -4,8 +4,8 @@ export type Scalars = {
UUID: string,
DateTime: string,
Boolean: boolean,
Float: number,
JSON: Record<string, unknown>,
Float: number,
Int: number,
ConnectionCursor: any,
JSONObject: any,
@@ -32,6 +32,8 @@ export interface ApplicationRegistrationVariable {
description: Scalars['String']
isSecret: Scalars['Boolean']
isRequired: Scalars['Boolean']
type: Scalars['String']
options?: Scalars['JSON']
isFilled: Scalars['Boolean']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
@@ -246,6 +248,8 @@ export interface ApplicationVariable {
value: Scalars['String']
description: Scalars['String']
isSecret: Scalars['Boolean']
type: Scalars['String']
options?: Scalars['JSON']
__typename: 'ApplicationVariable'
}
@@ -1399,6 +1403,8 @@ export interface ApplicationRegistrationVariableDTO {
isSecret: Scalars['Boolean']
isRequired: Scalars['Boolean']
isFilled: Scalars['Boolean']
type: Scalars['String']
options?: Scalars['JSON']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ApplicationRegistrationVariableDTO'
@@ -3029,6 +3035,8 @@ export interface ApplicationRegistrationVariableGenqlSelection{
description?: boolean | number
isSecret?: boolean | number
isRequired?: boolean | number
type?: boolean | number
options?: boolean | number
isFilled?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
@@ -3238,6 +3246,8 @@ export interface ApplicationVariableGenqlSelection{
value?: boolean | number
description?: boolean | number
isSecret?: boolean | number
type?: boolean | number
options?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -4441,6 +4451,8 @@ export interface ApplicationRegistrationVariableDTOGenqlSelection{
isSecret?: boolean | number
isRequired?: boolean | number
isFilled?: boolean | number
type?: boolean | number
options?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
File diff suppressed because it is too large Load Diff
@@ -35,6 +35,60 @@ Notes:
- The default role is detected automatically from the role file marked with [`defineApplicationRole()`](/developers/extend/apps/config/roles) — you do not need to reference it from `defineApplication()`.
- Pre-install and post-install functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`.
- Passing `defaultRoleUniversalIdentifier` explicitly is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`.
- `serverVariables` are instance-scoped configuration and secrets (e.g. API keys). Unlike `applicationVariables`, they declare no value in the manifest — the workspace operator fills them in from the app's settings, and they are injected into logic functions only once set.
## Variable types
Both `applicationVariables` and `serverVariables` accept an optional `type` (and, for `SELECT` / `MULTI_SELECT`, an `options` list). Supported types: `TEXT` (default), `BOOLEAN`, `NUMBER`, `NUMERIC`, `DATE`, `DATE_TIME`, `SELECT`, `MULTI_SELECT`, `ARRAY`, `RAW_JSON`, `RICH_TEXT`.
```ts src/application-config.ts
import { defineApplication, FieldType } from 'twenty-sdk/define';
export default defineApplication({
// ...identity, role...
applicationVariables: {
MAX_POSTCARDS: {
universalIdentifier: '5f4497e4-9030-4085-85eb-2c48b8d53713',
description: 'Maximum postcards per batch',
type: FieldType.NUMBER,
value: 10,
},
DEFAULT_REGION: {
universalIdentifier: '76c5c321-b6b6-46eb-b4fc-f9f04bb04227',
description: 'Default shipping region',
type: FieldType.SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
],
value: 'eu',
},
},
});
```
The `type` only affects **presentation and validation** — it selects the matching input in the workspace settings UI (a toggle, number field, dropdown, date picker, JSON editor, …) and lets the build validate your config (for example, `SELECT` / `MULTI_SELECT` must declare non-empty `options`). It does **not** change how the value reaches your code.
Values are **always injected as strings** — this is inherent to environment variables (`process.env.*` is string-only). When your logic function runs, the executor serializes each value by its declared `type` while building `process.env`, so the string format is consistent no matter how the value was set (manifest default, settings UI, or a previous version):
| Type | `process.env` string |
|------|----------------------|
| `TEXT`, `SELECT`, `DATE`, `DATE_TIME` | the raw value (`"eu"`, `"2026-01-01"`) |
| `BOOLEAN` | `"true"` / `"false"` |
| `NUMBER`, `NUMERIC` | decimal string (`"10"`, `"2.5"`) |
| `MULTI_SELECT`, `ARRAY` | JSON array (`'["email","postcard"]'`) |
| `RAW_JSON`, `RICH_TEXT` | JSON object (`'{"retries":3}'`) |
Parse the string back into the type you expect:
```ts
const maxCards = Number(process.env.MAX_POSTCARDS); // "10" -> 10
const enabled = process.env.ENABLE_TRACKING === 'true'; // "true" -> true
const channels = JSON.parse(process.env.ENABLED_CHANNELS ?? '[]'); // '["email"]' -> ["email"]
const config = JSON.parse(process.env.PROVIDER_CONFIG ?? '{}'); // '{"retries":3}' -> { retries: 3 }
```
The same applies to front components reading values via `getApplicationVariable('VARIABLE_NAME')` — the returned value is a string; parse it as needed.
## Default function role
@@ -377,6 +377,8 @@ export default defineFrontComponent({
Secret variables (`isSecret: true`) are **not** exposed to front components. They are only available in [logic functions](/developers/extend/apps/logic/logic-functions), which run server-side. This prevents sensitive values like API keys from being sent to the browser.
</Warning>
`getApplicationVariable` always returns a **string** (or `undefined`), regardless of the variable's declared `type`. The string is serialized consistently by type (booleans as `"true"` / `"false"`, numbers as decimal strings, arrays / objects as JSON), the same format used for logic-function `process.env` — parse it yourself (`Number(...)`, `JSON.parse(...)`, `=== 'true'`). See [Variable types](/developers/extend/apps/config/application#variable-types).
The following system variables are always available via `process.env`:
| Variable | Description |
@@ -232,6 +232,8 @@ export type ApplicationRegistrationVariableDto = {
isRequired: Scalars['Boolean']['output'];
isSecret: Scalars['Boolean']['output'];
key: Scalars['String']['output'];
options?: Maybe<Scalars['JSON']['output']>;
type: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
value?: Maybe<Scalars['String']['output']>;
};
@@ -1092,7 +1094,7 @@ export type FindAdminApplicationRegistrationVariablesQueryVariables = Exact<{
}>;
export type FindAdminApplicationRegistrationVariablesQuery = { __typename?: 'Query', findAdminApplicationRegistrationVariables: Array<{ __typename?: 'ApplicationRegistrationVariableDTO', id: string, key: string, value?: string | null, description: string, isSecret: boolean, isRequired: boolean, isFilled: boolean, createdAt: string, updatedAt: string }> };
export type FindAdminApplicationRegistrationVariablesQuery = { __typename?: 'Query', findAdminApplicationRegistrationVariables: Array<{ __typename?: 'ApplicationRegistrationVariableDTO', id: string, key: string, value?: string | null, description: string, isSecret: boolean, isRequired: boolean, isFilled: boolean, type: string, options?: any | null, createdAt: string, updatedAt: string }> };
export type FindAllApplicationRegistrationsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1339,7 +1341,7 @@ export const UpdateAdminApplicationRegistrationDocument = {"kind":"Document","de
export const UpdateAdminApplicationRegistrationVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistrationVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationVariableInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistrationVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<UpdateAdminApplicationRegistrationVariableMutation, UpdateAdminApplicationRegistrationVariableMutationVariables>;
export const FindAdminApplicationRegistrationInstalledWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationInstalledWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FindApplicationRegistrationInstalledWorkspacesInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationInstalledWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"hasMore"}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationInstalledWorkspacesQuery, FindAdminApplicationRegistrationInstalledWorkspacesQueryVariables>;
export const FindAdminApplicationRegistrationStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationStats"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeInstalls"}},{"kind":"Field","name":{"kind":"Name","value":"mostInstalledVersion"}},{"kind":"Field","name":{"kind":"Name","value":"versionDistribution"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationStatsQuery, FindAdminApplicationRegistrationStatsQueryVariables>;
export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
export const CreateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
export const DeleteDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}]}]}}]} as unknown as DocumentNode<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>;
File diff suppressed because one or more lines are too long
@@ -264,14 +264,6 @@ const SettingsAdminApplicationRegistrationDetail = lazy(() =>
),
);
const SettingsAdminApplicationRegistrationConfigVariableDetail = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationConfigVariableDetail').then(
(module) => ({
default: module.SettingsAdminApplicationRegistrationConfigVariableDetail,
}),
),
);
const SettingsAvailableApplicationDetails = lazy(() =>
import('~/pages/settings/applications/SettingsAvailableApplicationDetails').then(
(module) => ({
@@ -288,14 +280,6 @@ const SettingsApplicationRegistrationDetails = lazy(() =>
),
);
const SettingsApplicationRegistrationConfigVariableDetail = lazy(() =>
import('~/pages/settings/applications/components/SettingsApplicationRegistrationConfigVariableDetail').then(
(module) => ({
default: module.SettingsApplicationRegistrationConfigVariableDetail,
}),
),
);
const SettingsAgentForm = lazy(() =>
import('~/pages/settings/ai/SettingsAgentForm').then((module) => ({
default: module.SettingsAgentForm,
@@ -925,10 +909,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApplicationPageLayoutDetail}
element={<SettingsLayoutPageLayoutDetail />}
/>
<Route
path={SettingsPath.ApplicationRegistrationConfigVariableDetails}
element={<SettingsApplicationRegistrationConfigVariableDetail />}
/>
</Route>
<Route
@@ -1016,14 +996,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AdminPanelApplicationRegistrationDetail}
element={<SettingsAdminApplicationRegistrationDetail />}
/>
<Route
path={
SettingsPath.AdminPanelApplicationRegistrationConfigVariableDetails
}
element={
<SettingsAdminApplicationRegistrationConfigVariableDetail />
}
/>
<Route
path={SettingsPath.AdminPanelWorkspaceChatThread}
element={<SettingsAdminWorkspaceChatThread />}
@@ -31,6 +31,8 @@ export const APPLICATION_FRAGMENT = gql`
value
description
isSecret
type
options
}
agents {
...AgentFields
@@ -9,6 +9,7 @@ import { UndoRedo } from '@tiptap/extensions/undo-redo';
import { Slice } from '@tiptap/pm/model';
import { type Editor, useEditor } from '@tiptap/react';
import { useEffect } from 'react';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { type JsonValue } from 'type-fest';
@@ -131,5 +132,9 @@ export const useTextVariableEditor = ({
injectCSS: false,
});
useEffect(() => {
editor?.setEditable(!readonly, false);
}, [editor, readonly]);
return editor;
};
@@ -14,6 +14,8 @@ export const FIND_ADMIN_APPLICATION_REGISTRATION_VARIABLES = gql`
isSecret
isRequired
isFilled
type
options
createdAt
updatedAt
}
@@ -1,78 +0,0 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { ConfigVariableEdit } from '@/settings/config-variables/components/ConfigVariableEdit';
import { TextInput } from '@/ui/input/components/TextInput';
type VariableData = {
id: string;
key: string;
value?: string | null;
description: string;
isFilled: boolean;
};
export const ApplicationRegistrationConfigVariableEditForm = ({
variable,
onUpdateVariable,
}: {
variable: VariableData;
onUpdateVariable: (
id: string,
update: { value: string; resetValue?: boolean },
) => Promise<void>;
}) => {
const { t } = useLingui();
const [value, setValue] = useState<string>('');
const [isEditing, setIsEditing] = useState(false);
const canOpenCancelModal = variable.isFilled && !isNonEmptyString(value);
const onCancel = () => {
setValue('');
};
const onSave = async () => {
if (!isNonEmptyString(value)) {
return;
}
try {
await onUpdateVariable(variable.id, { value });
} finally {
setValue('');
}
};
const onConfirmReset = async () => {
try {
await onUpdateVariable(variable.id, { value: '', resetValue: true });
} finally {
setValue('');
}
};
return (
<ConfigVariableEdit
title={variable.key}
description={variable.description}
input={
<TextInput
value={value}
placeholder={!isEditing ? (variable.value ?? t`Enter a value`) : ''}
onChange={setValue}
disabled={!isEditing}
fullWidth
/>
}
isEditing={isEditing}
setIsEditing={setIsEditing}
isSaveDisabled={!isNonEmptyString(value)}
onSave={onSave}
onCancel={onCancel}
canOpenCancelModal={canOpenCancelModal}
onConfirmReset={onConfirmReset}
/>
);
};
@@ -14,6 +14,8 @@ export const FIND_APPLICATION_REGISTRATION_VARIABLES = gql`
isSecret
isRequired
isFilled
type
options
createdAt
updatedAt
}
@@ -1,106 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import { useMutation, useQuery } from '@apollo/client/react';
import {
FindAdminApplicationRegistrationVariablesDocument,
FindOneAdminApplicationRegistrationDocument,
UpdateAdminApplicationRegistrationVariableDocument,
} from '~/generated-admin/graphql';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { APPLICATION_REGISTRATION_ADMIN_PATH } from '@/settings/admin-panel/apps/constants/ApplicationRegistrationAdminPath';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { NotFound } from '~/pages/not-found/NotFound';
import { ApplicationRegistrationConfigVariableEditForm } from '@/settings/application-registrations/components/ApplicationRegistrationConfigVariableEditForm';
export const SettingsAdminApplicationRegistrationConfigVariableDetail = () => {
const { t } = useLingui();
const apolloAdminClient = useApolloAdminClient();
const { variableKey, applicationRegistrationId = '' } = useParams<{
applicationRegistrationId: string;
variableKey: string;
}>();
const { data: applicationRegistrationData, loading: registrationLoading } =
useQuery(FindOneAdminApplicationRegistrationDocument, {
client: apolloAdminClient,
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
});
const registration =
applicationRegistrationData?.findOneAdminApplicationRegistration;
const { data: variablesData, loading: variablesLoading } = useQuery(
FindAdminApplicationRegistrationVariablesDocument,
{
client: apolloAdminClient,
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const variable = (
variablesData?.findAdminApplicationRegistrationVariables ?? []
).find((variable) => variable.key === variableKey);
const [updateVariable] = useMutation(
UpdateAdminApplicationRegistrationVariableDocument,
{
client: apolloAdminClient,
refetchQueries: [FindAdminApplicationRegistrationVariablesDocument],
},
);
if (registrationLoading || variablesLoading) {
return <SettingsSkeletonLoader />;
}
if (!variable || !registration) {
return <NotFound />;
}
const onUpdateVariable = async (
id: string,
update: { value: string; resetValue?: boolean },
) => {
await updateVariable({
variables: { input: { id, update } },
});
};
return (
<SettingsPageLayout
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel - Apps`,
href: APPLICATION_REGISTRATION_ADMIN_PATH,
},
{
children: t`${registration.name} - Config`,
href: getSettingsPath(
SettingsPath.AdminPanelApplicationRegistrationDetail,
{ applicationRegistrationId },
undefined,
'config',
),
},
{
children: variableKey,
},
]}
>
<ApplicationRegistrationConfigVariableEditForm
variable={variable}
onUpdateVariable={onUpdateVariable}
/>
</SettingsPageLayout>
);
};
@@ -1,105 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import {
FindApplicationRegistrationVariablesDocument,
FindOneApplicationRegistrationDocument,
UpdateApplicationRegistrationVariableDocument,
} from '~/generated-metadata/graphql';
import { useMutation, useQuery } from '@apollo/client/react';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { NotFound } from '~/pages/not-found/NotFound';
import { ApplicationRegistrationConfigVariableEditForm } from '@/settings/application-registrations/components/ApplicationRegistrationConfigVariableEditForm';
export const SettingsApplicationRegistrationConfigVariableDetail = () => {
const { t } = useLingui();
const { variableKey, applicationRegistrationId = '' } = useParams<{
applicationRegistrationId: string;
variableKey: string;
}>();
const { data: applicationRegistrationData, loading: registrationLoading } =
useQuery(FindOneApplicationRegistrationDocument, {
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
});
const registration =
applicationRegistrationData?.findOneApplicationRegistration;
const { data: variablesData, loading: variablesLoading } = useQuery(
FindApplicationRegistrationVariablesDocument,
{
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const variable = (
variablesData?.findApplicationRegistrationVariables ?? []
).find((variable) => variable.key === variableKey);
const [updateVariable] = useMutation(
UpdateApplicationRegistrationVariableDocument,
{
refetchQueries: [FindApplicationRegistrationVariablesDocument],
},
);
if (registrationLoading || variablesLoading) {
return <SettingsSkeletonLoader />;
}
if (!variable || !registration) {
return <NotFound />;
}
const onUpdateVariable = async (
id: string,
update: { value: string; resetValue?: boolean },
) => {
await updateVariable({
variables: { input: { id, update } },
});
};
return (
<SettingsPageLayout
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.General),
},
{
children: t`Applications - Developer`,
href: getSettingsPath(
SettingsPath.Applications,
undefined,
undefined,
'developer',
),
},
{
children: t`${registration.name} - Config`,
href: getSettingsPath(
SettingsPath.ApplicationRegistrationDetail,
{ applicationRegistrationId },
undefined,
'config',
),
},
{
children: variableKey,
},
]}
>
<ApplicationRegistrationConfigVariableEditForm
variable={variable}
onUpdateVariable={onUpdateVariable}
/>
</SettingsPageLayout>
);
};
@@ -0,0 +1,190 @@
import { FormArrayFieldInput } from '@/object-record/record-field/ui/form-types/components/FormArrayFieldInput';
import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput';
import { FormDateFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateFieldInput';
import { FormDateTimeFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput';
import { FormRichTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRichTextFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
import { type FieldRichTextValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { TextInput } from '@/ui/input/components/TextInput';
import { t } from '@lingui/core/macro';
import {
type ApplicationVariableOption,
deserializeApplicationVariableValue,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui/input';
type SettingsApplicationVariableInputProps = {
type?: string | null;
value: string;
options?: ApplicationVariableOption[] | null;
onChange: (serializedValue: string) => void;
placeholder?: string;
disabled?: boolean;
};
const toSelectOptions = (
options: ApplicationVariableOption[] | null | undefined,
): SelectOption[] =>
(options ?? []).map((option) => ({
label: option.label,
value: option.value,
}));
const parseStringArray = (value: string): string[] => {
const parsed = deserializeApplicationVariableValue(
value,
FieldMetadataType.MULTI_SELECT,
);
return Array.isArray(parsed) ? (parsed as string[]) : [];
};
const parseRichTextValue = (value: string): FieldRichTextValue => {
try {
const parsed = JSON.parse(value) as Partial<FieldRichTextValue>;
if (isDefined(parsed) && typeof parsed === 'object') {
const blocknote =
typeof parsed.blocknote === 'string' ? parsed.blocknote : null;
const markdown =
typeof parsed.markdown === 'string' ? parsed.markdown : null;
return { blocknote, markdown };
}
} catch {
return { blocknote: null, markdown: value === '' ? null : value };
}
return { blocknote: null, markdown: value === '' ? null : value };
};
export const SettingsApplicationVariableInput = ({
type,
value,
options,
onChange,
placeholder,
disabled,
}: SettingsApplicationVariableInputProps) => {
const fieldType = (type as FieldMetadataType) ?? FieldMetadataType.TEXT;
switch (fieldType) {
case FieldMetadataType.BOOLEAN:
return (
<FormBooleanFieldInput
defaultValue={value === '' ? undefined : value === 'true'}
onChange={(newValue) =>
onChange(
newValue === true ? 'true' : newValue === false ? 'false' : '',
)
}
readonly={disabled}
/>
);
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC:
return (
<FormNumberFieldInput
defaultValue={value}
onChange={(newValue) =>
onChange(isDefined(newValue) ? String(newValue) : '')
}
placeholder={placeholder}
readonly={disabled}
/>
);
case FieldMetadataType.DATE:
return (
<FormDateFieldInput
defaultValue={value === '' ? undefined : value}
onChange={(newValue) => onChange(newValue ?? '')}
placeholder={placeholder}
readonly={disabled}
/>
);
case FieldMetadataType.DATE_TIME:
return (
<FormDateTimeFieldInput
defaultValue={value === '' ? undefined : value}
onChange={(newValue) => onChange(newValue ?? '')}
placeholder={placeholder}
readonly={disabled}
/>
);
case FieldMetadataType.SELECT:
return (
<FormSelectFieldInput
defaultValue={value === '' ? undefined : value}
options={toSelectOptions(options)}
onChange={(newValue) => onChange(newValue ?? '')}
readonly={disabled}
isNullable
/>
);
case FieldMetadataType.MULTI_SELECT:
return (
<FormMultiSelectFieldInput
defaultValue={parseStringArray(value)}
options={toSelectOptions(options)}
onChange={(newValue) =>
onChange(Array.isArray(newValue) ? JSON.stringify(newValue) : '')
}
placeholder={placeholder}
readonly={disabled}
/>
);
case FieldMetadataType.ARRAY:
return (
<FormArrayFieldInput
defaultValue={parseStringArray(value)}
onChange={(newValue) =>
onChange(Array.isArray(newValue) ? JSON.stringify(newValue) : '')
}
placeholder={placeholder}
readonly={disabled}
/>
);
case FieldMetadataType.RAW_JSON:
return (
<FormRawJsonFieldInput
defaultValue={value === '' ? null : value}
onChange={(newValue) => onChange(newValue ?? '')}
placeholder={placeholder}
readonly={disabled}
/>
);
case FieldMetadataType.RICH_TEXT:
return (
<FormRichTextFieldInput
defaultValue={parseRichTextValue(value)}
onChange={(newValue) => onChange(JSON.stringify(newValue))}
placeholder={placeholder}
readonly={disabled}
/>
);
default:
return (
<TextInput
value={value}
onChange={onChange}
placeholder={placeholder ?? t`Value`}
readOnly={disabled}
fullWidth
/>
);
}
};
@@ -48,6 +48,7 @@ const buildApplication = (variableValue: string): Application => ({
value: variableValue,
description: '',
isSecret: false,
type: 'TEXT',
},
],
agents: [],
@@ -1,8 +1,8 @@
import { TextInput } from '@/ui/input/components/TextInput';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext, useState } from 'react';
import { type ApplicationVariableOption } from 'twenty-shared/application';
import { IconInfoCircle } from 'twenty-ui/icon';
import { AppTooltip, TooltipDelay } from 'twenty-ui/surfaces';
import { H2Title } from 'twenty-ui/typography';
@@ -10,6 +10,7 @@ import { Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
import { type ApplicationVariable } from '~/generated-metadata/graphql';
import { SettingsApplicationVariableInput } from '~/pages/settings/applications/components/SettingsApplicationVariableInput';
const StyledContainer = styled.div`
display: flex;
@@ -80,8 +81,15 @@ export const SettingsApplicationDetailEnvironmentVariablesTable = ({
</>
)}
</StyledLabelRow>
<TextInput
<SettingsApplicationVariableInput
type={editedEnvVariable.type}
value={editedEnvVariable.value}
options={
editedEnvVariable.options as
| ApplicationVariableOption[]
| null
| undefined
}
onChange={(newValue) => {
setEditedEnvVariables((prevState) =>
prevState.map((val) => {
@@ -94,7 +102,6 @@ export const SettingsApplicationDetailEnvironmentVariablesTable = ({
onUpdateDebounced({ ...editedEnvVariable, value: newValue });
}}
placeholder={t`Value`}
fullWidth
/>
</div>
);
@@ -1,16 +1,90 @@
import type { ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { useQuery } from '@apollo/client/react';
import { FindApplicationRegistrationVariablesDocument } from '~/generated-metadata/graphql';
import { FindAdminApplicationRegistrationVariablesDocument } from '~/generated-admin/graphql';
import { useMutation, useQuery } from '@apollo/client/react';
import {
FindApplicationRegistrationVariablesDocument,
UpdateApplicationRegistrationVariableDocument,
} from '~/generated-metadata/graphql';
import {
FindAdminApplicationRegistrationVariablesDocument,
UpdateAdminApplicationRegistrationVariableDocument,
} from '~/generated-admin/graphql';
import { styled } from '@linaria/react';
import { Section } from 'twenty-ui/layout';
import { Status } from 'twenty-ui/data-display';
import { H2Title } from 'twenty-ui/typography';
import { IconInfoCircle } from 'twenty-ui/icon';
import { AppTooltip, TooltipDelay } from 'twenty-ui/surfaces';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useLingui } from '@lingui/react/macro';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { ConfigVariableTable } from '@/settings/config-variables/components/ConfigVariableTable';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext, useState } from 'react';
import { type ApplicationVariableOption } from 'twenty-shared/application';
import { useDebouncedCallback } from 'use-debounce';
import { SettingsApplicationVariableInput } from '~/pages/settings/applications/components/SettingsApplicationVariableInput';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
type ConfigVariable = {
id: string;
key: string;
value?: string | null;
type?: string | null;
options?: ApplicationVariableOption[] | null;
isSecret?: boolean | null;
isFilled?: boolean | null;
};
const ConfigVariableInput = ({
variable,
onUpdate,
}: {
variable: ConfigVariable;
onUpdate: (id: string, value: string) => void;
}) => {
const { t } = useLingui();
const isSecretFilled =
variable.isSecret === true && variable.isFilled === true;
const [value, setValue] = useState(
isSecretFilled ? '' : (variable.value ?? ''),
);
const onUpdateDebounced = useDebouncedCallback((newValue: string) => {
onUpdate(variable.id, newValue);
}, 250);
return (
<SettingsApplicationVariableInput
type={variable.type}
value={value}
options={variable.options}
onChange={(newValue) => {
setValue(newValue);
onUpdateDebounced(newValue);
}}
placeholder={isSecretFilled ? (variable.value ?? undefined) : t`Value`}
/>
);
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledLabelRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
margin-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledLabel = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: 11px;
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
export const SettingsApplicationRegistrationConfigTab = ({
registration,
fromAdmin,
@@ -19,6 +93,7 @@ export const SettingsApplicationRegistrationConfigTab = ({
fromAdmin?: boolean;
}) => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const apolloAdminClient = useApolloAdminClient();
const applicationRegistrationId = registration.id;
@@ -40,24 +115,36 @@ export const SettingsApplicationRegistrationConfigTab = ({
},
);
const [updateWorkspaceVariable] = useMutation(
UpdateApplicationRegistrationVariableDocument,
{
refetchQueries: [FindApplicationRegistrationVariablesDocument],
},
);
const [updateAdminVariable] = useMutation(
UpdateAdminApplicationRegistrationVariableDocument,
{
client: apolloAdminClient,
refetchQueries: [FindAdminApplicationRegistrationVariablesDocument],
},
);
const variables = fromAdmin
? (adminVariablesData?.findAdminApplicationRegistrationVariables ?? [])
: (workspaceVariablesData?.findApplicationRegistrationVariables ?? []);
const configVariables = variables.map((variable) => ({
name: variable.key,
description: variable.description,
value: variable.value ?? <Status color="gray" text={t`Not set`} />,
to: getSettingsPath(
fromAdmin
? SettingsPath.AdminPanelApplicationRegistrationConfigVariableDetails
: SettingsPath.ApplicationRegistrationConfigVariableDetails,
{
applicationRegistrationId,
variableKey: variable.key,
},
),
}));
const handleUpdate = (id: string, value: string) => {
if (fromAdmin === true) {
updateAdminVariable({
variables: { input: { id, update: { value } } },
});
} else {
updateWorkspaceVariable({
variables: { input: { id, update: { value } } },
});
}
};
return (
variables.length > 0 && (
@@ -66,7 +153,41 @@ export const SettingsApplicationRegistrationConfigTab = ({
title={t`Server Variables`}
description={t`Server variables are applied to all workspace installations.`}
/>
<ConfigVariableTable configVariables={configVariables} />
<StyledContainer>
{variables.map((variable) => {
const tooltipId = `config-var-desc-${variable.key}`;
return (
<div key={variable.key}>
<StyledLabelRow>
<StyledLabel>{variable.key}</StyledLabel>
{isNonEmptyString(variable.description) && (
<>
<IconInfoCircle
id={tooltipId}
size={theme.icon.size.sm}
color={theme.font.color.tertiary}
style={{ outline: 'none', cursor: 'pointer' }}
/>
<AppTooltip
anchorSelect={`#${tooltipId}`}
content={variable.description}
offset={5}
noArrow
place="bottom"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</>
)}
</StyledLabelRow>
<ConfigVariableInput
variable={variable as ConfigVariable}
onUpdate={handleUpdate}
/>
</div>
);
})}
</StyledContainer>
</Section>
)
);
@@ -55,6 +55,82 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
value: 'Alex Karp',
},
GREETING_TEXT: {
universalIdentifier: 'ad19edc5-4cc5-4003-a996-aef53a5c8de0',
description: 'Free text shown on the postcard',
type: FieldMetadataType.TEXT,
value: 'Hello from Rich App',
},
ENABLE_TRACKING: {
universalIdentifier: 'b9c58bb2-58c3-4c6c-9877-498ea6c03fde',
description: 'Toggle delivery tracking',
type: FieldMetadataType.BOOLEAN,
value: true,
},
MAX_POSTCARDS: {
universalIdentifier: '5f4497e4-9030-4085-85eb-2c48b8d53713',
description: 'Maximum postcards per batch',
type: FieldMetadataType.NUMBER,
value: 10,
},
DISCOUNT_RATE: {
universalIdentifier: 'd32f810f-06bb-4d29-b0ee-1dc4228f7cb8',
description: 'Bulk discount rate applied at checkout',
type: FieldMetadataType.NUMERIC,
value: 2.5,
},
CAMPAIGN_START_DATE: {
universalIdentifier: '5aa4fcec-e8a3-4bc1-9c7f-762e1f9dfb40',
description: 'Date the campaign starts',
type: FieldMetadataType.DATE,
value: '2026-01-01',
},
CAMPAIGN_START_AT: {
universalIdentifier: '273d0cfd-d6ab-4148-8816-c4a5d4b1d600',
description: 'Exact moment the campaign starts',
type: FieldMetadataType.DATE_TIME,
value: '2026-01-01T09:00:00.000Z',
},
DEFAULT_REGION: {
universalIdentifier: '76c5c321-b6b6-46eb-b4fc-f9f04bb04227',
description: 'Default shipping region',
type: FieldMetadataType.SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
{ label: 'Asia-Pacific', value: 'apac' },
],
value: 'eu',
},
ENABLED_CHANNELS: {
universalIdentifier: '706a2b08-8284-4715-8bc0-922a99cb26af',
description: 'Channels the app is allowed to use',
type: FieldMetadataType.MULTI_SELECT,
options: [
{ label: 'Email', value: 'email' },
{ label: 'SMS', value: 'sms' },
{ label: 'Postcard', value: 'postcard' },
],
value: ['email', 'postcard'],
},
ALLOWED_TAGS: {
universalIdentifier: 'c1c8a4c9-9130-4ab8-8dce-18d2b50879ed',
description: 'Free-form tags applied to recipients',
type: FieldMetadataType.ARRAY,
value: ['vip', 'returning'],
},
PROVIDER_CONFIG: {
universalIdentifier: '183d5285-c70c-4f29-96e0-c68659fbe5ae',
description: 'Raw JSON configuration for the printing provider',
type: FieldMetadataType.RAW_JSON,
value: { retries: 3, timeoutMs: 5000 },
},
WELCOME_MESSAGE: {
universalIdentifier: '25a66ff5-8458-498e-9e7e-33ea458a6f3c',
description: 'Rich text welcome message',
type: FieldMetadataType.RICH_TEXT,
value: { blocknote: null, markdown: 'Welcome to **Rich App**!' },
},
},
serverVariables: {
POSTCARD_API_KEY: {
@@ -67,6 +143,76 @@ export const EXPECTED_MANIFEST: Manifest = {
isSecret: false,
isRequired: false,
},
POSTCARD_DELIVERY_SPEED: {
description: 'Delivery speed requested from the provider',
type: FieldMetadataType.SELECT,
options: [
{ label: 'Standard', value: 'standard' },
{ label: 'Express', value: 'express' },
],
isSecret: false,
isRequired: true,
},
POSTCARD_DAILY_LIMIT: {
description: 'Maximum postcards the provider will accept per day',
type: FieldMetadataType.NUMBER,
isSecret: false,
isRequired: false,
},
POSTCARD_UNIT_PRICE: {
description: 'Price charged by the provider per postcard',
type: FieldMetadataType.NUMERIC,
isSecret: false,
isRequired: false,
},
POSTCARD_SANDBOX_MODE: {
description:
'Send postcards through the provider sandbox instead of production',
type: FieldMetadataType.BOOLEAN,
isSecret: false,
isRequired: false,
},
POSTCARD_CONTRACT_START_DATE: {
description: 'Date the provider contract starts',
type: FieldMetadataType.DATE,
isSecret: false,
isRequired: false,
},
POSTCARD_CONTRACT_RENEWAL_AT: {
description: 'Exact moment the provider contract renews',
type: FieldMetadataType.DATE_TIME,
isSecret: false,
isRequired: false,
},
POSTCARD_ENABLED_REGIONS: {
description: 'Regions the provider is allowed to ship to',
type: FieldMetadataType.MULTI_SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
{ label: 'Asia-Pacific', value: 'apac' },
],
isSecret: false,
isRequired: false,
},
POSTCARD_WEBHOOK_EVENTS: {
description: 'Provider webhook events the app subscribes to',
type: FieldMetadataType.ARRAY,
isSecret: false,
isRequired: false,
},
POSTCARD_PROVIDER_CONFIG: {
description: 'Raw JSON configuration for the printing provider',
type: FieldMetadataType.RAW_JSON,
isSecret: false,
isRequired: false,
},
POSTCARD_INVOICE_NOTE: {
description: 'Rich text note appended to provider invoices',
type: FieldMetadataType.RICH_TEXT,
isSecret: false,
isRequired: false,
},
},
description: 'A simple rich app',
displayName: 'Rich App',
@@ -1,3 +1,6 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
import { type ApplicationConfig } from '@/sdk/define/application/application-config';
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
@@ -14,6 +17,20 @@ export const defineApplication: DefineEntity<ApplicationConfig> = (config) => {
errors.push('Application must have a non empty display name');
}
for (const [variableName, variable] of Object.entries(
config.applicationVariables ?? {},
)) {
const requiresOptions =
variable.type === FieldMetadataType.SELECT ||
variable.type === FieldMetadataType.MULTI_SELECT;
if (requiresOptions && !isNonEmptyArray(variable.options)) {
errors.push(
`Application variable "${variableName}" of type ${variable.type} must define non-empty options`,
);
}
}
if (config.defaultRoleUniversalIdentifier) {
warnings.push(
'`defaultRoleUniversalIdentifier` on defineApplication() is deprecated. Use defineApplicationRole() in your role file instead.',
@@ -0,0 +1,39 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.19.0', 1783065514000)
export class AddTypeAndOptionsToApplicationVariablesFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationVariable" ADD COLUMN IF NOT EXISTS "type" text NOT NULL DEFAULT 'TEXT'`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationVariable" ADD COLUMN IF NOT EXISTS "options" jsonb`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistrationVariable" ADD COLUMN IF NOT EXISTS "type" text NOT NULL DEFAULT 'TEXT'`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistrationVariable" ADD COLUMN IF NOT EXISTS "options" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistrationVariable" DROP COLUMN IF EXISTS "options"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistrationVariable" DROP COLUMN IF EXISTS "type"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationVariable" DROP COLUMN IF EXISTS "options"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationVariable" DROP COLUMN IF EXISTS "type"`,
);
}
}
@@ -0,0 +1,2 @@
export const ADD_TYPE_AND_OPTIONS_TO_APPLICATION_VARIABLES_UPGRADE_COMMAND_NAME =
'2.19.0_AddTypeAndOptionsToApplicationVariablesFastInstanceCommand_1783065514000';
@@ -44,6 +44,7 @@ import { BackfillTsVectorFieldMetadataIdOnSearchFieldMetadataSlowInstanceCommand
import { AddMetadataOverridesColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1782986475000-add-metadata-overrides-column';
import { AddLastStreamErrorToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1782996657000-add-last-stream-error-to-agent-chat-thread';
import { BackfillMetadataOverridesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-slow-1782986476000-backfill-metadata-overrides';
import { AddTypeAndOptionsToApplicationVariablesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783065514000-add-type-and-options-to-application-variables';
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
import { AddSubFieldNameToViewSortEarlyFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1747234200000-add-sub-field-name-to-view-sort';
@@ -182,4 +183,5 @@ export const INSTANCE_COMMANDS = [
BackfillMetadataOverridesSlowInstanceCommand,
AddLastStreamErrorToAgentChatThreadFastInstanceCommand,
DropMetadataStandardOverridesColumnFastInstanceCommand,
AddTypeAndOptionsToApplicationVariablesFastInstanceCommand,
];
@@ -1,3 +1,9 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
type ApplicationVariableOption,
type ApplicationVariableType,
} from 'twenty-shared/application';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type UniversalFlatApplicationVariable } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-application-variable.type';
@@ -8,6 +14,8 @@ export const fromApplicationVariableManifestToUniversalFlatApplicationVariable =
description,
encryptedValue,
isSecret,
type,
options,
applicationUniversalIdentifier,
now,
}: {
@@ -16,6 +24,8 @@ export const fromApplicationVariableManifestToUniversalFlatApplicationVariable =
description?: string;
encryptedValue: EncryptedString | '';
isSecret?: boolean;
type?: ApplicationVariableType;
options?: ApplicationVariableOption[];
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatApplicationVariable => {
@@ -26,6 +36,8 @@ export const fromApplicationVariableManifestToUniversalFlatApplicationVariable =
value: encryptedValue,
description: description ?? '',
isSecret: isSecret ?? false,
type: type ?? FieldMetadataType.TEXT,
options: options ?? null,
createdAt: now,
updatedAt: now,
};
@@ -1,7 +1,11 @@
import { Injectable } from '@nestjs/common';
import { type Manifest } from 'twenty-shared/application';
import {
type Manifest,
serializeApplicationVariableValue,
} from 'twenty-shared/application';
import { MAX_CUSTOM_INDEXES_PER_OBJECT } from 'twenty-shared/constants';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { fromApplicationVariableManifestToUniversalFlatApplicationVariable } from 'src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util';
@@ -604,13 +608,18 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService {
for (const [key, applicationVariableManifest] of Object.entries(
manifest.application.applicationVariables ?? {},
)) {
const type = applicationVariableManifest.type ?? FieldMetadataType.TEXT;
const plaintextValue =
'value' in applicationVariableManifest
? applicationVariableManifest.value
: undefined;
? serializeApplicationVariableValue(
applicationVariableManifest.value,
type,
)
: '';
const isSecret = applicationVariableManifest.isSecret;
const rawValue = isSecret ? '' : (plaintextValue ?? '');
const rawValue = isSecret ? '' : plaintextValue;
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
@@ -624,6 +633,8 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService {
),
description: applicationVariableManifest.description,
isSecret,
type,
options: applicationVariableManifest.options,
applicationUniversalIdentifier,
now,
}),
@@ -15,6 +15,13 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { GraphQLJSON } from 'graphql-type-json';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type ApplicationVariableOption,
type ApplicationVariableType,
} from 'twenty-shared/application';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
@@ -57,6 +64,14 @@ export class ApplicationRegistrationVariableEntity {
@Column({ nullable: false, type: 'boolean', default: false })
isRequired: boolean;
@Field(() => String)
@Column({ nullable: false, type: 'text', default: FieldMetadataType.TEXT })
type: ApplicationVariableType;
@Field(() => GraphQLJSON, { nullable: true })
@Column({ nullable: true, type: 'jsonb', default: null })
options: ApplicationVariableOption[] | null;
@Field()
get isFilled(): boolean {
return this.encryptedValue !== '';
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ServerVariables } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, type Repository } from 'typeorm';
@@ -136,6 +137,8 @@ export class ApplicationRegistrationVariableService {
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
type: schema.type ?? FieldMetadataType.TEXT,
options: schema.options ?? null,
});
} else {
await this.variableRepository.save(
@@ -146,6 +149,8 @@ export class ApplicationRegistrationVariableService {
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
type: schema.type ?? FieldMetadataType.TEXT,
options: schema.options ?? null,
}),
);
}
@@ -1,7 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsBoolean, IsString } from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { GraphQLJSON } from 'graphql-type-json';
import { type ApplicationVariableOption } from 'twenty-shared/application';
@ObjectType()
export class ApplicationRegistrationVariableDTO {
@@ -32,6 +34,14 @@ export class ApplicationRegistrationVariableDTO {
@Field()
isFilled: boolean;
@IsString()
@Field()
type: string;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
options?: ApplicationVariableOption[] | null;
@Field(() => Date)
createdAt: Date;
@@ -9,6 +9,7 @@ import semver from 'semver';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
@@ -210,7 +211,7 @@ export class ApplicationTarballService {
isListed: false,
isFeatured: false,
ownerWorkspaceId: params.ownerWorkspaceId,
});
} as QueryDeepPartialEntity<ApplicationRegistrationEntity>);
if (manifest.application?.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
@@ -10,8 +10,16 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type ApplicationVariableOption,
type ApplicationVariableType,
} from 'twenty-shared/application';
import { ADD_TYPE_AND_OPTIONS_TO_APPLICATION_VARIABLES_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-19/add-type-and-options-to-application-variables-upgrade-command-name.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({
@@ -42,6 +50,20 @@ export class ApplicationVariableEntity extends SyncableEntity {
@Column({ nullable: false, type: 'boolean', default: false })
isSecret: boolean;
@WasIntroducedInUpgrade({
upgradeCommandName:
ADD_TYPE_AND_OPTIONS_TO_APPLICATION_VARIABLES_UPGRADE_COMMAND_NAME,
})
@Column({ nullable: false, type: 'text', default: FieldMetadataType.TEXT })
type: ApplicationVariableType;
@WasIntroducedInUpgrade({
upgradeCommandName:
ADD_TYPE_AND_OPTIONS_TO_APPLICATION_VARIABLES_UPGRADE_COMMAND_NAME,
})
@Column({ nullable: true, type: 'jsonb', default: null })
options: ApplicationVariableOption[] | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -1,7 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean, IsString } from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { type ApplicationVariableOption } from 'twenty-shared/application';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -25,4 +27,12 @@ export class ApplicationVariableEntityDTO {
@IsBoolean()
@Field()
isSecret: boolean;
@IsString()
@Field()
type: string;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
options?: ApplicationVariableOption[] | null;
}
@@ -1,3 +1,5 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@@ -37,6 +39,8 @@ describe('buildEnvVar', () => {
`enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
description: 'Public URL',
isSecret: false,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: workspaceA,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -50,6 +54,8 @@ describe('buildEnvVar', () => {
value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString,
description: 'API secret',
isSecret: true,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: workspaceA,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -63,6 +69,8 @@ describe('buildEnvVar', () => {
value: `enc:v2:deadbeef:true|${workspaceA}` as EncryptedString,
description: 'Debug flag',
isSecret: false,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: workspaceA,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -92,6 +100,8 @@ describe('buildEnvVar', () => {
value: `enc:v2:deadbeef:value-a|${workspaceA}` as EncryptedString,
description: '',
isSecret: true,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: workspaceA,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -105,6 +115,8 @@ describe('buildEnvVar', () => {
value: `enc:v2:deadbeef:value-b|${workspaceB}` as EncryptedString,
description: '',
isSecret: true,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: workspaceB,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -136,6 +148,8 @@ describe('buildEnvVar', () => {
value: null as unknown as EncryptedString | '',
description: '',
isSecret: false,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: workspaceA,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -149,6 +163,8 @@ describe('buildEnvVar', () => {
value: undefined as unknown as EncryptedString | '',
description: '',
isSecret: false,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: workspaceA,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -174,6 +190,8 @@ describe('buildEnvVar', () => {
value: 123 as unknown as EncryptedString | '',
description: '',
isSecret: false,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: workspaceA,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -25,8 +25,12 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"key",
"description",
"isSecret",
"type",
"options",
],
"propertiesToStringify": [
"options",
],
"propertiesToStringify": [],
},
"commandMenuItem": {
"propertiesToCompare": [
@@ -1761,6 +1761,16 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
type: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
options: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
createdAt: {
toCompare: false,
toStringify: false,
@@ -1,3 +1,5 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables';
@@ -10,6 +12,8 @@ const makeFlatVariable = (
value: 'value' as EncryptedString,
description: '',
isSecret: false,
type: FieldMetadataType.TEXT,
options: null,
applicationId: 'app-1',
workspaceId: '00000000-0000-0000-0000-000000000000',
universalIdentifier: '00000000-0000-0000-0000-000000000000',
@@ -1,16 +1,54 @@
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
import { FieldMetadataType } from '@/types/FieldMetadataType';
type SecretApplicationVariable = SyncableEntityOptions & {
description?: string;
isSecret: true;
export const APPLICATION_VARIABLE_FIELD_METADATA_TYPES = [
FieldMetadataType.TEXT,
FieldMetadataType.ARRAY,
FieldMetadataType.BOOLEAN,
FieldMetadataType.DATE,
FieldMetadataType.DATE_TIME,
FieldMetadataType.NUMBER,
FieldMetadataType.NUMERIC,
FieldMetadataType.RAW_JSON,
FieldMetadataType.RICH_TEXT,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
] as const;
export type ApplicationVariableType =
(typeof APPLICATION_VARIABLE_FIELD_METADATA_TYPES)[number];
export type ApplicationVariableOption = {
label: string;
value: string;
};
type NonSecretApplicationVariable = SyncableEntityOptions & {
value?: string;
description?: string;
isSecret?: false;
export type ApplicationVariableValue =
| string
| number
| boolean
| string[]
| Record<string, unknown>
| null;
type TypedApplicationVariable = {
type?: ApplicationVariableType;
options?: ApplicationVariableOption[];
};
type SecretApplicationVariable = SyncableEntityOptions &
TypedApplicationVariable & {
description?: string;
isSecret: true;
};
type NonSecretApplicationVariable = SyncableEntityOptions &
TypedApplicationVariable & {
value?: ApplicationVariableValue;
description?: string;
isSecret?: false;
};
export type ApplicationVariable =
| SecretApplicationVariable
| NonSecretApplicationVariable;
@@ -11,9 +11,13 @@ export type { AgentManifest } from './agentManifestType';
export type { AppConnection } from './appConnectionType';
export type { ApplicationManifest } from './applicationType';
export type {
ApplicationVariableType,
ApplicationVariableOption,
ApplicationVariableValue,
ApplicationVariable,
ApplicationVariables,
} from './applicationVariablesType';
export { APPLICATION_VARIABLE_FIELD_METADATA_TYPES } from './applicationVariablesType';
export type { AssetManifest } from './assetManifestType';
export type { ConnectionProviderManifest } from './connectionProviderManifestType';
export type { ConnectionProviderType } from './connectionProviderType';
@@ -122,6 +126,10 @@ export type { SkillManifest } from './skillManifestType';
export type { StoredOAuthConnectionProviderConfig } from './storedOAuthConnectionProviderConfigType';
export type { SyncableEntityOptions } from './syncableEntityOptionsType';
export type { ToolTriggerSettings } from './toolTriggerSettingsType';
export {
serializeApplicationVariableValue,
deserializeApplicationVariableValue,
} from './utils/applicationVariableValueSerialization';
export type {
ViewManifestFilterValue,
ViewFieldManifest,
@@ -1,7 +1,14 @@
import {
type ApplicationVariableOption,
type ApplicationVariableType,
} from '@/application/applicationVariablesType';
type ServerVariableSchema = {
description?: string;
isSecret?: boolean;
isRequired?: boolean;
type?: ApplicationVariableType;
options?: ApplicationVariableOption[];
};
export type ServerVariables = Record<string, ServerVariableSchema>;
@@ -0,0 +1,86 @@
import {
type ApplicationVariableType,
type ApplicationVariableValue,
} from '@/application/applicationVariablesType';
import { FieldMetadataType } from '@/types/FieldMetadataType';
export const serializeApplicationVariableValue = (
value: ApplicationVariableValue | undefined,
type: ApplicationVariableType = FieldMetadataType.TEXT,
): string => {
if (value === null || value === undefined) {
return '';
}
switch (type) {
case FieldMetadataType.BOOLEAN:
return String(value) === 'true' ? 'true' : 'false';
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC:
return String(value);
case FieldMetadataType.ARRAY:
case FieldMetadataType.MULTI_SELECT:
if (Array.isArray(value)) {
return JSON.stringify(value);
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as unknown;
if (Array.isArray(parsed)) {
return value;
}
} catch {}
return JSON.stringify([value]);
}
return JSON.stringify(value);
case FieldMetadataType.RAW_JSON:
case FieldMetadataType.RICH_TEXT:
return typeof value === 'string' ? value : JSON.stringify(value);
default:
return typeof value === 'string' ? value : String(value);
}
};
export const deserializeApplicationVariableValue = (
value: string,
type: ApplicationVariableType = FieldMetadataType.TEXT,
): ApplicationVariableValue => {
if (value === '') {
return type === FieldMetadataType.ARRAY ||
type === FieldMetadataType.MULTI_SELECT
? []
: '';
}
switch (type) {
case FieldMetadataType.BOOLEAN:
return value === 'true';
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC: {
const parsed = Number(value);
return Number.isNaN(parsed) ? value : parsed;
}
case FieldMetadataType.ARRAY:
case FieldMetadataType.MULTI_SELECT:
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? (parsed as string[]) : [];
} catch {
return [];
}
case FieldMetadataType.RAW_JSON:
case FieldMetadataType.RICH_TEXT:
try {
return JSON.parse(value) as Record<string, unknown>;
} catch {
return value;
}
default:
return value;
}
};
@@ -55,7 +55,6 @@ export enum SettingsPath {
ApplicationPageLayoutDetail = 'applications/:applicationId/pageLayouts/:pageLayoutUniversalIdentifier',
AvailableApplicationDetail = 'applications/available/:availableApplicationId',
ApplicationRegistrationDetail = 'applications/registrations/:applicationRegistrationId',
ApplicationRegistrationConfigVariableDetails = 'applications/registrations/:applicationRegistrationId/config-variables/:variableKey',
LogicFunctions = 'functions',
NewLogicFunction = 'functions/new',
LogicFunctionDetail = 'functions/:logicFunctionId',
@@ -86,7 +85,6 @@ export enum SettingsPath {
AdminPanelUserDetail = 'admin-panel/users/:userId',
AdminPanelWorkspaceDetail = 'admin-panel/workspaces/:workspaceId',
AdminPanelApplicationRegistrationDetail = 'admin-panel/applications/registrations/:applicationRegistrationId',
AdminPanelApplicationRegistrationConfigVariableDetails = 'admin-panel/applications/registrations/:applicationRegistrationId/config-variables/:variableKey',
AdminPanelWorkspaceChatThread = 'admin-panel/workspaces/:workspaceId/threads/:threadId',
Roles = 'members/roles',