1635 extensibilitytwenty cli app vars (#15143)
- Update twenty-cli to support application env variable definition - Update twenty-server to create a new `core.applicationVariable` entity to store env variables and provide env var when executing serverless function - Update twenty-front to support application environment variable value setting <img width="1044" height="660" alt="image" src="https://github.com/user-attachments/assets/24c3d323-5370-4a80-8174-fc4653cc3c22" /> <img width="1178" height="662" alt="image" src="https://github.com/user-attachments/assets/c124f423-8ed8-4246-ae5b-a9bd6672c7dc" /> <img width="1163" height="823" alt="image" src="https://github.com/user-attachments/assets/fb7425a3-facc-4895-a5eb-8a8e278e0951" /> <img width="1087" height="696" alt="image" src="https://github.com/user-attachments/assets/113da8a2-5590-433c-b1b3-5ed3137f24ca" /> <img width="1512" height="715" alt="image" src="https://github.com/user-attachments/assets/1d2110b7-301d-4f21-a45c-ddd54d6e3391" /> <img width="1287" height="581" alt="image" src="https://github.com/user-attachments/assets/353b16c6-0527-444c-87d6-51447a96cbc7" />
This commit is contained in:
@@ -1 +1,2 @@
|
||||
.yarn/install-state.gz
|
||||
.env
|
||||
|
||||
@@ -11,7 +11,17 @@
|
||||
"universalIdentifier": "4ec0391d-18d5-411c-b2f3-266ddc1c3ef7",
|
||||
"name": "Hello world",
|
||||
"description": "A hello-world application example",
|
||||
"env": {
|
||||
"TWENTY_API_KEY": {
|
||||
"isSecret": true,
|
||||
"value": "",
|
||||
"description": "Twenty api key"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,7 +1,5 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const TWENTY_API_KEY = '<SET_YOUR_TWENTY_API>';
|
||||
|
||||
export const main = async (params: { recipient: string }): Promise<object> => {
|
||||
const { recipient } = params;
|
||||
|
||||
@@ -10,7 +8,7 @@ export const main = async (params: { recipient: string }): Promise<object> => {
|
||||
url: 'http://localhost:3000/rest/postCards',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
|
||||
},
|
||||
data: { name: recipient ?? 'Unknown' },
|
||||
};
|
||||
@@ -20,8 +18,9 @@ export const main = async (params: { recipient: string }): Promise<object> => {
|
||||
|
||||
console.log(`New post card to "${recipient}" created`);
|
||||
|
||||
return { data };
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-cli",
|
||||
"version": "0.1.2-alpha",
|
||||
"version": "0.1.2-beta",
|
||||
"description": "Command-line interface for Twenty application development",
|
||||
"main": "dist/cli.js",
|
||||
"bin": {
|
||||
|
||||
@@ -36,6 +36,34 @@
|
||||
"title": "The application's license",
|
||||
"description": "Currently only MIT is accepted, although more licenses will probably be available in the future."
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"title": "Environment Variables",
|
||||
"description": "Key-value pairs defining environment variables available to all serverless functions.",
|
||||
"patternProperties": {
|
||||
"^[A-Z_][A-Z0-9_]*$": {
|
||||
"type": "object",
|
||||
"title": "Environment Variable Definition",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description for this environment variable."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Default value for this environment variable"
|
||||
},
|
||||
"isSecret": {
|
||||
"type": "boolean",
|
||||
"description": "If true, the value will be treated as sensitive and hidden from logs or UI."
|
||||
}
|
||||
},
|
||||
"required": ["isSecret"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"engines": {
|
||||
"type": "object",
|
||||
"title": "The application's engines",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Set environment values for your application here.
|
||||
# Use the format: KEY=value
|
||||
#
|
||||
# These variables are automatically loaded when running your serverless functions.
|
||||
# You can access them directly in your code using:
|
||||
# const myValue = process.env.KEY;
|
||||
#
|
||||
# To make these variables available to your application,
|
||||
# add them to package.json "env" key. This "env" key defines all
|
||||
# environment variables that will be provided to your serverless
|
||||
# functions at runtime.
|
||||
#
|
||||
# Example:
|
||||
# API_TOKEN=your-api-token
|
||||
# TIMEOUT_MS=3000
|
||||
@@ -1 +1,2 @@
|
||||
.yarn/install-state.gz
|
||||
.env
|
||||
|
||||
@@ -6,5 +6,8 @@
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2"
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import dotenv from 'dotenv';
|
||||
import assert from 'assert';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
@@ -111,12 +112,44 @@ export const loadManifest = async (
|
||||
manifest: AppManifest;
|
||||
}> => {
|
||||
const packageJsonPath = await findPathFile(appPath, 'package.json');
|
||||
|
||||
const rawPackageJson = await parseJsoncFile(packageJsonPath);
|
||||
|
||||
const yarnLockPath = await findPathFile(appPath, 'yarn.lock');
|
||||
|
||||
const rawYarnLock = await fs.readFile(yarnLockPath, 'utf8');
|
||||
|
||||
await validateSchema('appManifest', rawPackageJson, packageJsonPath);
|
||||
let envFile = '';
|
||||
|
||||
try {
|
||||
const envFilePath = await findPathFile(appPath, '.env');
|
||||
|
||||
envFile = await fs.readFile(envFilePath, 'utf8');
|
||||
} catch {
|
||||
// Allow missing .env
|
||||
}
|
||||
|
||||
const envVariables = dotenv.parse(envFile);
|
||||
|
||||
const packageJsonEnv = rawPackageJson.env || {};
|
||||
|
||||
for (const key of Object.keys(envVariables)) {
|
||||
if (packageJsonEnv[key]) {
|
||||
packageJsonEnv[key] = {
|
||||
isSecret: false,
|
||||
...packageJsonEnv[key],
|
||||
value: envVariables[key],
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Environment variable "${key}" is defined in .env but missing from package.json. Please add it to the "env" section in package.json.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const packageJson = { ...rawPackageJson, env: packageJsonEnv };
|
||||
|
||||
await validateSchema('appManifest', packageJson, packageJsonPath);
|
||||
|
||||
const agents = await loadCoreEntity(
|
||||
path.join(appPath, 'agents'),
|
||||
@@ -134,10 +167,10 @@ export const loadManifest = async (
|
||||
);
|
||||
|
||||
return {
|
||||
packageJson: rawPackageJson,
|
||||
packageJson,
|
||||
yarnLock: rawYarnLock,
|
||||
manifest: {
|
||||
...rawPackageJson,
|
||||
...packageJson,
|
||||
agents,
|
||||
objects,
|
||||
serverlessFunctions,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { config } from 'dotenv';
|
||||
import path from 'path';
|
||||
import * as path from 'path';
|
||||
|
||||
const envResult = config({
|
||||
path: path.resolve(__dirname, '.env'),
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = {
|
||||
'./src/modules/views/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/ai/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/applications/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/application-variables/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/workspace/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/workspace-member/graphql/**/*.{ts,tsx}',
|
||||
|
||||
@@ -204,11 +204,22 @@ export type AppTokenEdge = {
|
||||
export type Application = {
|
||||
__typename?: 'Application';
|
||||
agents: Array<Agent>;
|
||||
applicationVariables: Array<ApplicationVariable>;
|
||||
description: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
name: Scalars['String'];
|
||||
objects: Array<Object>;
|
||||
serverlessFunctions: Array<ServerlessFunction>;
|
||||
version: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ApplicationVariable = {
|
||||
__typename?: 'ApplicationVariable';
|
||||
description: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
isSecret: Scalars['Boolean'];
|
||||
key: Scalars['String'];
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ApprovedAccessDomain = {
|
||||
@@ -1841,6 +1852,7 @@ export type Mutation = {
|
||||
updateDatabaseConfigVariable: Scalars['Boolean'];
|
||||
updateLabPublicFeatureFlag: FeatureFlagDto;
|
||||
updateOneAgent: Agent;
|
||||
updateOneApplicationVariable: Scalars['Boolean'];
|
||||
updateOneCronTrigger: CronTrigger;
|
||||
updateOneDatabaseEventTrigger: DatabaseEventTrigger;
|
||||
updateOneField: Field;
|
||||
@@ -2584,6 +2596,13 @@ export type MutationUpdateOneAgentArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateOneApplicationVariableArgs = {
|
||||
applicationId: Scalars['UUID'];
|
||||
key: Scalars['String'];
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateOneCronTriggerArgs = {
|
||||
input: UpdateCronTriggerInput;
|
||||
};
|
||||
@@ -4777,19 +4796,28 @@ export type TrackAnalyticsMutationVariables = Exact<{
|
||||
|
||||
export type TrackAnalyticsMutation = { __typename?: 'Mutation', trackAnalytics: { __typename?: 'Analytics', success: boolean } };
|
||||
|
||||
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
|
||||
export type UpdateOneApplicationVariableMutationVariables = Exact<{
|
||||
key: Scalars['String'];
|
||||
value: Scalars['String'];
|
||||
applicationId: Scalars['UUID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean };
|
||||
|
||||
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
|
||||
|
||||
export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description: string }> };
|
||||
export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description: string, version: string }> };
|
||||
|
||||
export type FindOneApplicationQueryVariables = Exact<{
|
||||
id: Scalars['UUID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } };
|
||||
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } };
|
||||
|
||||
export type UploadFileMutationVariables = Exact<{
|
||||
file: Scalars['Upload'];
|
||||
@@ -6286,6 +6314,14 @@ export const ApplicationFieldsFragmentDoc = gql`
|
||||
id
|
||||
name
|
||||
description
|
||||
version
|
||||
applicationVariables {
|
||||
id
|
||||
key
|
||||
value
|
||||
description
|
||||
isSecret
|
||||
}
|
||||
agents {
|
||||
...AgentFields
|
||||
}
|
||||
@@ -7323,12 +7359,50 @@ export function useTrackAnalyticsMutation(baseOptions?: Apollo.MutationHookOptio
|
||||
export type TrackAnalyticsMutationHookResult = ReturnType<typeof useTrackAnalyticsMutation>;
|
||||
export type TrackAnalyticsMutationResult = Apollo.MutationResult<TrackAnalyticsMutation>;
|
||||
export type TrackAnalyticsMutationOptions = Apollo.BaseMutationOptions<TrackAnalyticsMutation, TrackAnalyticsMutationVariables>;
|
||||
export const UpdateOneApplicationVariableDocument = gql`
|
||||
mutation UpdateOneApplicationVariable($key: String!, $value: String!, $applicationId: UUID!) {
|
||||
updateOneApplicationVariable(
|
||||
key: $key
|
||||
value: $value
|
||||
applicationId: $applicationId
|
||||
)
|
||||
}
|
||||
`;
|
||||
export type UpdateOneApplicationVariableMutationFn = Apollo.MutationFunction<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdateOneApplicationVariableMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdateOneApplicationVariableMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdateOneApplicationVariableMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updateOneApplicationVariableMutation, { data, loading, error }] = useUpdateOneApplicationVariableMutation({
|
||||
* variables: {
|
||||
* key: // value for 'key'
|
||||
* value: // value for 'value'
|
||||
* applicationId: // value for 'applicationId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdateOneApplicationVariableMutation(baseOptions?: Apollo.MutationHookOptions<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>(UpdateOneApplicationVariableDocument, options);
|
||||
}
|
||||
export type UpdateOneApplicationVariableMutationHookResult = ReturnType<typeof useUpdateOneApplicationVariableMutation>;
|
||||
export type UpdateOneApplicationVariableMutationResult = Apollo.MutationResult<UpdateOneApplicationVariableMutation>;
|
||||
export type UpdateOneApplicationVariableMutationOptions = Apollo.BaseMutationOptions<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>;
|
||||
export const FindManyApplicationsDocument = gql`
|
||||
query FindManyApplications {
|
||||
findManyApplications {
|
||||
id
|
||||
name
|
||||
description
|
||||
version
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -204,11 +204,22 @@ export type AppTokenEdge = {
|
||||
export type Application = {
|
||||
__typename?: 'Application';
|
||||
agents: Array<Agent>;
|
||||
applicationVariables: Array<ApplicationVariable>;
|
||||
description: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
name: Scalars['String'];
|
||||
objects: Array<Object>;
|
||||
serverlessFunctions: Array<ServerlessFunction>;
|
||||
version: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ApplicationVariable = {
|
||||
__typename?: 'ApplicationVariable';
|
||||
description: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
isSecret: Scalars['Boolean'];
|
||||
key: Scalars['String'];
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ApprovedAccessDomain = {
|
||||
@@ -1793,6 +1804,7 @@ export type Mutation = {
|
||||
updateDatabaseConfigVariable: Scalars['Boolean'];
|
||||
updateLabPublicFeatureFlag: FeatureFlagDto;
|
||||
updateOneAgent: Agent;
|
||||
updateOneApplicationVariable: Scalars['Boolean'];
|
||||
updateOneCronTrigger: CronTrigger;
|
||||
updateOneDatabaseEventTrigger: DatabaseEventTrigger;
|
||||
updateOneField: Field;
|
||||
@@ -2500,6 +2512,13 @@ export type MutationUpdateOneAgentArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateOneApplicationVariableArgs = {
|
||||
applicationId: Scalars['UUID'];
|
||||
key: Scalars['String'];
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateOneCronTriggerArgs = {
|
||||
input: UpdateCronTriggerInput;
|
||||
};
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_ONE_APPLICATION_VARIABLE = gql`
|
||||
mutation UpdateOneApplicationVariable(
|
||||
$key: String!
|
||||
$value: String!
|
||||
$applicationId: UUID!
|
||||
) {
|
||||
updateOneApplicationVariable(
|
||||
key: $key
|
||||
value: $value
|
||||
applicationId: $applicationId
|
||||
)
|
||||
}
|
||||
`;
|
||||
+8
@@ -11,6 +11,14 @@ export const APPLICATION_FRAGMENT = gql`
|
||||
id
|
||||
name
|
||||
description
|
||||
version
|
||||
applicationVariables {
|
||||
id
|
||||
key
|
||||
value
|
||||
description
|
||||
isSecret
|
||||
}
|
||||
agents {
|
||||
...AgentFields
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const FIND_MANY_APPLICATIONS = gql`
|
||||
id
|
||||
name
|
||||
description
|
||||
version
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+3
-6
@@ -1,6 +1,5 @@
|
||||
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
|
||||
import { type EditorProps, type Monaco } from '@monaco-editor/react';
|
||||
import dotenv from 'dotenv';
|
||||
import { type editor } from 'monaco-editor';
|
||||
import { AutoTypings } from 'monaco-editor-auto-typings';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -35,7 +34,6 @@ export const SettingsServerlessFunctionCodeEditor = ({
|
||||
});
|
||||
|
||||
const currentFile = files.find((file) => file.path === currentFilePath);
|
||||
const environmentVariablesFile = files.find((file) => file.path === '.env');
|
||||
|
||||
const handleEditorDidMount = async (
|
||||
editor: editor.IStandaloneCodeEditor,
|
||||
@@ -67,11 +65,10 @@ export const SettingsServerlessFunctionCodeEditor = ({
|
||||
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
||||
});
|
||||
|
||||
if (isDefined(environmentVariablesFile)) {
|
||||
const environmentVariables = dotenv.parse(
|
||||
environmentVariablesFile.content,
|
||||
);
|
||||
// TODO load that with proper env variables
|
||||
const environmentVariables = {};
|
||||
|
||||
if (isDefined(environmentVariables)) {
|
||||
const environmentDefinition = `
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ export const SettingsServerlessFunctionNewForm = ({
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title title="About" description="Name and set your function" />
|
||||
<H2Title title="About" description="Name and describe your function" />
|
||||
<StyledInputsContainer>
|
||||
<SettingsTextInput
|
||||
instanceId={nameTextInputId}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { LinkChip } from 'twenty-ui/components';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
export const SettingsServerlessFunctionTabEnvironmentVariablesSection = () => {
|
||||
const { applicationId = '' } = useParams<{ applicationId: string }>();
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Environment Variables`}
|
||||
description="Accessible in your function via process.env.KEY"
|
||||
/>
|
||||
Environment variables are defined at application level for all functions.
|
||||
Please check{' '}
|
||||
<LinkChip
|
||||
label={'application detail page'}
|
||||
to={getSettingsPath(
|
||||
SettingsPath.ApplicationDetail,
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
undefined,
|
||||
'settings',
|
||||
)}
|
||||
/>
|
||||
.
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+3
-5
@@ -44,11 +44,9 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
|
||||
|
||||
const HeaderTabList = (
|
||||
<StyledTabList
|
||||
tabs={files
|
||||
.filter((file) => file.path !== '.env')
|
||||
.map((file) => {
|
||||
return { id: file.path, title: file.path.split('/').at(-1) || '' };
|
||||
})}
|
||||
tabs={files.map((file) => {
|
||||
return { id: file.path, title: file.path.split('/').at(-1) || '' };
|
||||
})}
|
||||
componentInstanceId={SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID}
|
||||
/>
|
||||
);
|
||||
|
||||
+2
-9
@@ -1,17 +1,13 @@
|
||||
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
|
||||
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
|
||||
import { type ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
|
||||
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/SettingsServerlessFunctionTabEnvironmentVariablesSection';
|
||||
|
||||
export const SettingsServerlessFunctionSettingsTab = ({
|
||||
formValues,
|
||||
onChange,
|
||||
onCodeChange,
|
||||
serverlessFunctionId,
|
||||
}: {
|
||||
formValues: ServerlessFunctionFormValues;
|
||||
serverlessFunctionId: string;
|
||||
onChange: (key: string) => (value: string) => void;
|
||||
onCodeChange: (filePath: string, value: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -20,10 +16,7 @@ export const SettingsServerlessFunctionSettingsTab = ({
|
||||
onChange={onChange}
|
||||
readonly
|
||||
/>
|
||||
<SettingsServerlessFunctionTabEnvironmentVariablesSection
|
||||
onCodeChange={onCodeChange}
|
||||
serverlessFunctionId={serverlessFunctionId}
|
||||
/>
|
||||
<SettingsServerlessFunctionTabEnvironmentVariablesSection />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type EnvironmentVariable } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
|
||||
import { type EnvironmentVariable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
import { SettingsServerlessFunctionTabEnvironmentVariableTableRow } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariableTableRow';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import styled from '@emotion/styled';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { H2Title, IconPlus, IconSearch } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
|
||||
import { v4 } from 'uuid';
|
||||
import { serverlessFunctionEnvVarFamilyState } from '@/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
const StyledSearchInput = styled(SettingsTextInput)`
|
||||
padding-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: ${({ theme }) => theme.spacing(2)};
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
padding-top: ${({ theme }) => theme.spacing(5)};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTableBody = styled(TableBody)`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
`;
|
||||
|
||||
const StyledTableRow = styled(TableRow)`
|
||||
grid-template-columns: 180px auto 32px;
|
||||
`;
|
||||
|
||||
export type EnvironmentVariable = { id: string; key: string; value: string };
|
||||
|
||||
export const SettingsServerlessFunctionTabEnvironmentVariablesSection = ({
|
||||
onCodeChange,
|
||||
serverlessFunctionId,
|
||||
}: {
|
||||
serverlessFunctionId: string;
|
||||
onCodeChange: (filePath: string, value: string) => void;
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [newEnvVarAdded, setNewEnvVarAdded] = useState(false);
|
||||
const [envVariables, setEnvVariables] = useRecoilState(
|
||||
serverlessFunctionEnvVarFamilyState(serverlessFunctionId),
|
||||
);
|
||||
|
||||
const filteredEnvVariable = useMemo(() => {
|
||||
return envVariables.filter(
|
||||
({ key, value }) =>
|
||||
key.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
value.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}, [envVariables, searchTerm]);
|
||||
|
||||
const getFormattedEnvironmentVariables = (
|
||||
newEnvVariables: EnvironmentVariable[],
|
||||
) => {
|
||||
return [...newEnvVariables]
|
||||
.reverse()
|
||||
.reduce(
|
||||
(acc, { key, value }) =>
|
||||
key.length > 0 && value.length > 0 ? `${key}=${value}\n${acc}` : acc,
|
||||
'',
|
||||
);
|
||||
};
|
||||
|
||||
const onEnvVarChange = (newEnvVariable: EnvironmentVariable) => {
|
||||
const newEnvVariables: EnvironmentVariable[] = [];
|
||||
|
||||
for (const envVariable of envVariables) {
|
||||
if (envVariable.id === newEnvVariable.id) {
|
||||
newEnvVariables.push(newEnvVariable);
|
||||
} else if (envVariable.key !== newEnvVariable.key) {
|
||||
newEnvVariables.push(envVariable);
|
||||
}
|
||||
}
|
||||
|
||||
setEnvVariables(newEnvVariables);
|
||||
onCodeChange('.env', getFormattedEnvironmentVariables(newEnvVariables));
|
||||
};
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Environment variables"
|
||||
description="Set your function environment variables"
|
||||
/>
|
||||
<StyledSearchInput
|
||||
instanceId="serverless-function-env-var-search"
|
||||
LeftIcon={IconSearch}
|
||||
placeholder="Search a variable"
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
/>
|
||||
<Table>
|
||||
<StyledTableRow>
|
||||
<TableHeader>Name</TableHeader>
|
||||
<TableHeader>Value</TableHeader>
|
||||
<TableHeader></TableHeader>
|
||||
</StyledTableRow>
|
||||
{filteredEnvVariable.length > 0 && (
|
||||
<StyledTableBody>
|
||||
{filteredEnvVariable.map((envVariable) => (
|
||||
<SettingsServerlessFunctionTabEnvironmentVariableTableRow
|
||||
key={envVariable.id}
|
||||
envVariable={envVariable}
|
||||
initialEditMode={newEnvVarAdded && envVariable.value === ''}
|
||||
onChange={onEnvVarChange}
|
||||
onDelete={() => {
|
||||
const newEnvVariables = envVariables.filter(
|
||||
({ id }) => id !== envVariable.id,
|
||||
);
|
||||
setEnvVariables(newEnvVariables);
|
||||
onCodeChange(
|
||||
'.env',
|
||||
getFormattedEnvironmentVariables(newEnvVariables),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</StyledTableBody>
|
||||
)}
|
||||
</Table>
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
Icon={IconPlus}
|
||||
title="Add Variable"
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setEnvVariables((prevState) => {
|
||||
return [...prevState, { id: v4(), key: '', value: '' }];
|
||||
});
|
||||
setNewEnvVarAdded(true);
|
||||
}}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+1
-19
@@ -4,14 +4,11 @@ import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hoo
|
||||
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
|
||||
import { serverlessFunctionTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/code-action/states/serverlessFunctionTestDataFamilyState';
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { type FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
|
||||
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
|
||||
import { type ServerlessFunction } from '~/generated/graphql';
|
||||
import { type Sources } from '@/serverless-functions/types/sources.type';
|
||||
import { serverlessFunctionEnvVarFamilyState } from '@/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState';
|
||||
import dotenv from 'dotenv';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export type ServerlessFunctionNewFormValues = {
|
||||
name: string;
|
||||
@@ -44,10 +41,6 @@ export const useServerlessFunctionUpdateFormState = ({
|
||||
code: { src: { 'index.ts': '' } },
|
||||
});
|
||||
|
||||
const setEnvVar = useSetRecoilState(
|
||||
serverlessFunctionEnvVarFamilyState(serverlessFunctionId),
|
||||
);
|
||||
|
||||
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
|
||||
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
|
||||
|
||||
@@ -74,17 +67,6 @@ export const useServerlessFunctionUpdateFormState = ({
|
||||
...newState,
|
||||
}));
|
||||
|
||||
const environmentVariables =
|
||||
code?.['.env'] && typeof code?.['.env'] === 'string'
|
||||
? dotenv.parse(code['.env'])
|
||||
: {};
|
||||
|
||||
const environmentVariablesList = Object.entries(
|
||||
environmentVariables,
|
||||
).map(([key, value]) => ({ id: v4(), key, value }));
|
||||
|
||||
setEnvVar(environmentVariablesList);
|
||||
|
||||
if (serverlessFunctionTestData.shouldInitInput) {
|
||||
const sourceCode =
|
||||
data?.getServerlessFunctionSourceCode?.[SOURCE_FOLDER_NAME]?.[
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
|
||||
import { type EnvironmentVariable } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
|
||||
|
||||
export const serverlessFunctionEnvVarFamilyState = createFamilyState<
|
||||
EnvironmentVariable[],
|
||||
string
|
||||
>({
|
||||
key: 'serverlessFunctionEnvVarFamilyState',
|
||||
defaultValue: [],
|
||||
});
|
||||
+47
-73
@@ -5,58 +5,60 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useFindOneApplicationQuery } from '~/generated-metadata/graphql';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { IconInfoCircle, IconSettings, IconBroadcast } from 'twenty-ui/display';
|
||||
import { SettingsApplicationDetailSkeletonLoader } from '~/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader';
|
||||
import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
|
||||
import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable';
|
||||
import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectTable';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
|
||||
import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab';
|
||||
import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab';
|
||||
|
||||
const APPLICATION_DETAIL_ID = 'application-detail-id';
|
||||
|
||||
export const SettingsApplicationDetails = () => {
|
||||
const { applicationId = '' } = useParams<{ applicationId: string }>();
|
||||
|
||||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
||||
const activeTabId = useRecoilComponentValue(
|
||||
activeTabIdComponentState,
|
||||
APPLICATION_DETAIL_ID,
|
||||
);
|
||||
|
||||
const { data, loading } = useFindOneApplicationQuery({
|
||||
const { data } = useFindOneApplicationQuery({
|
||||
variables: { id: applicationId },
|
||||
skip: !applicationId,
|
||||
});
|
||||
|
||||
if (!isDefined(data?.findOneApplication)) {
|
||||
return;
|
||||
}
|
||||
const application = data?.findOneApplication;
|
||||
|
||||
const {
|
||||
name: applicationName,
|
||||
serverlessFunctions,
|
||||
agents,
|
||||
objects,
|
||||
} = data.findOneApplication;
|
||||
const applicationName = application?.name;
|
||||
|
||||
const shouldDisplayServerlessFunctions =
|
||||
!loading &&
|
||||
isDefined(serverlessFunctions) &&
|
||||
serverlessFunctions?.length > 0;
|
||||
|
||||
const shouldDisplayAgents =
|
||||
!loading && isDefined(agents) && agents.length > 0;
|
||||
|
||||
const shouldDisplayObjects =
|
||||
!loading && isDefined(objects) && objects.length > 0;
|
||||
|
||||
const objectIds = objects.map((object) => object.id);
|
||||
|
||||
const applicationObjectMetadataItems = shouldDisplayObjects
|
||||
? objectMetadataItems.filter((objectMetadataItem) =>
|
||||
objectIds.includes(objectMetadataItem.id),
|
||||
)
|
||||
: [];
|
||||
|
||||
const title = loading
|
||||
const title = !isDefined(application)
|
||||
? t`Application details`
|
||||
: data?.findOneApplication?.name;
|
||||
: applicationName;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'about', title: 'About', Icon: IconInfoCircle },
|
||||
{ id: 'settings', title: 'Settings', Icon: IconSettings },
|
||||
{ id: 'content', title: 'Content', Icon: IconBroadcast },
|
||||
];
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
switch (activeTabId) {
|
||||
case 'about':
|
||||
return <SettingsApplicationDetailAboutTab application={application} />;
|
||||
case 'settings':
|
||||
return (
|
||||
<SettingsApplicationDetailSettingsTab application={application} />
|
||||
);
|
||||
case 'content':
|
||||
return (
|
||||
<SettingsApplicationDetailContentTab application={application} />
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
@@ -70,43 +72,15 @@ export const SettingsApplicationDetails = () => {
|
||||
children: t`Applications`,
|
||||
href: getSettingsPath(SettingsPath.Applications),
|
||||
},
|
||||
{ children: `${applicationName}` },
|
||||
{ children: `${title}` },
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
{loading && <SettingsApplicationDetailSkeletonLoader />}
|
||||
{shouldDisplayServerlessFunctions && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Application serverless functions`}
|
||||
description={t`Serverless functions created by application`}
|
||||
/>
|
||||
<SettingsServerlessFunctionsTable
|
||||
serverlessFunctions={serverlessFunctions}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
{shouldDisplayAgents && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Application agents`}
|
||||
description={t`Agents created by application`}
|
||||
/>
|
||||
<SettingsAIAgentsTable agents={agents} withSearchBar={false} />
|
||||
</Section>
|
||||
)}
|
||||
{shouldDisplayObjects && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Application objects`}
|
||||
description={t`Objects created by application`}
|
||||
/>
|
||||
<SettingsObjectTable
|
||||
activeObjects={applicationObjectMetadataItems}
|
||||
inactiveObjects={[]}
|
||||
withSearchBar={false}
|
||||
/>
|
||||
</Section>
|
||||
<TabList tabs={tabs} componentInstanceId={APPLICATION_DETAIL_ID} />
|
||||
{!isDefined(application) ? (
|
||||
<SettingsApplicationDetailSkeletonLoader />
|
||||
) : (
|
||||
renderActiveTabContent()
|
||||
)}
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
|
||||
-31
@@ -16,20 +16,6 @@ const StyledFormSection = styled.div`
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledIconNameRow = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledNameContainer = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
export const SettingsApplicationDetailSkeletonLoader = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -41,21 +27,6 @@ export const SettingsApplicationDetailSkeletonLoader = () => {
|
||||
>
|
||||
<StyledSkeletonContainer>
|
||||
<StyledFormSection>
|
||||
<StyledIconNameRow>
|
||||
<StyledIconContainer>
|
||||
<Skeleton
|
||||
width={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
/>
|
||||
</StyledIconContainer>
|
||||
<StyledNameContainer>
|
||||
<Skeleton
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
width="100%"
|
||||
/>
|
||||
</StyledNameContainer>
|
||||
</StyledIconNameRow>
|
||||
|
||||
<Skeleton
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
width="100%"
|
||||
@@ -75,8 +46,6 @@ export const SettingsApplicationDetailSkeletonLoader = () => {
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
<Skeleton height={120} width="100%" />
|
||||
</StyledFormSection>
|
||||
</StyledSkeletonContainer>
|
||||
</SkeletonTheme>
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
|
||||
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconCircleDot, IconStatusChange } from 'twenty-ui/display';
|
||||
import type { Application } from '~/generated/graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const SettingsApplicationVersionContainer = ({
|
||||
application,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
|
||||
}) => {
|
||||
const loading = !isDefined(application);
|
||||
|
||||
const currentVersion = application?.version;
|
||||
|
||||
// TODO fetch latestVersion of the application
|
||||
// if published on twenty public application registry
|
||||
const latestVersion = currentVersion;
|
||||
|
||||
const versionItems = [
|
||||
{
|
||||
Icon: IconCircleDot,
|
||||
label: t`Current version`,
|
||||
value: (
|
||||
<SettingsAdminVersionDisplay
|
||||
version={currentVersion}
|
||||
loading={loading}
|
||||
noVersionMessage={t`Unknown`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
Icon: IconStatusChange,
|
||||
label: t`Latest version`,
|
||||
value: (
|
||||
<SettingsAdminVersionDisplay
|
||||
version={latestVersion}
|
||||
loading={loading}
|
||||
noVersionMessage={t`No latest version found`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsAdminTableCard
|
||||
rounded
|
||||
items={versionItems}
|
||||
gridAutoColumns="3fr 8fr"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { UPDATE_ONE_APPLICATION_VARIABLE } from '@/application-variables/graphql/mutations/updateOneApplicationVariable';
|
||||
import {
|
||||
type UpdateOneApplicationVariableMutation,
|
||||
type UpdateOneApplicationVariableMutationVariables,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const useUpdateOneApplicationVariable = () => {
|
||||
const apolloMetadataClient = useApolloCoreClient();
|
||||
const [mutate] = useMutation<
|
||||
UpdateOneApplicationVariableMutation,
|
||||
UpdateOneApplicationVariableMutationVariables
|
||||
>(UPDATE_ONE_APPLICATION_VARIABLE, { client: apolloMetadataClient });
|
||||
|
||||
const updateOneApplicationVariable = async ({
|
||||
key,
|
||||
value,
|
||||
applicationId,
|
||||
}: {
|
||||
key: string;
|
||||
value: string;
|
||||
applicationId: string;
|
||||
}) => {
|
||||
return await mutate({ variables: { key, value, applicationId } });
|
||||
};
|
||||
|
||||
return { updateOneApplicationVariable };
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import type { Application } from '~/generated/graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { SettingsApplicationVersionContainer } from '~/pages/settings/applications/components/SettingsApplicationVersionContainer';
|
||||
|
||||
export const SettingsApplicationDetailAboutTab = ({
|
||||
application,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
|
||||
}) => {
|
||||
if (!isDefined(application)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, name, description } = application;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title title={t`Name`} description={t`Name of the application`} />
|
||||
<SettingsTextInput
|
||||
instanceId={`application-name-${id}`}
|
||||
value={name}
|
||||
disabled
|
||||
fullWidth
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Description`}
|
||||
description={t`Description of the application`}
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId={`application-description-${id}`}
|
||||
value={description}
|
||||
disabled
|
||||
fullWidth
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Version`}
|
||||
description={t`Version of the application`}
|
||||
/>
|
||||
<SettingsApplicationVersionContainer application={application} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
|
||||
import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable';
|
||||
import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectTable';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type Application } from '~/generated/graphql';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
|
||||
export const SettingsApplicationDetailContentTab = ({
|
||||
application,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
|
||||
}) => {
|
||||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
||||
|
||||
if (!isDefined(application)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { serverlessFunctions, agents, objects } = application;
|
||||
|
||||
const shouldDisplayServerlessFunctions =
|
||||
isDefined(serverlessFunctions) && serverlessFunctions?.length > 0;
|
||||
|
||||
const shouldDisplayAgents = isDefined(agents) && agents.length > 0;
|
||||
|
||||
const shouldDisplayObjects = isDefined(objects) && objects.length > 0;
|
||||
|
||||
const applicationObjectMetadataItems = shouldDisplayObjects
|
||||
? objectMetadataItems.filter((objectMetadataItem) =>
|
||||
objects.map((object) => object.id).includes(objectMetadataItem.id),
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayServerlessFunctions && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Application serverless functions`}
|
||||
description={t`Serverless functions created by application`}
|
||||
/>
|
||||
<SettingsServerlessFunctionsTable
|
||||
serverlessFunctions={serverlessFunctions}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
{shouldDisplayAgents && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Application agents`}
|
||||
description={t`Agents created by application`}
|
||||
/>
|
||||
<SettingsAIAgentsTable agents={agents} withSearchBar={false} />
|
||||
</Section>
|
||||
)}
|
||||
{shouldDisplayObjects && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Application objects`}
|
||||
description={t`Objects created by application`}
|
||||
/>
|
||||
<SettingsObjectTable
|
||||
activeObjects={applicationObjectMetadataItems}
|
||||
inactiveObjects={[]}
|
||||
withSearchBar={false}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { H2Title, IconSearch } from 'twenty-ui/display';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import styled from '@emotion/styled';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import {
|
||||
type EnvironmentVariable,
|
||||
SettingsApplicationDetailEnvironmentVariablesTableRow,
|
||||
StyledApplicationEnvironmentVariableTableRow,
|
||||
} from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow';
|
||||
|
||||
const StyledSearchInput = styled(SettingsTextInput)`
|
||||
padding-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledTableBody = styled(TableBody)`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
`;
|
||||
|
||||
export const SettingsApplicationDetailEnvironmentVariablesTable = ({
|
||||
envVariables,
|
||||
onUpdate,
|
||||
readonly,
|
||||
}: {
|
||||
envVariables: EnvironmentVariable[];
|
||||
onUpdate: (newEnv: Pick<EnvironmentVariable, 'key' | 'value'>) => void;
|
||||
readonly?: boolean;
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const filteredEnvVariable = useMemo(() => {
|
||||
return envVariables.filter(
|
||||
({ key, value }) =>
|
||||
key.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
value.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}, [envVariables, searchTerm]);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Configuration"
|
||||
description="Set your application configuration variables"
|
||||
/>
|
||||
<StyledSearchInput
|
||||
instanceId="env-var-search"
|
||||
LeftIcon={IconSearch}
|
||||
placeholder="Search a variable"
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
/>
|
||||
<Table>
|
||||
<StyledApplicationEnvironmentVariableTableRow>
|
||||
<TableHeader>Name</TableHeader>
|
||||
<TableHeader>Value</TableHeader>
|
||||
<TableHeader>Info</TableHeader>
|
||||
<TableHeader></TableHeader>
|
||||
</StyledApplicationEnvironmentVariableTableRow>
|
||||
{filteredEnvVariable.length > 0 && (
|
||||
<StyledTableBody>
|
||||
{filteredEnvVariable.map((envVariable) => (
|
||||
<SettingsApplicationDetailEnvironmentVariablesTableRow
|
||||
key={envVariable.id}
|
||||
envVariable={envVariable}
|
||||
onChange={onUpdate}
|
||||
readonly={readonly}
|
||||
/>
|
||||
))}
|
||||
</StyledTableBody>
|
||||
)}
|
||||
</Table>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AppTooltip,
|
||||
IconCheck,
|
||||
IconDotsVertical,
|
||||
IconInfoCircle,
|
||||
IconPencil,
|
||||
OverflowingTextWithTooltip,
|
||||
TooltipDelay,
|
||||
} from 'twenty-ui/display';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type ApplicationVariable } from '~/generated/graphql';
|
||||
|
||||
export const StyledApplicationEnvironmentVariableTableRow = styled(TableRow)`
|
||||
grid-template-columns: auto 200px 36px 36px;
|
||||
`;
|
||||
|
||||
export type EnvironmentVariable = ApplicationVariable;
|
||||
|
||||
export const SettingsApplicationDetailEnvironmentVariablesTableRow = ({
|
||||
envVariable,
|
||||
onChange,
|
||||
readonly,
|
||||
}: {
|
||||
envVariable: EnvironmentVariable;
|
||||
onChange: (
|
||||
newEnvVariable: Pick<EnvironmentVariable, 'key' | 'value'>,
|
||||
) => void;
|
||||
readonly?: boolean;
|
||||
}) => {
|
||||
const [editedEnvVariable, setEditedEnvVariable] = useState(envVariable);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const dropDownId = `settings-environment-variable-dropdown-${envVariable.key}`;
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
const theme = useTheme();
|
||||
const { t } = useLingui();
|
||||
|
||||
const description =
|
||||
envVariable.description.length > 0
|
||||
? envVariable.description
|
||||
: t`No description`;
|
||||
|
||||
const InfoTableCell = (
|
||||
<TableCell>
|
||||
<IconInfoCircle
|
||||
id={`info-circle-id-description-${envVariable.key}`}
|
||||
size={theme.icon.size.md}
|
||||
color={theme.font.color.tertiary}
|
||||
style={{ outline: 'none' }}
|
||||
/>
|
||||
<AppTooltip
|
||||
anchorSelect={`#info-circle-id-description-${envVariable.key}`}
|
||||
content={description}
|
||||
offset={5}
|
||||
noArrow
|
||||
place="bottom"
|
||||
positionStrategy="fixed"
|
||||
delay={TooltipDelay.shortDelay}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
|
||||
return editMode && !readonly ? (
|
||||
<StyledApplicationEnvironmentVariableTableRow>
|
||||
<TableCell>
|
||||
<OverflowingTextWithTooltip text={envVariable.key} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextInput
|
||||
value={editedEnvVariable.value}
|
||||
onChange={(newValue) =>
|
||||
setEditedEnvVariable({ ...editedEnvVariable, value: newValue })
|
||||
}
|
||||
placeholder={t`Value`}
|
||||
fullWidth
|
||||
/>
|
||||
</TableCell>
|
||||
{InfoTableCell}
|
||||
<TableCell>
|
||||
<LightIconButton
|
||||
accent="tertiary"
|
||||
Icon={IconCheck}
|
||||
disabled={editedEnvVariable.value === ''}
|
||||
onClick={() => {
|
||||
onChange(editedEnvVariable);
|
||||
setEditMode(false);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</StyledApplicationEnvironmentVariableTableRow>
|
||||
) : (
|
||||
<StyledApplicationEnvironmentVariableTableRow
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<TableCell>
|
||||
<OverflowingTextWithTooltip text={envVariable.key} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OverflowingTextWithTooltip text={editedEnvVariable.value} />
|
||||
</TableCell>
|
||||
{InfoTableCell}
|
||||
<TableCell>
|
||||
{!readonly && (
|
||||
<Dropdown
|
||||
dropdownId={dropDownId}
|
||||
clickableComponent={
|
||||
<LightIconButton
|
||||
aria-label="Env Variable Options"
|
||||
Icon={IconDotsVertical}
|
||||
accent="tertiary"
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
text={t`Edit`}
|
||||
LeftIcon={IconPencil}
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
closeDropdown(dropDownId);
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</StyledApplicationEnvironmentVariableTableRow>
|
||||
);
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import type { Application } from '~/generated/graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable';
|
||||
import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable';
|
||||
|
||||
export const SettingsApplicationDetailSettingsTab = ({
|
||||
application,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
|
||||
}) => {
|
||||
const { updateOneApplicationVariable } = useUpdateOneApplicationVariable();
|
||||
|
||||
if (!isDefined(application)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envVariables = [...(application.applicationVariables ?? [])].sort(
|
||||
(a, b) => a.key.localeCompare(b.key),
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsApplicationDetailEnvironmentVariablesTable
|
||||
envVariables={envVariables}
|
||||
onUpdate={({ key, value }) =>
|
||||
updateOneApplicationVariable({
|
||||
key,
|
||||
value,
|
||||
applicationId: application.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+15
-22
@@ -9,8 +9,7 @@ import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import {
|
||||
@@ -24,12 +23,15 @@ import { t } from '@lingui/core/macro';
|
||||
import { useFindOneApplicationQuery } from '~/generated-metadata/graphql';
|
||||
import { computeNewSources } from '@/serverless-functions/utils/computeNewSources';
|
||||
import { flattenSources } from '@/serverless-functions/utils/flattenSources';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
|
||||
const SERVERLESS_FUNCTION_DETAIL_ID = 'serverless-function-detail';
|
||||
|
||||
export const SettingsServerlessFunctionDetail = () => {
|
||||
const { serverlessFunctionId = '', applicationId = '' } = useParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data } = useFindOneApplicationQuery({
|
||||
variables: { id: applicationId },
|
||||
skip: !applicationId,
|
||||
@@ -37,9 +39,11 @@ export const SettingsServerlessFunctionDetail = () => {
|
||||
|
||||
const applicationName = data?.findOneApplication?.name;
|
||||
|
||||
const [activeTabId, setActiveTabId] = useRecoilComponentState(
|
||||
const instanceId = `${SERVERLESS_FUNCTION_DETAIL_ID}-${serverlessFunctionId}`;
|
||||
|
||||
const activeTabId = useRecoilComponentValue(
|
||||
activeTabIdComponentState,
|
||||
SERVERLESS_FUNCTION_DETAIL_ID,
|
||||
instanceId,
|
||||
);
|
||||
const { updateOneServerlessFunction } =
|
||||
useUpdateOneServerlessFunction(serverlessFunctionId);
|
||||
@@ -84,8 +88,8 @@ export const SettingsServerlessFunctionDetail = () => {
|
||||
};
|
||||
|
||||
const handleTestFunction = async () => {
|
||||
navigate('#test');
|
||||
await testServerlessFunction();
|
||||
setActiveTabId('test');
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
@@ -97,17 +101,11 @@ export const SettingsServerlessFunctionDetail = () => {
|
||||
|
||||
const flattenedCode = flattenSources(formValues.code);
|
||||
|
||||
const files = flattenedCode
|
||||
.map((file) => {
|
||||
const language = file.path === '.env' ? 'ini' : 'typescript';
|
||||
|
||||
return {
|
||||
path: file.path,
|
||||
language,
|
||||
content: file.content,
|
||||
};
|
||||
})
|
||||
.reverse();
|
||||
const files = flattenedCode.map((file) => ({
|
||||
path: file.path,
|
||||
language: 'typescript',
|
||||
content: file.content,
|
||||
}));
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
switch (activeTabId) {
|
||||
@@ -138,9 +136,7 @@ export const SettingsServerlessFunctionDetail = () => {
|
||||
return (
|
||||
<SettingsServerlessFunctionSettingsTab
|
||||
formValues={formValues}
|
||||
serverlessFunctionId={serverlessFunctionId}
|
||||
onChange={onChange}
|
||||
onCodeChange={onCodeChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@@ -171,10 +167,7 @@ export const SettingsServerlessFunctionDetail = () => {
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<TabList
|
||||
tabs={tabs}
|
||||
componentInstanceId={SERVERLESS_FUNCTION_DETAIL_ID}
|
||||
/>
|
||||
<TabList tabs={tabs} componentInstanceId={instanceId} />
|
||||
{renderActiveTabContent()}
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddApplicationVariableCoreEntity1760640844181
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddApplicationVariableCoreEntity1760640844181';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."applicationVariable" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" text NOT NULL, "value" text NOT NULL DEFAULT '', "description" text NOT NULL DEFAULT '', "isSecret" boolean NOT NULL DEFAULT false, "applicationId" uuid, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE" UNIQUE ("key", "applicationId"), CONSTRAINT "PK_62f7823eb5f1e416c9d60614dfb" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationVariable" ADD CONSTRAINT "FK_51adb49e7f8df35dd23e01c4830" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationVariable" DROP CONSTRAINT "FK_51adb49e7f8df35dd23e01c4830"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "core"."applicationVariable"`);
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -28,6 +28,7 @@ import { FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/type
|
||||
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
|
||||
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
|
||||
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationSyncService {
|
||||
@@ -35,6 +36,7 @@ export class ApplicationSyncService {
|
||||
|
||||
constructor(
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationVariableService: ApplicationVariableService,
|
||||
private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService,
|
||||
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
|
||||
private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service,
|
||||
@@ -106,7 +108,7 @@ export class ApplicationSyncService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return await this.applicationService.create({
|
||||
const application = await this.applicationService.create({
|
||||
universalIdentifier: manifest.universalIdentifier,
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
@@ -115,6 +117,13 @@ export class ApplicationSyncService {
|
||||
serverlessFunctionLayerId: serverlessFunctionLayer.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.applicationVariableService.upsertManyApplicationVariables({
|
||||
env: manifest.env,
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
await this.serverlessFunctionLayerService.update(
|
||||
@@ -131,6 +140,11 @@ export class ApplicationSyncService {
|
||||
version: manifest.version,
|
||||
});
|
||||
|
||||
await this.applicationVariableService.upsertManyApplicationVariables({
|
||||
env: manifest.env,
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
|
||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
|
||||
@Entity({ name: 'application', schema: 'core' })
|
||||
@Index('IDX_APPLICATION_WORKSPACE_ID', ['workspaceId'])
|
||||
@@ -86,6 +87,15 @@ export class ApplicationEntity {
|
||||
})
|
||||
objects: Relation<ObjectMetadataEntity[]>;
|
||||
|
||||
@OneToMany(
|
||||
() => ApplicationVariable,
|
||||
(applicationVariable) => applicationVariable.application,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
)
|
||||
applicationVariables: Relation<ApplicationVariable[]>;
|
||||
|
||||
@ManyToOne(() => Workspace, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
|
||||
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
|
||||
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
|
||||
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
|
||||
import { ApplicationVariableModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,6 +25,7 @@ import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless
|
||||
ObjectMetadataModule,
|
||||
DataSourceModule,
|
||||
AgentModule,
|
||||
ApplicationVariableModule,
|
||||
ServerlessFunctionLayerModule,
|
||||
ServerlessFunctionModule,
|
||||
DatabaseEventTriggerModule,
|
||||
|
||||
@@ -25,7 +25,12 @@ export class ApplicationService {
|
||||
): Promise<ApplicationEntity[]> {
|
||||
return this.applicationRepository.find({
|
||||
where: { workspaceId },
|
||||
relations: ['serverlessFunctions', 'agents', 'objects'],
|
||||
relations: [
|
||||
'serverlessFunctions',
|
||||
'agents',
|
||||
'objects',
|
||||
'applicationVariables',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +40,12 @@ export class ApplicationService {
|
||||
): Promise<ApplicationEntity> {
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { workspaceId, id: applicationId },
|
||||
relations: ['serverlessFunctions', 'agents', 'objects'],
|
||||
relations: [
|
||||
'serverlessFunctions',
|
||||
'agents',
|
||||
'objects',
|
||||
'applicationVariables',
|
||||
],
|
||||
});
|
||||
|
||||
if (!isDefined(application)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
|
||||
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
|
||||
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
import { ApplicationVariableDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
|
||||
|
||||
@ObjectType('Application')
|
||||
export class ApplicationDTO {
|
||||
@@ -22,6 +23,10 @@ export class ApplicationDTO {
|
||||
@Field()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
version: string;
|
||||
|
||||
@Field(() => [AgentDTO])
|
||||
agents: AgentDTO[];
|
||||
|
||||
@@ -30,4 +35,7 @@ export class ApplicationDTO {
|
||||
|
||||
@Field(() => [ObjectMetadataDTO])
|
||||
objects: ObjectMetadataDTO[];
|
||||
|
||||
@Field(() => [ApplicationVariableDTO])
|
||||
applicationVariables: ApplicationVariableDTO[];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ export type PackageJson = {
|
||||
npm: string;
|
||||
yarn: string;
|
||||
};
|
||||
env: Record<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret: boolean;
|
||||
}
|
||||
>;
|
||||
icon?: string;
|
||||
version: string;
|
||||
dependencies?: object;
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Catch, ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
ApplicationVariableException,
|
||||
ApplicationVariableExceptionCode,
|
||||
} from 'src/engine/core-modules/applicationVariable/application-variable.exception';
|
||||
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(ApplicationVariableException)
|
||||
export class ApplicationVariableExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: ApplicationVariableException) {
|
||||
switch (exception.code) {
|
||||
case ApplicationVariableExceptionCode.APPLICATION_VARIABLE_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
default:
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
|
||||
@Entity({
|
||||
name: 'applicationVariable',
|
||||
schema: 'core',
|
||||
})
|
||||
@ObjectType()
|
||||
@Unique('IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE', [
|
||||
'key',
|
||||
'applicationId',
|
||||
])
|
||||
export class ApplicationVariable {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
key: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text', default: '' })
|
||||
value: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text', default: '' })
|
||||
description: string;
|
||||
|
||||
@Column({ nullable: false, type: 'boolean', default: false })
|
||||
isSecret: boolean;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
applicationId?: string;
|
||||
|
||||
@ManyToOne(
|
||||
() => ApplicationEntity,
|
||||
(application) => application.applicationVariables,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: Relation<ApplicationEntity> | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ApplicationVariableException extends CustomException<ApplicationVariableExceptionCode> {}
|
||||
|
||||
export enum ApplicationVariableExceptionCode {
|
||||
APPLICATION_VARIABLE_NOT_FOUND = 'APPLICATION_VARIABLE_NOT_FOUND',
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { ApplicationVariableResolver } from 'src/engine/core-modules/applicationVariable/application-variable.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [NestjsQueryTypeOrmModule.forFeature([ApplicationVariable])],
|
||||
providers: [ApplicationVariableService, ApplicationVariableResolver],
|
||||
exports: [ApplicationVariableService],
|
||||
})
|
||||
export class ApplicationVariableModule {}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { UpdateApplicationVariableInput } from 'src/engine/core-modules/applicationVariable/dtos/update-application-variable.input';
|
||||
import { ApplicationVariableExceptionFilter } from 'src/engine/core-modules/applicationVariable/application-variable-exception-filter';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver()
|
||||
@UseFilters(ApplicationVariableExceptionFilter)
|
||||
export class ApplicationVariableResolver {
|
||||
constructor(
|
||||
private readonly applicationVariableService: ApplicationVariableService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async updateOneApplicationVariable(
|
||||
@Args() { key, value, applicationId }: UpdateApplicationVariableInput,
|
||||
) {
|
||||
await this.applicationVariableService.update({ key, value, applicationId });
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
|
||||
export class ApplicationVariableService {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationVariable)
|
||||
private readonly applicationVariableRepository: Repository<ApplicationVariable>,
|
||||
) {}
|
||||
|
||||
async update({
|
||||
key,
|
||||
value,
|
||||
applicationId,
|
||||
}: Pick<ApplicationVariable, 'key' | 'value'> & { applicationId: string }) {
|
||||
await this.applicationVariableRepository.update(
|
||||
{ key, applicationId },
|
||||
{
|
||||
value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async upsertManyApplicationVariables({
|
||||
env,
|
||||
applicationId,
|
||||
}: {
|
||||
env: Record<
|
||||
string,
|
||||
{
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret: boolean;
|
||||
}
|
||||
>;
|
||||
applicationId: string;
|
||||
}) {
|
||||
for (const [key, { value, description, isSecret }] of Object.entries(env)) {
|
||||
await this.applicationVariableRepository.upsert(
|
||||
{
|
||||
key,
|
||||
value,
|
||||
description,
|
||||
isSecret,
|
||||
applicationId,
|
||||
},
|
||||
{ conflictPaths: ['key', 'applicationId'] },
|
||||
);
|
||||
}
|
||||
|
||||
await this.applicationVariableRepository.delete({
|
||||
applicationId,
|
||||
key: Not(In(Object.keys(env))),
|
||||
});
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('ApplicationVariable')
|
||||
export class ApplicationVariableDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
key: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
value: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
description: string;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
isSecret: boolean;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ArgsType()
|
||||
export class UpdateApplicationVariableInput {
|
||||
@Field(() => String, { nullable: false })
|
||||
key: string;
|
||||
|
||||
@Field(() => String, { nullable: false })
|
||||
value: string;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
applicationId: string;
|
||||
}
|
||||
+5
-2
@@ -6,17 +6,20 @@ export const handler = async (event) => {
|
||||
|
||||
const mainPath = `/tmp/${randomId}.mjs`;
|
||||
|
||||
const oldProcessEnv = { ...process.env };
|
||||
|
||||
try {
|
||||
const { code, params } = event;
|
||||
const { code, params, env } = event;
|
||||
|
||||
await fs.writeFile(mainPath, code, 'utf8');
|
||||
|
||||
process.env = {};
|
||||
process.env = { ...process.env, ...(env ?? {}) };
|
||||
|
||||
const mainFile = await import(mainPath);
|
||||
|
||||
return await mainFile.main(params);
|
||||
} finally {
|
||||
await fs.rm(mainPath, { force: true });
|
||||
process.env = oldProcessEnv;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
|
||||
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
|
||||
import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
|
||||
|
||||
const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60;
|
||||
const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h
|
||||
@@ -338,6 +339,7 @@ export class LambdaDriver implements ServerlessDriver {
|
||||
const executorPayload = {
|
||||
params: payload,
|
||||
code: compiledCode,
|
||||
env: buildEnvVar(serverlessFunction),
|
||||
};
|
||||
|
||||
const params: InvokeCommandInput = {
|
||||
|
||||
+179
-15
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import {
|
||||
type ServerlessDriver,
|
||||
@@ -16,6 +17,7 @@ import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serve
|
||||
import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/serverless/drivers/utils/lambda-build-directory-manager';
|
||||
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
|
||||
import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
|
||||
|
||||
export interface LocalDriverOptions {
|
||||
fileStorageService: FileStorageService;
|
||||
@@ -162,30 +164,53 @@ export class LocalDriver implements ServerlessDriver {
|
||||
});
|
||||
|
||||
try {
|
||||
const mainFile = await import(builtBundleFilePath);
|
||||
|
||||
const result = await this.executeWithTimeout<object | null>(
|
||||
() => mainFile.main(payload),
|
||||
serverlessFunction.timeoutSeconds * 1_000,
|
||||
const runnerPath = await this.writeBootstrapRunner(
|
||||
sourceTemporaryDir,
|
||||
builtBundleFilePath,
|
||||
);
|
||||
|
||||
const { ok, result, error, stack, stdout, stderr } =
|
||||
await this.runChildWithEnv({
|
||||
runnerPath,
|
||||
env: buildEnvVar(serverlessFunction),
|
||||
payload,
|
||||
timeoutMs: serverlessFunction.timeoutSeconds * 1_000,
|
||||
});
|
||||
|
||||
if (stdout)
|
||||
logs +=
|
||||
stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((l) => `${new Date().toISOString()} INFO ${l}`)
|
||||
.join('\n') + '\n';
|
||||
if (stderr)
|
||||
logs +=
|
||||
stderr
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((l) => `${new Date().toISOString()} ERROR ${l}`)
|
||||
.join('\n') + '\n';
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
data: result,
|
||||
logs,
|
||||
duration,
|
||||
status: ServerlessFunctionExecutionStatus.SUCCESS,
|
||||
};
|
||||
} catch (error) {
|
||||
if (ok) {
|
||||
return {
|
||||
data: (result ?? null) as object | null,
|
||||
logs,
|
||||
duration,
|
||||
status: ServerlessFunctionExecutionStatus.SUCCESS,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: null,
|
||||
logs,
|
||||
duration: Date.now() - startTime,
|
||||
duration,
|
||||
error: {
|
||||
errorType: 'UnhandledError',
|
||||
errorMessage: error.message || 'Unknown error',
|
||||
stackTrace: error.stack ? error.stack.split('\n') : [],
|
||||
errorMessage: error || 'Unknown error',
|
||||
stackTrace: stack ? String(stack).split('\n') : [],
|
||||
},
|
||||
status: ServerlessFunctionExecutionStatus.ERROR,
|
||||
};
|
||||
@@ -196,4 +221,143 @@ export class LocalDriver implements ServerlessDriver {
|
||||
await lambdaBuildDirectoryManager.clean();
|
||||
}
|
||||
}
|
||||
|
||||
async writeBootstrapRunner(dir: string, builtFileAbsPath: string) {
|
||||
const runnerPath = join(dir, '__runner.cjs');
|
||||
const code = `
|
||||
// Auto-generated. Do not edit.
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const builtUrl = pathToFileURL(${JSON.stringify(builtFileAbsPath)});
|
||||
const mod = await import(builtUrl.href);
|
||||
if (typeof mod.main !== 'function') {
|
||||
throw new Error('Export "main" not found in serverless bundle');
|
||||
}
|
||||
|
||||
let payload = undefined;
|
||||
if (process.send) {
|
||||
process.on('message', async (msg) => {
|
||||
if (!msg || msg.type !== 'run') return;
|
||||
try {
|
||||
const out = await mod.main(msg.payload);
|
||||
process.send && process.send({ ok: true, result: out });
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.send && process.send({ ok: false, error: String(err), stack: err?.stack });
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: read payload from argv[2] (JSON) and print to stdout
|
||||
const json = process.argv[2];
|
||||
payload = json ? JSON.parse(json) : undefined;
|
||||
const out = await mod.main(payload);
|
||||
console.log(JSON.stringify({ ok: true, result: out }));
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (process.send) {
|
||||
process.send({ ok: false, error: msg, stack: err?.stack });
|
||||
} else {
|
||||
console.error(msg);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
await fs.writeFile(runnerPath, code, 'utf8');
|
||||
|
||||
return runnerPath;
|
||||
}
|
||||
|
||||
runChildWithEnv(options: {
|
||||
runnerPath: string;
|
||||
env: Record<string, string>;
|
||||
payload: unknown;
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
const { runnerPath, env, payload, timeoutMs } = options;
|
||||
|
||||
return new Promise<{
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
stack?: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>((resolve, _) => {
|
||||
const child = spawn(process.execPath, [runnerPath], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
child.stdout?.on('data', (d) => (stdout += String(d)));
|
||||
child.stderr?.on('data', (d) => (stderr += String(d)));
|
||||
|
||||
child.on(
|
||||
'message',
|
||||
(
|
||||
msg:
|
||||
| {
|
||||
ok: true;
|
||||
result?: unknown;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
stack?: string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
},
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ ...msg, stdout, stderr });
|
||||
},
|
||||
);
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (code === 0) {
|
||||
// Fallback path if no IPC (shouldn’t happen with our stdio)
|
||||
resolve({ ok: true, stdout, stderr });
|
||||
} else {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `Exited with code ${code}`,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const t = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGKILL');
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `Timed out after ${timeoutMs}ms`,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
// Kick it off
|
||||
child.send?.({ type: 'run', payload });
|
||||
|
||||
child.on('close', () => clearTimeout(t));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
|
||||
export const buildEnvVar = (serverlessFunction: ServerlessFunctionEntity) => {
|
||||
return (serverlessFunction.application?.applicationVariables ?? []).reduce(
|
||||
(acc, v) => {
|
||||
acc[v.key] = String(v.value ?? '');
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
};
|
||||
+4
-1
@@ -96,7 +96,10 @@ export class ServerlessFunctionService {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
relations: ['serverlessFunctionLayer'],
|
||||
relations: [
|
||||
'serverlessFunctionLayer',
|
||||
'application.applicationVariables',
|
||||
],
|
||||
});
|
||||
|
||||
const resultServerlessFunction = await this.serverlessService.execute(
|
||||
|
||||
-1
@@ -4,5 +4,4 @@ export type ServerlessFunctionCode = {
|
||||
src: {
|
||||
'index.ts': string;
|
||||
} & Sources;
|
||||
'.env'?: string;
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ export {
|
||||
IconBrandLinkedin,
|
||||
IconBrandX,
|
||||
IconBriefcase,
|
||||
IconBroadcast,
|
||||
IconBrowserMaximize,
|
||||
IconBuildingSkyscraper,
|
||||
IconCalendar,
|
||||
|
||||
@@ -107,6 +107,7 @@ export {
|
||||
IconBrandLinkedin,
|
||||
IconBrandX,
|
||||
IconBriefcase,
|
||||
IconBroadcast,
|
||||
IconBrowserMaximize,
|
||||
IconBuildingSkyscraper,
|
||||
IconCalendar,
|
||||
|
||||
Reference in New Issue
Block a user