Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d62f50f5e | ||
|
|
53c314d0fa | ||
|
|
618df704e6 | ||
|
|
058489b5cc | ||
|
|
3bd431e95d | ||
|
|
f4a61f26c0 | ||
|
|
8e6b267ff3 | ||
|
|
88146c2170 | ||
|
|
3706da9bcb | ||
|
|
9bac8f15d4 | ||
|
|
7332379d26 | ||
|
|
2455c859b4 | ||
|
|
e3753bf822 |
@@ -54,6 +54,9 @@ yarn twenty function:logs
|
||||
# Execute a function with a JSON payload
|
||||
yarn twenty function:execute -n my-function -p '{"key": "value"}'
|
||||
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
```
|
||||
@@ -63,6 +66,7 @@ yarn twenty app:uninstall
|
||||
- `application-config.ts` - Application metadata configuration
|
||||
- `roles/default-role.ts` - Default role for logic functions
|
||||
- `logic-functions/hello-world.ts` - Example logic function with HTTP trigger
|
||||
- `logic-functions/post-install.ts` - Post-install logic function (runs after app installation)
|
||||
- `front-components/hello-world.tsx` - Example front component
|
||||
- TypeScript configuration
|
||||
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.6.0-alpha",
|
||||
"version": "0.6.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -49,6 +49,12 @@ export const copyBaseApplicationProject = async ({
|
||||
fileName: 'hello-world.ts',
|
||||
});
|
||||
|
||||
await createDefaultPostInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'post-install.ts',
|
||||
});
|
||||
|
||||
await createApplicationConfig({
|
||||
displayName: appDisplayName,
|
||||
description: appDescription,
|
||||
@@ -196,7 +202,6 @@ const handler = async (): Promise<{ message: string }> => {
|
||||
return { message: 'Hello, World!' };
|
||||
};
|
||||
|
||||
// Logic function handler - rename and implement your logic
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'hello-world-logic-function',
|
||||
@@ -215,6 +220,38 @@ export default defineLogicFunction({
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultPostInstallFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createApplicationConfig = async ({
|
||||
displayName,
|
||||
description,
|
||||
@@ -230,12 +267,14 @@ const createApplicationConfig = async ({
|
||||
}) => {
|
||||
const content = `import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '${v4()}',
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
`;
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ yarn twenty function:logs
|
||||
# Execute a function by name
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
|
||||
@@ -69,7 +72,7 @@ When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder:
|
||||
- Copies a minimal base application into `my-twenty-app/`
|
||||
- Adds a local `twenty-sdk` dependency and Yarn 4 configuration
|
||||
- Creates config files and scripts wired to the `twenty` CLI
|
||||
- Generates a default application config and a default function role
|
||||
- Generates a default application config, a default function role, and a post-install function
|
||||
|
||||
A freshly scaffolded app looks like this:
|
||||
|
||||
@@ -91,7 +94,8 @@ my-twenty-app/
|
||||
├── roles/
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Example logic function
|
||||
│ ├── hello-world.ts # Example logic function
|
||||
│ └── post-install.ts # Post-install logic function
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Example front component
|
||||
```
|
||||
@@ -289,6 +293,7 @@ Every app has a single `application-config.ts` file that describes:
|
||||
- **Who the app is**: identifiers, display name, and description.
|
||||
- **How its functions run**: which role they use for permissions.
|
||||
- **(Optional) variables**: key–value pairs exposed to your functions as environment variables.
|
||||
- **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||||
|
||||
Use `defineApplication()` to define your application configuration:
|
||||
|
||||
@@ -296,6 +301,7 @@ Use `defineApplication()` to define your application configuration:
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -311,6 +317,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -318,6 +325,7 @@ Notes:
|
||||
- `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs.
|
||||
- `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
- `defaultRoleUniversalIdentifier` must match the role file (see below).
|
||||
- `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
|
||||
#### Roles and permissions
|
||||
|
||||
@@ -450,6 +458,54 @@ Notes:
|
||||
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
|
||||
- You can mix multiple trigger types in a single function.
|
||||
|
||||
### Post-install functions
|
||||
|
||||
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
- The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Route trigger payload
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -1448,13 +1448,8 @@ export enum EventLogTable {
|
||||
export type EventSubscription = {
|
||||
__typename?: 'EventSubscription';
|
||||
eventStreamId: Scalars['String'];
|
||||
eventWithQueryIdsList: Array<EventWithQueryIds>;
|
||||
};
|
||||
|
||||
export type EventWithQueryIds = {
|
||||
__typename?: 'EventWithQueryIds';
|
||||
event: ObjectRecordEvent;
|
||||
queryIds: Array<Scalars['String']>;
|
||||
metadataEventsWithQueryIds: Array<MetadataEventWithQueryIds>;
|
||||
objectRecordEventsWithQueryIds: Array<ObjectRecordEventWithQueryIds>;
|
||||
};
|
||||
|
||||
export type ExecuteOneLogicFunctionInput = {
|
||||
@@ -1485,6 +1480,7 @@ export enum FeatureFlagKey {
|
||||
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
|
||||
IS_CORE_PICTURE_MIGRATED = 'IS_CORE_PICTURE_MIGRATED',
|
||||
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
|
||||
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED',
|
||||
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
|
||||
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
|
||||
IS_FILES_FIELD_MIGRATED = 'IS_FILES_FIELD_MIGRATED',
|
||||
@@ -2138,6 +2134,27 @@ export type MarketplaceAppRoleObjectPermission = {
|
||||
objectUniversalIdentifier: Scalars['String'];
|
||||
};
|
||||
|
||||
export type MetadataEvent = {
|
||||
__typename?: 'MetadataEvent';
|
||||
metadataName: Scalars['String'];
|
||||
properties: ObjectRecordEventProperties;
|
||||
recordId: Scalars['String'];
|
||||
type: MetadataEventAction;
|
||||
};
|
||||
|
||||
/** Metadata Event Action */
|
||||
export enum MetadataEventAction {
|
||||
CREATED = 'CREATED',
|
||||
DELETED = 'DELETED',
|
||||
UPDATED = 'UPDATED'
|
||||
}
|
||||
|
||||
export type MetadataEventWithQueryIds = {
|
||||
__typename?: 'MetadataEventWithQueryIds';
|
||||
metadataEvent: MetadataEvent;
|
||||
queryIds: Array<Scalars['String']>;
|
||||
};
|
||||
|
||||
export enum ModelProvider {
|
||||
ANTHROPIC = 'ANTHROPIC',
|
||||
GROQ = 'GROQ',
|
||||
@@ -3379,6 +3396,12 @@ export type ObjectRecordEventProperties = {
|
||||
updatedFields?: Maybe<Array<Scalars['String']>>;
|
||||
};
|
||||
|
||||
export type ObjectRecordEventWithQueryIds = {
|
||||
__typename?: 'ObjectRecordEventWithQueryIds';
|
||||
objectRecordEvent: ObjectRecordEvent;
|
||||
queryIds: Array<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, WEEK, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR) */
|
||||
export enum ObjectRecordGroupByDateGranularity {
|
||||
DAY = 'DAY',
|
||||
@@ -5781,7 +5804,7 @@ export type FindOneFrontComponentQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null };
|
||||
export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, builtComponentChecksum: string, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null };
|
||||
|
||||
export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string };
|
||||
|
||||
@@ -10569,6 +10592,7 @@ export const FindOneFrontComponentDocument = gql`
|
||||
id
|
||||
name
|
||||
applicationId
|
||||
builtComponentChecksum
|
||||
applicationTokenPair {
|
||||
applicationAccessToken {
|
||||
token
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const METADATA_OPERATION_BROWSER_EVENT_NAME =
|
||||
'metadata-operation-browser-event';
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { METADATA_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/MetadataOperationBrowserEventName';
|
||||
import { type MetadataOperation } from '@/browser-event/types/MetadataOperation';
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
import { useEffect } from 'react';
|
||||
import { type AllMetadataName } from 'twenty-shared/metadata';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
export const useListenToMetadataOperationBrowserEvent = <
|
||||
T extends Record<string, unknown>,
|
||||
>({
|
||||
onMetadataOperationBrowserEvent,
|
||||
metadataName,
|
||||
operationTypes,
|
||||
}: {
|
||||
onMetadataOperationBrowserEvent: (
|
||||
detail: MetadataOperationBrowserEventDetail<T>,
|
||||
) => void;
|
||||
metadataName?: AllMetadataName;
|
||||
operationTypes?: MetadataOperation<T>['type'][];
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleMetadataOperationEvent = (
|
||||
event: CustomEvent<MetadataOperationBrowserEventDetail<T>>,
|
||||
) => {
|
||||
const detail = event.detail;
|
||||
|
||||
if (isDefined(metadataName) && detail.metadataName !== metadataName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isNonEmptyArray(operationTypes) &&
|
||||
!operationTypes.includes(detail.operation.type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMetadataOperationBrowserEvent(detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
METADATA_OPERATION_BROWSER_EVENT_NAME,
|
||||
handleMetadataOperationEvent as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
METADATA_OPERATION_BROWSER_EVENT_NAME,
|
||||
handleMetadataOperationEvent as EventListener,
|
||||
);
|
||||
};
|
||||
}, [metadataName, onMetadataOperationBrowserEvent, operationTypes]);
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName';
|
||||
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName';
|
||||
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type MetadataOperation<T extends Record<string, unknown>> =
|
||||
| {
|
||||
type: 'create';
|
||||
createdRecord: T;
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
updatedRecord: T;
|
||||
updatedFields?: string[];
|
||||
}
|
||||
| {
|
||||
type: 'delete';
|
||||
deletedRecordId: string;
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { type MetadataOperation } from '@/browser-event/types/MetadataOperation';
|
||||
import { type AllMetadataName } from 'twenty-shared/metadata';
|
||||
|
||||
export type MetadataOperationBrowserEventDetail<
|
||||
T extends Record<string, unknown>,
|
||||
> = {
|
||||
metadataName: AllMetadataName;
|
||||
operation: MetadataOperation<T>;
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { METADATA_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/MetadataOperationBrowserEventName';
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
|
||||
export const dispatchMetadataOperationBrowserEvent = <
|
||||
T extends Record<string, unknown>,
|
||||
>(
|
||||
detail: MetadataOperationBrowserEventDetail<T>,
|
||||
) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(METADATA_OPERATION_BROWSER_EVENT_NAME, {
|
||||
detail,
|
||||
}),
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
|
||||
export const dispatchObjectRecordOperationBrowserEvent = (
|
||||
detail: ObjectRecordOperationBrowserEventDetail,
|
||||
+11
-3
@@ -1,5 +1,6 @@
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
|
||||
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
|
||||
import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponentUrl';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@@ -21,8 +22,6 @@ export const FrontComponentRenderer = ({
|
||||
const { executionContext, frontComponentHostCommunicationApi } =
|
||||
useFrontComponentExecutionContext();
|
||||
|
||||
const componentUrl = `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
|
||||
|
||||
const handleError = useCallback(
|
||||
(error?: Error) => {
|
||||
if (!isDefined(error)) {
|
||||
@@ -43,6 +42,15 @@ export const FrontComponentRenderer = ({
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
useOnFrontComponentUpdated({
|
||||
frontComponentId,
|
||||
});
|
||||
|
||||
const componentUrl = getFrontComponentUrl({
|
||||
frontComponentId,
|
||||
checksum: data?.frontComponent?.builtComponentChecksum,
|
||||
});
|
||||
|
||||
if (
|
||||
loading ||
|
||||
!isDefined(data?.frontComponent) ||
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
|
||||
id
|
||||
name
|
||||
applicationId
|
||||
builtComponentChecksum
|
||||
applicationTokenPair {
|
||||
applicationAccessToken {
|
||||
token
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { useUpdateFrontComponentApolloCache } from '@/front-components/hooks/useUpdateFrontComponentApolloCache';
|
||||
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
import {
|
||||
AllMetadataName,
|
||||
type FrontComponent,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type UseOnFrontComponentUpdatedArgs = {
|
||||
frontComponentId: string;
|
||||
};
|
||||
|
||||
export const useOnFrontComponentUpdated = ({
|
||||
frontComponentId,
|
||||
}: UseOnFrontComponentUpdatedArgs) => {
|
||||
const queryId = `front-component-updated-${frontComponentId}`;
|
||||
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
metadataName: AllMetadataName.frontComponent,
|
||||
variables: {
|
||||
filter: { id: { eq: frontComponentId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { updateFrontComponentApolloCache } =
|
||||
useUpdateFrontComponentApolloCache({
|
||||
frontComponentId,
|
||||
});
|
||||
|
||||
useListenToMetadataOperationBrowserEvent<FrontComponent>({
|
||||
metadataName: AllMetadataName.frontComponent,
|
||||
onMetadataOperationBrowserEvent: updateFrontComponentApolloCache,
|
||||
});
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FindOneFrontComponentDocument,
|
||||
type FindOneFrontComponentQuery,
|
||||
type FrontComponent,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type UseUpdateFrontComponentApolloCacheArgs = {
|
||||
frontComponentId: string;
|
||||
};
|
||||
|
||||
export const useUpdateFrontComponentApolloCache = ({
|
||||
frontComponentId,
|
||||
}: UseUpdateFrontComponentApolloCacheArgs) => {
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
const updateFrontComponentApolloCache = (
|
||||
detail: MetadataOperationBrowserEventDetail<FrontComponent>,
|
||||
) => {
|
||||
if (detail.operation.type !== 'update') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { updatedRecord } = detail.operation;
|
||||
|
||||
if (!isDefined(updatedRecord) || updatedRecord.id !== frontComponentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
apolloClient.cache.updateQuery<FindOneFrontComponentQuery>(
|
||||
{
|
||||
query: FindOneFrontComponentDocument,
|
||||
variables: { id: frontComponentId },
|
||||
},
|
||||
(existingData) => {
|
||||
if (!isDefined(existingData?.frontComponent)) {
|
||||
return existingData;
|
||||
}
|
||||
|
||||
return {
|
||||
...existingData,
|
||||
frontComponent: {
|
||||
...existingData.frontComponent,
|
||||
...updatedRecord,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return { updateFrontComponentApolloCache };
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const getFrontComponentUrl = ({
|
||||
frontComponentId,
|
||||
checksum,
|
||||
}: {
|
||||
frontComponentId: string;
|
||||
checksum?: string;
|
||||
}): string => {
|
||||
return isDefined(checksum)
|
||||
? `${REST_API_BASE_URL}/front-components/${frontComponentId}?checksum=${checksum}`
|
||||
: `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
|
||||
};
|
||||
+11
-1
@@ -19,6 +19,7 @@ import {
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
import { WORKFLOW_TIMEZONE } from '@/workflow/constants/WorkflowTimeZone';
|
||||
import { isObject, isString } from '@sniptt/guards';
|
||||
@@ -62,6 +63,10 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
|
||||
const { applyObjectFilterDropdownFilterValue } =
|
||||
useApplyObjectFilterDropdownFilterValue();
|
||||
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
const isWholeDayFilterEnabled =
|
||||
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
|
||||
|
||||
const handleChange = (newValue: JsonValue) => {
|
||||
if (isString(newValue)) {
|
||||
applyObjectFilterDropdownFilterValue(newValue);
|
||||
@@ -178,7 +183,12 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
|
||||
}
|
||||
|
||||
const field = {
|
||||
type: recordFilter.type as FieldMetadataType,
|
||||
type:
|
||||
isWholeDayFilterEnabled === true &&
|
||||
recordFilter.type === FieldMetadataType.DATE_TIME &&
|
||||
recordFilter.operand === RecordFilterOperand.IS
|
||||
? FieldMetadataType.DATE
|
||||
: (recordFilter.type as FieldMetadataType),
|
||||
label: '',
|
||||
metadata: fieldDefinition?.metadata as FieldMetadata,
|
||||
};
|
||||
|
||||
+2
-2
@@ -2,11 +2,11 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
|
||||
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
|
||||
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useObjectMetadataItem');
|
||||
jest.mock('@/object-record/utils/dispatchObjectRecordOperationBrowserEvent');
|
||||
jest.mock('@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent');
|
||||
jest.mock('@/object-record/hooks/useUpdateManyRecords', () => ({
|
||||
useUpdateManyRecords: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/object-record/hooks/useCreateManyRecords';
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
@@ -19,7 +19,7 @@ import { type FieldActorForInputValue } from '@/object-record/record-field/ui/ty
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField';
|
||||
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { type BaseObjectRecord } from '@/object-record/types/BaseObjectRecord';
|
||||
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
|
||||
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
|
||||
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField';
|
||||
import { isNull } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
|
||||
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF
|
||||
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
|
||||
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDestroyOneRecordMutationResponseField } from '@/object-record/utils/getDestroyOneRecordMutationResponseField';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIn
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
|
||||
|
||||
const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQue
|
||||
import { useMergeManyRecordsMutation } from '@/object-record/hooks/useMergeManyRecordsMutation';
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
|
||||
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
|
||||
import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField';
|
||||
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
|
||||
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
|
||||
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
|
||||
|
||||
+9
-3
@@ -38,7 +38,12 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
useApplyObjectFilterDropdownFilterValue();
|
||||
|
||||
const handleAbsoluteDateChange = (newPlainDate: string | null) => {
|
||||
const newFilterValue = newPlainDate ?? '';
|
||||
if (!isDefined(newPlainDate)) {
|
||||
applyObjectFilterDropdownFilterValue('', '');
|
||||
return;
|
||||
}
|
||||
|
||||
const newFilterValue = newPlainDate;
|
||||
|
||||
// TODO: remove this and use getDisplayValue instead
|
||||
const formattedDate = formatDateString({
|
||||
@@ -91,6 +96,7 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
? handleRelativeDateChange(null)
|
||||
: handleAbsoluteDateChange(null);
|
||||
};
|
||||
|
||||
const resolvedValue = objectFilterDropdownCurrentRecordFilter
|
||||
? resolveDateFilter(objectFilterDropdownCurrentRecordFilter)
|
||||
: null;
|
||||
@@ -100,7 +106,7 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
? resolvedValue
|
||||
: undefined;
|
||||
|
||||
const plainDateValue =
|
||||
const safePlainDateValue: string | undefined =
|
||||
resolvedValue && typeof resolvedValue === 'string'
|
||||
? resolvedValue
|
||||
: undefined;
|
||||
@@ -110,7 +116,7 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
instanceId={`object-filter-dropdown-date-input`}
|
||||
relativeDate={relativeDate}
|
||||
isRelative={isRelativeOperand}
|
||||
plainDateString={plainDateValue ?? null}
|
||||
plainDateString={safePlainDateValue ?? null}
|
||||
onChange={handleAbsoluteDateChange}
|
||||
onRelativeDateChange={handleRelativeDateChange}
|
||||
onClear={handleClear}
|
||||
|
||||
+17
@@ -5,6 +5,7 @@ import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-d
|
||||
import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect';
|
||||
import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
@@ -28,6 +29,10 @@ export const ObjectFilterDropdownFilterInput = ({
|
||||
filterDropdownId,
|
||||
recordFilterId,
|
||||
}: ObjectFilterDropdownFilterInputProps) => {
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
const isWholeDayFilterEnabled =
|
||||
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
|
||||
|
||||
const fieldMetadataItemUsedInDropdown = useRecoilComponentValue(
|
||||
fieldMetadataItemUsedInDropdownComponentSelector,
|
||||
);
|
||||
@@ -76,6 +81,18 @@ export const ObjectFilterDropdownFilterInput = ({
|
||||
</>
|
||||
);
|
||||
} else if (filterType === 'DATE_TIME') {
|
||||
if (
|
||||
isWholeDayFilterEnabled &&
|
||||
selectedOperandInDropdown === ViewFilterOperand.IS
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<ObjectFilterDropdownInnerSelectOperandDropdown />
|
||||
<DropdownMenuSeparator />
|
||||
<ObjectFilterDropdownDateInput />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ObjectFilterDropdownInnerSelectOperandDropdown />
|
||||
|
||||
+76
-10
@@ -1,3 +1,6 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { useUpsertObjectFilterDropdownCurrentFilter } from '@/object-record/object-filter-dropdown/hooks/useUpsertObjectFilterDropdownCurrentFilter';
|
||||
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
|
||||
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
|
||||
@@ -7,14 +10,12 @@ import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
|
||||
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
|
||||
import {
|
||||
isDefined,
|
||||
relativeDateFilterStringifiedSchema,
|
||||
@@ -47,6 +48,10 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
const { getRelativeDateFilterWithUserTimezone } =
|
||||
useGetRelativeDateFilterWithUserTimezone();
|
||||
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
const isWholeDayFilterEnabled =
|
||||
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
|
||||
|
||||
const applyObjectFilterDropdownOperand = (
|
||||
newOperand: RecordFilterOperand,
|
||||
) => {
|
||||
@@ -106,7 +111,24 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
recordFilterToUpsert.value,
|
||||
);
|
||||
|
||||
if (filterValueIsEmpty || isStillRelativeFilterValue.success) {
|
||||
const previousOperand =
|
||||
objectFilterDropdownCurrentRecordFilter?.operand;
|
||||
|
||||
const isDateTimeOperandFormatChange =
|
||||
recordFilterToUpsert.type === 'DATE_TIME' &&
|
||||
!filterValueIsEmpty &&
|
||||
!isStillRelativeFilterValue.success &&
|
||||
(previousOperand === RecordFilterOperand.IS ||
|
||||
newOperand === RecordFilterOperand.IS);
|
||||
|
||||
if (isDateTimeOperandFormatChange) {
|
||||
recordFilterToUpsert.value = convertDateTimeFilterValue(
|
||||
recordFilterToUpsert.value,
|
||||
newOperand,
|
||||
userTimezone,
|
||||
isWholeDayFilterEnabled,
|
||||
);
|
||||
} else if (filterValueIsEmpty || isStillRelativeFilterValue.success) {
|
||||
const zonedDateToUse = Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
if (recordFilterToUpsert.type === 'DATE') {
|
||||
@@ -116,11 +138,18 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
|
||||
recordFilterToUpsert.value = initialNowDateFilterValue;
|
||||
} else {
|
||||
const initialNowDateTimeFilterValue = zonedDateToUse
|
||||
.toInstant()
|
||||
.toString();
|
||||
|
||||
recordFilterToUpsert.value = initialNowDateTimeFilterValue;
|
||||
if (
|
||||
newOperand === RecordFilterOperand.IS &&
|
||||
isWholeDayFilterEnabled
|
||||
) {
|
||||
recordFilterToUpsert.value = zonedDateToUse
|
||||
.toPlainDate()
|
||||
.toString();
|
||||
} else {
|
||||
recordFilterToUpsert.value = zonedDateToUse
|
||||
.toInstant()
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,3 +166,40 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
applyObjectFilterDropdownOperand,
|
||||
};
|
||||
};
|
||||
|
||||
const convertDateTimeFilterValue = (
|
||||
currentValue: string,
|
||||
targetOperand: RecordFilterOperand,
|
||||
userTimezone: string,
|
||||
isWholeDayFilterEnabled = false,
|
||||
): string => {
|
||||
const zonedDateToUse = Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
if (targetOperand === RecordFilterOperand.IS) {
|
||||
try {
|
||||
const existingZoned = currentValue.includes('T')
|
||||
? Temporal.Instant.from(currentValue).toZonedDateTimeISO(userTimezone)
|
||||
: Temporal.PlainDate.from(currentValue).toZonedDateTime(userTimezone);
|
||||
|
||||
if (isWholeDayFilterEnabled) {
|
||||
return existingZoned.toPlainDate().toString();
|
||||
} else {
|
||||
return existingZoned.toInstant().toString();
|
||||
}
|
||||
} catch {
|
||||
return zonedDateToUse.toPlainDate().toString();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const existingPlainDate = Temporal.PlainDate.from(currentValue);
|
||||
const currentTime = zonedDateToUse.toPlainTime();
|
||||
const zonedFromPlain = existingPlainDate.toZonedDateTime({
|
||||
timeZone: userTimezone,
|
||||
plainTime: currentTime,
|
||||
});
|
||||
return zonedFromPlain.toInstant().toString();
|
||||
} catch {
|
||||
return zonedDateToUse.toInstant().toString();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+18
-1
@@ -1,9 +1,12 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
|
||||
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
|
||||
import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types';
|
||||
|
||||
const activeDatePickerOperands = [
|
||||
@@ -16,6 +19,9 @@ export const useGetInitialFilterValue = () => {
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue();
|
||||
const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue();
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
const isWholeDayFilterEnabled =
|
||||
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
|
||||
|
||||
const getInitialFilterValue = (
|
||||
newType: FilterableAndTSVectorFieldType,
|
||||
@@ -44,6 +50,17 @@ export const useGetInitialFilterValue = () => {
|
||||
alreadyExistingZonedDateTime ??
|
||||
Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
if (
|
||||
isWholeDayFilterEnabled === true &&
|
||||
newOperand === RecordFilterOperand.IS
|
||||
) {
|
||||
const value = referenceDate.toPlainDate().toString();
|
||||
|
||||
const { displayValue } = getDateFilterDisplayValue(referenceDate);
|
||||
|
||||
return { value, displayValue };
|
||||
}
|
||||
|
||||
const value = referenceDate.toInstant().toString();
|
||||
|
||||
const { displayValue } = getDateTimeFilterDisplayValue(referenceDate);
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useGetShouldInitializeRecordBoardForUpdateInputs } from '@/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs';
|
||||
import { useRemoveRecordsFromBoard } from '@/object-record/record-board/hooks/useRemoveRecordsFromBoard';
|
||||
import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery';
|
||||
@@ -7,7 +7,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { useContext } from 'react';
|
||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { useRecordIndexGroupCommonQueryVariables } from '@/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
|
||||
export const RecordBoardSSESubscribeEffect = () => {
|
||||
const { recordBoardId } = useContext(RecordBoardContext);
|
||||
@@ -13,7 +13,7 @@ export const RecordBoardSSESubscribeEffect = () => {
|
||||
|
||||
const queryId = `record-board-${recordBoardId}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar
|
||||
import { useRecordCalendarQueryDateRangeFilter } from '@/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
@@ -32,7 +32,7 @@ export const RecordCalendarSSESubscribeEffect = () => {
|
||||
|
||||
const queryId = `record-calendar-${recordCalendarId}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
|
||||
+21
-3
@@ -1,3 +1,6 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
|
||||
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
|
||||
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
|
||||
@@ -7,8 +10,7 @@ import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordF
|
||||
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import {
|
||||
isDefined,
|
||||
@@ -87,7 +89,23 @@ export const useGetRecordFilterDisplayValue = () => {
|
||||
}
|
||||
} else if (recordFilter.type === 'DATE_TIME') {
|
||||
switch (recordFilter.operand) {
|
||||
case RecordFilterOperand.IS:
|
||||
case RecordFilterOperand.IS: {
|
||||
if (!isNonEmptyString(recordFilter.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const zonedDateTime = recordFilter.value.includes('T')
|
||||
? Temporal.Instant.from(recordFilter.value).toZonedDateTimeISO(
|
||||
userTimezone,
|
||||
)
|
||||
: Temporal.PlainDate.from(recordFilter.value).toZonedDateTime(
|
||||
userTimezone,
|
||||
);
|
||||
|
||||
const { displayValue } = getDateFilterDisplayValue(zonedDateTime);
|
||||
|
||||
return `${displayValue}`;
|
||||
}
|
||||
case RecordFilterOperand.IS_AFTER:
|
||||
case RecordFilterOperand.IS_BEFORE: {
|
||||
if (!isNonEmptyString(recordFilter.value)) {
|
||||
|
||||
+4
-4
@@ -1024,9 +1024,9 @@ describe('should work as expected for the different field types', () => {
|
||||
|
||||
const dateFilterIs: RecordFilter = {
|
||||
id: 'company-date-filter-is',
|
||||
value: '2024-09-17T20:46:58.922Z',
|
||||
value: '2024-09-17',
|
||||
fieldMetadataId: companyMockDateFieldMetadataId?.id,
|
||||
displayValue: '2024-09-17T20:46:58.922Z',
|
||||
displayValue: '2024-09-17',
|
||||
operand: ViewFilterOperand.IS,
|
||||
label: 'Created At',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
@@ -1081,12 +1081,12 @@ describe('should work as expected for the different field types', () => {
|
||||
and: [
|
||||
{
|
||||
createdAt: {
|
||||
lt: '2024-09-17T20:47:00Z',
|
||||
gte: '2024-09-16T22:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: {
|
||||
gte: '2024-09-17T20:46:00Z',
|
||||
lt: '2024-09-17T22:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
|
||||
type RecordShowPageSSESubscribeEffectProps = {
|
||||
objectNameSingular: string;
|
||||
@@ -11,7 +11,7 @@ export const RecordShowPageSSESubscribeEffect = ({
|
||||
}: RecordShowPageSSESubscribeEffectProps) => {
|
||||
const queryId = `record-show-${objectNameSingular}-${recordId}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular,
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
|
||||
import { SSE_TABLE_DEBOUNCE_TIME_IN_MS_TO_AVOID_SSE_OWN_EVENTS_RACE_CONDITION } from '@/object-record/record-table/virtualization/constants/SseTableDebounceTimeInMsToAvoidSseOwnEventsRaceCondition';
|
||||
import { useGetShouldResetTableVirtualizationForUpdateInputs } from '@/object-record/record-table/virtualization/hooks/useGetShouldResetTableVirtualizationForUpdateInputs';
|
||||
import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
export const RecordTableVirtualizedDataChangedEffect = () => {
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
|
||||
|
||||
@@ -26,7 +26,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
|
||||
|
||||
const queryId = `record-table-virtualized-${objectMetadataItem.nameSingular}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
|
||||
@@ -37,11 +37,11 @@ export const SSEEventStreamEffect = () => {
|
||||
const { triggerEventStreamDestroy } = useTriggerEventStreamDestroy();
|
||||
|
||||
useEffect(() => {
|
||||
const isSseClientAvailabble =
|
||||
const isSseClientAvailable =
|
||||
!isCreatingSseEventStream && !isDestroyingEventStream;
|
||||
|
||||
const willCreateEventStream =
|
||||
isSseClientAvailabble &&
|
||||
isSseClientAvailable &&
|
||||
isLoggedIn &&
|
||||
isSseDbEventsEnabled &&
|
||||
isDefined(currentUser) &&
|
||||
@@ -51,7 +51,7 @@ export const SSEEventStreamEffect = () => {
|
||||
isNonEmptyArray(objectMetadataItems);
|
||||
|
||||
const willDestroyEventStream =
|
||||
isSseClientAvailabble &&
|
||||
isSseClientAvailable &&
|
||||
isNonEmptyString(sseEventStreamId) &&
|
||||
shouldDestroyEventStream;
|
||||
|
||||
|
||||
+16
-2
@@ -4,8 +4,8 @@ export const ON_EVENT_SUBSCRIPTION = gql`
|
||||
subscription OnEventSubscription($eventStreamId: String!) {
|
||||
onEventSubscription(eventStreamId: $eventStreamId) {
|
||||
eventStreamId
|
||||
eventWithQueryIdsList {
|
||||
event {
|
||||
objectRecordEventsWithQueryIds {
|
||||
objectRecordEvent {
|
||||
action
|
||||
objectNameSingular
|
||||
recordId
|
||||
@@ -20,6 +20,20 @@ export const ON_EVENT_SUBSCRIPTION = gql`
|
||||
}
|
||||
queryIds
|
||||
}
|
||||
metadataEventsWithQueryIds {
|
||||
metadataEvent {
|
||||
type
|
||||
metadataName
|
||||
recordId
|
||||
properties {
|
||||
updatedFields
|
||||
before
|
||||
after
|
||||
diff
|
||||
}
|
||||
}
|
||||
queryIds
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { dispatchMetadataOperationBrowserEvent } from '@/browser-event/utils/dispatchMetadataOperationBrowserEvent';
|
||||
import { turnSseMetadataEventsToMetadataOperationBrowserEvents } from '@/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
type AllMetadataName,
|
||||
type MetadataEvent,
|
||||
type MetadataEventWithQueryIds,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const groupSseMetadataEventsByMetadataName = (
|
||||
sseMetadataEvents: MetadataEvent[],
|
||||
): Map<AllMetadataName, MetadataEvent[]> => {
|
||||
const eventsByMetadataName = new Map<AllMetadataName, MetadataEvent[]>();
|
||||
|
||||
for (const event of sseMetadataEvents) {
|
||||
const metadataName = event.metadataName as AllMetadataName;
|
||||
|
||||
const existing = eventsByMetadataName.get(metadataName) ?? [];
|
||||
|
||||
eventsByMetadataName.set(metadataName, [...existing, event]);
|
||||
}
|
||||
|
||||
return eventsByMetadataName;
|
||||
};
|
||||
|
||||
export const useDispatchMetadataEventsFromSseToBrowserEvents = <
|
||||
T extends Record<string, unknown>,
|
||||
>() => {
|
||||
const dispatchMetadataEventsFromSseToBrowserEvents = useCallback(
|
||||
(metadataEventsWithQueryIds: MetadataEventWithQueryIds[]) => {
|
||||
const sseMetadataEvents = metadataEventsWithQueryIds.map(
|
||||
(item) => item.metadataEvent,
|
||||
);
|
||||
|
||||
const eventsByMetadataName =
|
||||
groupSseMetadataEventsByMetadataName(sseMetadataEvents);
|
||||
|
||||
for (const [metadataName, events] of eventsByMetadataName) {
|
||||
const metadataOperationBrowserEvents =
|
||||
turnSseMetadataEventsToMetadataOperationBrowserEvents<T>({
|
||||
metadataName,
|
||||
sseMetadataEvents: events,
|
||||
});
|
||||
|
||||
for (const browserEvent of metadataOperationBrowserEvents) {
|
||||
dispatchMetadataOperationBrowserEvent(browserEvent);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { dispatchMetadataEventsFromSseToBrowserEvents };
|
||||
};
|
||||
+6
-6
@@ -1,19 +1,19 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular';
|
||||
import { turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents } from '@/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type EventWithQueryIds } from '~/generated-metadata/graphql';
|
||||
import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const dispatchObjectRecordEventsFromSseToBrowserEvents = useCallback(
|
||||
(eventsWithQueryIds: EventWithQueryIds[]) => {
|
||||
const objectRecordEvents = eventsWithQueryIds.map((eventWithQueryIds) => {
|
||||
return eventWithQueryIds.event;
|
||||
});
|
||||
(objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]) => {
|
||||
const objectRecordEvents = objectRecordEventsWithQueryIds.map(
|
||||
(item) => item.objectRecordEvent,
|
||||
);
|
||||
|
||||
const objectRecordEventsByObjectMetadataItemNameSingular =
|
||||
groupObjectRecordSseEventsByObjectMetadataItemNameSingular({
|
||||
|
||||
+8
-3
@@ -2,14 +2,19 @@ import { requiredQueryListenersState } from '@/sse-db-event/states/requiredQuery
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
|
||||
import {
|
||||
type MetadataGqlOperationSignature,
|
||||
type RecordGqlOperationSignature,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const useListenToObjectRecordEventsForQuery = ({
|
||||
export const useListenToEventsForQuery = ({
|
||||
queryId,
|
||||
operationSignature,
|
||||
}: {
|
||||
queryId: string;
|
||||
operationSignature: RecordGqlOperationSignature;
|
||||
operationSignature:
|
||||
| RecordGqlOperationSignature
|
||||
| MetadataGqlOperationSignature;
|
||||
}) => {
|
||||
const changeQueryIdListenState = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
+56
-6
@@ -1,4 +1,5 @@
|
||||
import { ON_EVENT_SUBSCRIPTION } from '@/sse-db-event/graphql/subscriptions/OnEventSubscription';
|
||||
import { useDispatchMetadataEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchMetadataEventsFromSseToBrowserEvents';
|
||||
import { useDispatchObjectRecordEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents';
|
||||
import { useTriggerOptimisticEffectFromSseEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents';
|
||||
import { disposeFunctionForEventStreamState } from '@/sse-db-event/states/disposeFunctionByEventStreamMapState';
|
||||
@@ -23,6 +24,9 @@ export const useTriggerEventStreamCreation = () => {
|
||||
isCreatingSseEventStreamState,
|
||||
);
|
||||
|
||||
const { dispatchMetadataEventsFromSseToBrowserEvents } =
|
||||
useDispatchMetadataEventsFromSseToBrowserEvents();
|
||||
|
||||
const { dispatchObjectRecordEventsFromSseToBrowserEvents } =
|
||||
useDispatchObjectRecordEventsFromSseToBrowserEvents();
|
||||
|
||||
@@ -73,9 +77,55 @@ export const useTriggerEventStreamCreation = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
next: (
|
||||
value: ExecutionResult<{
|
||||
onEventSubscription: EventSubscription;
|
||||
}>,
|
||||
) => {
|
||||
if (isDefined(value?.errors)) {
|
||||
captureException(
|
||||
new Error(
|
||||
`SSE subscription error: ${value.errors[0]?.message}`,
|
||||
),
|
||||
);
|
||||
set(shouldDestroyEventStreamState, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasReceivedFirstEvent) {
|
||||
hasReceivedFirstEvent = true;
|
||||
set(sseEventStreamReadyState, true);
|
||||
}
|
||||
|
||||
const eventSubscription = value?.data?.onEventSubscription;
|
||||
|
||||
const objectRecordEventsWithQueryIds =
|
||||
eventSubscription?.objectRecordEventsWithQueryIds ?? [];
|
||||
|
||||
const metadataEventsWithQueryIds =
|
||||
eventSubscription?.metadataEventsWithQueryIds ?? [];
|
||||
|
||||
const objectRecordEvents = objectRecordEventsWithQueryIds.map(
|
||||
(item) => item.objectRecordEvent,
|
||||
);
|
||||
|
||||
triggerOptimisticEffectFromSseEvents({
|
||||
objectRecordEvents,
|
||||
});
|
||||
|
||||
dispatchObjectRecordEventsFromSseToBrowserEvents(
|
||||
objectRecordEventsWithQueryIds,
|
||||
);
|
||||
|
||||
dispatchMetadataEventsFromSseToBrowserEvents(
|
||||
metadataEventsWithQueryIds,
|
||||
);
|
||||
},
|
||||
error: (error) => {
|
||||
captureException(error);
|
||||
},
|
||||
complete: () => {},
|
||||
error: () => {},
|
||||
next: () => {},
|
||||
},
|
||||
{
|
||||
message: ({ data, event }) => {
|
||||
@@ -109,12 +159,12 @@ export const useTriggerEventStreamCreation = () => {
|
||||
|
||||
const objectRecordEventsWithQueryIds =
|
||||
result?.data?.onEventSubscription
|
||||
?.eventWithQueryIdsList ?? [];
|
||||
?.objectRecordEventsWithQueryIds ?? [];
|
||||
|
||||
const objectRecordEvents =
|
||||
objectRecordEventsWithQueryIds.map(
|
||||
(eventWithQueryIds) => {
|
||||
return eventWithQueryIds.event;
|
||||
(objectRecordEventWithQueryIds) => {
|
||||
return objectRecordEventWithQueryIds.objectRecordEvent;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -144,9 +194,9 @@ export const useTriggerEventStreamCreation = () => {
|
||||
setIsCreatingSseEventStream(false);
|
||||
},
|
||||
[
|
||||
dispatchMetadataEventsFromSseToBrowserEvents,
|
||||
dispatchObjectRecordEventsFromSseToBrowserEvents,
|
||||
setIsCreatingSseEventStream,
|
||||
|
||||
triggerOptimisticEffectFromSseEvents,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
|
||||
import { createState } from '@/ui/utilities/state/utils/createState';
|
||||
import {
|
||||
type MetadataGqlOperationSignature,
|
||||
type RecordGqlOperationSignature,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const activeQueryListenersState = createState<
|
||||
{ queryId: string; operationSignature: RecordGqlOperationSignature }[]
|
||||
{
|
||||
queryId: string;
|
||||
operationSignature:
|
||||
| RecordGqlOperationSignature
|
||||
| MetadataGqlOperationSignature;
|
||||
}[]
|
||||
>({
|
||||
key: 'activeQueryListenersState',
|
||||
defaultValue: [],
|
||||
|
||||
+10
-2
@@ -1,8 +1,16 @@
|
||||
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
|
||||
import { createState } from '@/ui/utilities/state/utils/createState';
|
||||
import {
|
||||
type MetadataGqlOperationSignature,
|
||||
type RecordGqlOperationSignature,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const requiredQueryListenersState = createState<
|
||||
{ queryId: string; operationSignature: RecordGqlOperationSignature }[]
|
||||
{
|
||||
queryId: string;
|
||||
operationSignature:
|
||||
| RecordGqlOperationSignature
|
||||
| MetadataGqlOperationSignature;
|
||||
}[]
|
||||
>({
|
||||
key: 'requiredQueryListenersState',
|
||||
defaultValue: [],
|
||||
|
||||
+5
-7
@@ -1,22 +1,20 @@
|
||||
import { type ObjectRecordEventsByQueryId } from '@/sse-db-event/types/ObjectRecordEventsByQueryId';
|
||||
import { getObjectRecordEventsForQueryEventName } from '@/sse-db-event/utils/getObjectRecordEventsForQueryEventName';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type EventWithQueryIds } from '~/generated-metadata/graphql';
|
||||
import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql';
|
||||
|
||||
export const dispatchObjectRecordEventsWithQueryIds = (
|
||||
objectRecordEventsWithQueryIds: EventWithQueryIds[],
|
||||
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[],
|
||||
) => {
|
||||
const objectRecordEventsByQueryId: ObjectRecordEventsByQueryId = {};
|
||||
|
||||
for (const objectRecordEventWithQueryIds of objectRecordEventsWithQueryIds) {
|
||||
for (const queryId of objectRecordEventWithQueryIds.queryIds) {
|
||||
for (const item of objectRecordEventsWithQueryIds) {
|
||||
for (const queryId of item.queryIds) {
|
||||
if (!isDefined(objectRecordEventsByQueryId[queryId])) {
|
||||
objectRecordEventsByQueryId[queryId] = [];
|
||||
}
|
||||
|
||||
objectRecordEventsByQueryId[queryId].push(
|
||||
objectRecordEventWithQueryIds.event,
|
||||
);
|
||||
objectRecordEventsByQueryId[queryId].push(item.objectRecordEvent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
MetadataEventAction,
|
||||
type AllMetadataName,
|
||||
type MetadataEvent,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const turnSseMetadataEventsToMetadataOperationBrowserEvents = <
|
||||
T extends Record<string, unknown>,
|
||||
>({
|
||||
metadataName,
|
||||
sseMetadataEvents,
|
||||
}: {
|
||||
metadataName: AllMetadataName;
|
||||
sseMetadataEvents: MetadataEvent[];
|
||||
}): MetadataOperationBrowserEventDetail<T>[] => {
|
||||
return sseMetadataEvents
|
||||
.map((event): MetadataOperationBrowserEventDetail<T> | null => {
|
||||
switch (event.type) {
|
||||
case MetadataEventAction.CREATED: {
|
||||
const createdRecord = event.properties.after;
|
||||
|
||||
if (!isDefined(createdRecord)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
metadataName,
|
||||
operation: {
|
||||
type: 'create',
|
||||
createdRecord,
|
||||
},
|
||||
};
|
||||
}
|
||||
case MetadataEventAction.UPDATED: {
|
||||
const updatedRecord = event.properties.after;
|
||||
|
||||
if (!isDefined(updatedRecord)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
metadataName,
|
||||
operation: {
|
||||
type: 'update',
|
||||
updatedRecord,
|
||||
updatedFields: event.properties.updatedFields ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
case MetadataEventAction.DELETED: {
|
||||
return {
|
||||
metadataName,
|
||||
operation: {
|
||||
type: 'delete',
|
||||
deletedRecordId: event.recordId,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(isDefined);
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { getObjectRecordOperationUpdateInputs } from '@/sse-db-event/utils/getObjectRecordOperationUpdateInputs';
|
||||
import { groupObjectRecordSseEventsByEventType } from '@/sse-db-event/utils/groupObjectRecordSseEventsByEventType';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { useStopWorkflowRunMutation } from '~/generated/graphql';
|
||||
|
||||
export const useStopWorkflowRun = () => {
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
|
||||
export const WorkflowRunSSESubscribeEffect = ({
|
||||
workflowRunId,
|
||||
@@ -8,7 +8,7 @@ export const WorkflowRunSSESubscribeEffect = ({
|
||||
}) => {
|
||||
const queryId = `workflow-run-${workflowRunId}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
|
||||
|
||||
+3
-3
@@ -3,8 +3,8 @@ import { useSetRecoilState } from 'recoil';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
import { shouldWorkflowRefetchRequestFamilyState } from '@/workflow/states/shouldWorkflowRefetchRequestFamilyState';
|
||||
|
||||
export const WorkflowSSESubscribeEffect = ({
|
||||
@@ -23,7 +23,7 @@ export const WorkflowSSESubscribeEffect = ({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
|
||||
});
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
|
||||
|
||||
@@ -148,8 +148,9 @@ Application development commands.
|
||||
|
||||
- `twenty function:execute [appPath]` — Execute a logic function with a JSON payload.
|
||||
- Options:
|
||||
- `-n, --functionName <name>`: Name of the function to execute (required if `-u` not provided).
|
||||
- `-u, --functionUniversalIdentifier <id>`: Universal ID of the function to execute (required if `-n` not provided).
|
||||
- `--postInstall`: Execute the post-install logic function defined in the application config (required if `-n` and `-u` not provided).
|
||||
- `-n, --functionName <name>`: Name of the function to execute (required if `--postInstall` and `-u` not provided).
|
||||
- `-u, --functionUniversalIdentifier <id>`: Universal ID of the function to execute (required if `--postInstall` and `-n` not provided).
|
||||
- `-p, --payload <payload>`: JSON payload to send to the function (default: `{}`).
|
||||
|
||||
Examples:
|
||||
@@ -187,6 +188,9 @@ twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Execute a function by universal identifier
|
||||
twenty function:execute -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf -p '{"key": "value"}'
|
||||
|
||||
# Execute the post-install function
|
||||
twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "0.6.0-alpha",
|
||||
"version": "0.6.0",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/sdk/index.d.ts",
|
||||
@@ -63,7 +63,6 @@
|
||||
"commander": "^12.0.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-svelte": "^0.9.4",
|
||||
"fast-glob": "^3.3.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"graphql": "^16.8.1",
|
||||
@@ -96,7 +95,6 @@
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "^1.56.1",
|
||||
"storybook": "^10.1.11",
|
||||
"svelte": "^4.2.19",
|
||||
"ts-morph": "^25.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"twenty-shared": "workspace:*",
|
||||
|
||||
@@ -54,21 +54,9 @@ const twentySharedAliases = Object.fromEntries(
|
||||
]),
|
||||
);
|
||||
|
||||
const svelteRoot = path.join(rootNodeModules, 'svelte');
|
||||
|
||||
const storyAlias = {
|
||||
react: path.join(rootNodeModules, 'react'),
|
||||
'react-dom': path.join(rootNodeModules, 'react-dom'),
|
||||
vue: path.join(rootNodeModules, 'vue'),
|
||||
svelte: svelteRoot,
|
||||
'svelte/internal': path.join(
|
||||
svelteRoot,
|
||||
'src/runtime/internal/index.js',
|
||||
),
|
||||
'svelte/internal/disclose-version': path.join(
|
||||
svelteRoot,
|
||||
'src/runtime/internal/disclose-version/index.js',
|
||||
),
|
||||
'@/sdk': sdkIndividualIndex,
|
||||
'twenty-sdk/ui': twentyUiIndividualIndex,
|
||||
...twentySharedAliases,
|
||||
@@ -86,8 +74,6 @@ const STORY_COMPONENTS = [
|
||||
'mui-example.front-component',
|
||||
'twenty-ui-example.front-component',
|
||||
'sdk-context-example.front-component',
|
||||
'vue-example.front-component',
|
||||
'svelte-example.front-component',
|
||||
];
|
||||
|
||||
const resolveEntryPoints = (): Record<string, string> => {
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
|
||||
packageJsonChecksum: '2851d0e2c3621a57e1fd103a245b6fde',
|
||||
apiClientChecksum: null,
|
||||
},
|
||||
frontComponents: [
|
||||
{
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
|
||||
packageJsonChecksum: '93ae1e2eb3db18351d06f43550700dcc',
|
||||
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
|
||||
apiClientChecksum: null,
|
||||
},
|
||||
publicAssets: [],
|
||||
fields: [],
|
||||
|
||||
@@ -129,6 +129,7 @@ export const registerCommands = (program: Command): void => {
|
||||
|
||||
program
|
||||
.command('function:execute [appPath]')
|
||||
.option('--postInstall', 'Execute post-install logic function if defined')
|
||||
.option(
|
||||
'-p, --payload <payload>',
|
||||
'JSON payload to send to the function',
|
||||
@@ -147,15 +148,20 @@ export const registerCommands = (program: Command): void => {
|
||||
async (
|
||||
appPath?: string,
|
||||
options?: {
|
||||
postInstall?: boolean;
|
||||
payload?: string;
|
||||
functionUniversalIdentifier?: string;
|
||||
functionName?: string;
|
||||
},
|
||||
) => {
|
||||
if (!options?.functionUniversalIdentifier && !options?.functionName) {
|
||||
if (
|
||||
!options?.postInstall &&
|
||||
!options?.functionUniversalIdentifier &&
|
||||
!options?.functionName
|
||||
) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Error: Either --functionName (-n) or --functionUniversalIdentifier (-u) is required.',
|
||||
'Error: Either --postInstall or --functionName (-n) or --functionUniversalIdentifier (-u) is required.',
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
|
||||
|
||||
export class AppUninstallCommand {
|
||||
private apiService = new ApiService();
|
||||
@@ -25,7 +25,7 @@ export class AppUninstallCommand {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { manifest } = await buildManifest(appPath);
|
||||
const manifest = await readManifestFromFile(appPath);
|
||||
|
||||
if (!manifest) {
|
||||
return { success: false, error: 'Build failed' };
|
||||
|
||||
@@ -3,18 +3,20 @@ import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-exec
|
||||
import chalk from 'chalk';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
|
||||
|
||||
export class LogicFunctionExecuteCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
async execute({
|
||||
appPath = CURRENT_EXECUTION_DIRECTORY,
|
||||
postInstall = false,
|
||||
functionUniversalIdentifier,
|
||||
functionName,
|
||||
payload = '{}',
|
||||
}: {
|
||||
appPath?: string;
|
||||
postInstall?: boolean;
|
||||
functionUniversalIdentifier?: string;
|
||||
functionName?: string;
|
||||
payload?: string;
|
||||
@@ -30,7 +32,7 @@ export class LogicFunctionExecuteCommand {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { manifest } = await buildManifest(appPath);
|
||||
const manifest = await readManifestFromFile(appPath);
|
||||
|
||||
if (!manifest) {
|
||||
console.error(chalk.red('Failed to build manifest.'));
|
||||
@@ -54,6 +56,12 @@ export class LogicFunctionExecuteCommand {
|
||||
);
|
||||
|
||||
const targetFunction = appFunctions.find((fn) => {
|
||||
if (postInstall) {
|
||||
return (
|
||||
fn.universalIdentifier ===
|
||||
manifest.application.postInstallLogicFunctionUniversalIdentifier
|
||||
);
|
||||
}
|
||||
if (functionUniversalIdentifier) {
|
||||
return fn.universalIdentifier === functionUniversalIdentifier;
|
||||
}
|
||||
@@ -64,7 +72,9 @@ export class LogicFunctionExecuteCommand {
|
||||
});
|
||||
|
||||
if (!targetFunction) {
|
||||
const identifier = functionUniversalIdentifier || functionName;
|
||||
const identifier = postInstall
|
||||
? 'post install'
|
||||
: functionUniversalIdentifier || functionName;
|
||||
console.error(
|
||||
chalk.red(`Function "${identifier}" not found in application.`),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
|
||||
|
||||
export class LogicFunctionLogsCommand {
|
||||
private apiService = new ApiService();
|
||||
@@ -16,7 +16,7 @@ export class LogicFunctionLogsCommand {
|
||||
functionName?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { manifest } = await buildManifest(appPath);
|
||||
const manifest = await readManifestFromFile(appPath);
|
||||
|
||||
if (!manifest) {
|
||||
process.exit(1);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type RestartableWatcher,
|
||||
type RestartableWatcherOptions,
|
||||
} from '@/cli/utilities/build/common/restartable-watcher-interface';
|
||||
import { createTypecheckPlugin } from '@/cli/utilities/build/common/typecheck-plugin';
|
||||
import * as esbuild from 'esbuild';
|
||||
import path from 'path';
|
||||
import { OUTPUT_DIR, NODE_ESM_CJS_BANNER } from 'twenty-shared/application';
|
||||
@@ -214,7 +215,10 @@ export const createLogicFunctionsWatcher = (
|
||||
externalModules: LOGIC_FUNCTION_EXTERNAL_MODULES,
|
||||
fileFolder: FileFolder.BuiltLogicFunction,
|
||||
platform: 'node',
|
||||
extraPlugins: [createSdkGeneratedResolverPlugin(options.appPath)],
|
||||
extraPlugins: [
|
||||
createTypecheckPlugin(options.appPath),
|
||||
createSdkGeneratedResolverPlugin(options.appPath),
|
||||
],
|
||||
banner: NODE_ESM_CJS_BANNER,
|
||||
},
|
||||
});
|
||||
@@ -229,6 +233,7 @@ export const createFrontComponentsWatcher = (
|
||||
fileFolder: FileFolder.BuiltFrontComponent,
|
||||
jsx: 'automatic',
|
||||
extraPlugins: [
|
||||
createTypecheckPlugin(options.appPath),
|
||||
createSdkGeneratedResolverPlugin(options.appPath),
|
||||
...getFrontComponentBuildPlugins(),
|
||||
],
|
||||
|
||||
@@ -51,7 +51,7 @@ export class FileUploadWatcher {
|
||||
});
|
||||
|
||||
this.watcher.on('all', (event, filePath) => {
|
||||
if (event === 'addDir') {
|
||||
if (event === 'addDir' || event === 'unlinkDir') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -1,5 +1,4 @@
|
||||
import type * as esbuild from 'esbuild';
|
||||
import sveltePlugin from 'esbuild-svelte';
|
||||
|
||||
import { createJsxRuntimeRemoteWrapperPlugin } from '../jsx-runtime-remote-wrapper-plugin';
|
||||
import { jsxTransformToRemoteDomWorkerFormatPlugin } from '../jsx-transform-to-remote-dom-worker-format-plugin';
|
||||
@@ -13,7 +12,6 @@ type GetFrontComponentBuildPluginsOptions = {
|
||||
export const getFrontComponentBuildPlugins = (
|
||||
options?: GetFrontComponentBuildPluginsOptions,
|
||||
): esbuild.Plugin[] => [
|
||||
sveltePlugin(),
|
||||
createJsxRuntimeRemoteWrapperPlugin(
|
||||
options?.usePreact ? { usePreact: true } : undefined,
|
||||
),
|
||||
|
||||
+5
-30
@@ -1,34 +1,9 @@
|
||||
import { type FrontComponentFramework } from 'twenty-shared/application';
|
||||
|
||||
const DEFINE_FRONT_COMPONENT_IMPORT_PATTERN =
|
||||
/import\s*\{\s*defineFrontComponent\s*\}\s*from\s*['"][^'"]+['"];?\n?/g;
|
||||
|
||||
const DEFINE_FRONT_COMPONENT_EXPORT_PATTERN =
|
||||
/export\s+default\s+defineFrontComponent\s*\(\s*\{[^}]*component\s*:\s*(\w+)[^}]*\}\s*\)\s*;?/s;
|
||||
|
||||
const FRAMEWORK_PATTERN = /framework\s*:\s*['"](\w+)['"]/;
|
||||
|
||||
const extractFramework = (sourceCode: string): FrontComponentFramework => {
|
||||
const match = sourceCode.match(FRAMEWORK_PATTERN);
|
||||
|
||||
return (match?.[1] as FrontComponentFramework) ?? 'react';
|
||||
};
|
||||
|
||||
const REACT_MOUNT_IMPORTS =
|
||||
`import { createRoot as __createRoot } from 'react-dom/client';\n` +
|
||||
`import { jsx as __frontComponentJsx } from 'react/jsx-runtime';\n`;
|
||||
|
||||
const generateRenderExport = (
|
||||
framework: FrontComponentFramework,
|
||||
componentName: string,
|
||||
): string => {
|
||||
if (framework === 'react') {
|
||||
return `export default function __renderFrontComponent(__container) { __createRoot(__container).render(__frontComponentJsx(${componentName}, {})); }`;
|
||||
}
|
||||
|
||||
return `export default function __renderFrontComponent(__container) { ${componentName}(__container); }`;
|
||||
};
|
||||
|
||||
export const unwrapDefineFrontComponentToDirectExport = (
|
||||
sourceCode: string,
|
||||
): string => {
|
||||
@@ -43,7 +18,6 @@ export const unwrapDefineFrontComponentToDirectExport = (
|
||||
|
||||
if (defineFrontComponentMatch) {
|
||||
const wrappedComponentName = defineFrontComponentMatch[1];
|
||||
const framework = extractFramework(sourceCode);
|
||||
|
||||
const exportedComponentDeclarationPattern = new RegExp(
|
||||
`export\\s+(const|function)\\s+${wrappedComponentName}\\b`,
|
||||
@@ -54,13 +28,14 @@ export const unwrapDefineFrontComponentToDirectExport = (
|
||||
`$1 ${wrappedComponentName}`,
|
||||
);
|
||||
|
||||
if (framework === 'react') {
|
||||
transformedSource = REACT_MOUNT_IMPORTS + transformedSource;
|
||||
}
|
||||
transformedSource =
|
||||
`import { createRoot as __createRoot } from 'react-dom/client';\n` +
|
||||
`import { jsx as __frontComponentJsx } from 'react/jsx-runtime';\n` +
|
||||
transformedSource;
|
||||
|
||||
transformedSource = transformedSource.replace(
|
||||
DEFINE_FRONT_COMPONENT_EXPORT_PATTERN,
|
||||
generateRenderExport(framework, wrappedComponentName),
|
||||
`export default function __renderFrontComponent(__container) { __createRoot(__container).render(__frontComponentJsx(${wrappedComponentName}, {})); }`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
parseTscOutputLine,
|
||||
type TypecheckError,
|
||||
} from '@/cli/utilities/build/common/typecheck-plugin';
|
||||
|
||||
export type TscWatcherOptions = {
|
||||
appPath: string;
|
||||
onErrors: (errors: TypecheckError[]) => void;
|
||||
};
|
||||
|
||||
export class TscWatcher {
|
||||
private appPath: string;
|
||||
private onErrors: (errors: TypecheckError[]) => void;
|
||||
private process: ChildProcess | null = null;
|
||||
private pendingErrors: TypecheckError[] = [];
|
||||
private buffer = '';
|
||||
private hasErrors = false;
|
||||
|
||||
constructor(options: TscWatcherOptions) {
|
||||
this.appPath = options.appPath;
|
||||
this.onErrors = options.onErrors;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const tscPath = path.join(this.appPath, 'node_modules', '.bin', 'tsc');
|
||||
|
||||
if (!(await fs.pathExists(tscPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tsconfigPath = path.join(this.appPath, 'tsconfig.json');
|
||||
|
||||
this.process = spawn(
|
||||
tscPath,
|
||||
['--watch', '--noEmit', '--pretty', 'false', '-p', tsconfigPath],
|
||||
{ cwd: this.appPath, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
|
||||
this.process.on('error', () => {
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
this.process.stdout?.on('data', (chunk: Buffer) => {
|
||||
this.handleOutput(chunk.toString());
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (chunk: Buffer) => {
|
||||
this.handleOutput(chunk.toString());
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.process?.kill();
|
||||
this.process = null;
|
||||
}
|
||||
|
||||
private handleOutput(data: string): void {
|
||||
this.buffer += data;
|
||||
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
this.buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
this.processLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
private processLine(line: string): void {
|
||||
if (
|
||||
line.includes('Starting compilation in watch mode...') ||
|
||||
line.includes('Starting incremental compilation...')
|
||||
) {
|
||||
this.pendingErrors = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('Watching for file changes.')) {
|
||||
const hadErrors = this.hasErrors;
|
||||
|
||||
this.hasErrors = this.pendingErrors.length > 0;
|
||||
|
||||
if (this.hasErrors || hadErrors) {
|
||||
this.onErrors(this.pendingErrors);
|
||||
}
|
||||
|
||||
this.pendingErrors = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const error = parseTscOutputLine(line);
|
||||
|
||||
if (error) {
|
||||
this.pendingErrors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import type * as esbuild from 'esbuild';
|
||||
import path from 'node:path';
|
||||
|
||||
export type TypecheckError = {
|
||||
text: string;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
|
||||
const TSC_ERROR_REGEX = /^(.+)\((\d+),(\d+)\): error TS\d+: (.+)$/;
|
||||
|
||||
export const parseTscOutputLine = (line: string): TypecheckError | null => {
|
||||
const match = line.match(TSC_ERROR_REGEX);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, filePath, lineStr, columnStr, text] = match;
|
||||
|
||||
return {
|
||||
text,
|
||||
file: filePath,
|
||||
line: Number(lineStr),
|
||||
column: Number(columnStr) - 1,
|
||||
};
|
||||
};
|
||||
|
||||
const parseTscOutput = (output: string): TypecheckError[] => {
|
||||
const errors: TypecheckError[] = [];
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
const error = parseTscOutputLine(line);
|
||||
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const runTypecheck = (appPath: string): Promise<TypecheckError[]> => {
|
||||
const tsconfigPath = path.join(appPath, 'tsconfig.json');
|
||||
const tscPath = path.join(appPath, 'node_modules', '.bin', 'tsc');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
tscPath,
|
||||
['--noEmit', '--pretty', 'false', '-p', tsconfigPath],
|
||||
{ cwd: appPath },
|
||||
(_error, stdout, stderr) => {
|
||||
resolve(parseTscOutput(stderr || stdout));
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const toEsbuildErrors = (errors: TypecheckError[]): esbuild.PartialMessage[] =>
|
||||
errors.map((error) => ({
|
||||
text: error.text,
|
||||
location: {
|
||||
file: error.file,
|
||||
line: error.line,
|
||||
column: error.column,
|
||||
lineText: '',
|
||||
length: 0,
|
||||
namespace: '',
|
||||
suggestion: '',
|
||||
},
|
||||
}));
|
||||
|
||||
export const createTypecheckPlugin = (appPath: string): esbuild.Plugin => ({
|
||||
name: 'typecheck',
|
||||
setup: (build) => {
|
||||
build.onStart(async () => {
|
||||
const errors = await runTypecheck(appPath);
|
||||
|
||||
return { errors: toEsbuildErrors(errors) };
|
||||
});
|
||||
},
|
||||
});
|
||||
+1
@@ -13,6 +13,7 @@ const validApplication: ApplicationManifest = {
|
||||
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
|
||||
packageJsonChecksum: '98592af7-4be9-4655-b5c4-9bef307a996c',
|
||||
yarnLockChecksum: '580ee05f-15fe-4146-bac2-6c382483c94e',
|
||||
apiClientChecksum: null,
|
||||
};
|
||||
|
||||
const validField: FieldManifest = {
|
||||
|
||||
@@ -101,6 +101,7 @@ export const buildManifest = async (
|
||||
...extract.config,
|
||||
yarnLockChecksum: null,
|
||||
packageJsonChecksum: null,
|
||||
apiClientChecksum: null,
|
||||
};
|
||||
errors.push(...extract.errors);
|
||||
applicationFilePaths.push(relativePath);
|
||||
@@ -203,7 +204,6 @@ export const buildManifest = async (
|
||||
|
||||
const config: FrontComponentManifest = {
|
||||
...rest,
|
||||
framework: rest.framework ?? 'react',
|
||||
componentName: component.name,
|
||||
sourceComponentPath: relativeFilePath,
|
||||
builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
|
||||
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
|
||||
export const readManifestFromFile = async (
|
||||
appPath: string,
|
||||
): Promise<Manifest | null> => {
|
||||
const outputDir = path.join(appPath, OUTPUT_DIR);
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
const manifestPath = path.join(outputDir, 'manifest.json');
|
||||
|
||||
if (!(await fs.pathExists(manifestPath))) {
|
||||
const { manifest } = await buildManifest(appPath);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
return await fs.readJson(manifestPath);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'crypto';
|
||||
import { relative } from 'path';
|
||||
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
@@ -88,5 +89,30 @@ export const manifestUpdateChecksums = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const apiClientChecksums: string[] = [];
|
||||
|
||||
for (const [builtPath, { fileFolder }] of builtFileInfos.entries()) {
|
||||
const rootBuiltPath = relative(OUTPUT_DIR, builtPath);
|
||||
|
||||
if (
|
||||
fileFolder === FileFolder.Dependencies &&
|
||||
rootBuiltPath.startsWith('api-client/')
|
||||
) {
|
||||
const entry = builtFileInfos.get(builtPath);
|
||||
|
||||
if (entry) {
|
||||
apiClientChecksums.push(entry.checksum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (apiClientChecksums.length > 0) {
|
||||
result.application.apiClientChecksum = crypto
|
||||
.createHash('md5')
|
||||
.update(apiClientChecksums.sort().join(''))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export class ManifestWatcher {
|
||||
});
|
||||
|
||||
this.watcher.on('all', (event, filePath) => {
|
||||
if (event === 'addDir') {
|
||||
if (event === 'addDir' || event === 'unlinkDir') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export class ClientService {
|
||||
authToken?: string;
|
||||
}): Promise<void> {
|
||||
const outputPath = this.resolveGeneratedPath(appPath);
|
||||
const tempPath = `${outputPath}.tmp`;
|
||||
|
||||
const getSchemaResponse = await this.apiService.getSchema({ authToken });
|
||||
|
||||
@@ -33,12 +34,12 @@ export class ClientService {
|
||||
|
||||
const { data: schema } = getSchemaResponse;
|
||||
|
||||
await fs.ensureDir(outputPath);
|
||||
await fs.emptyDir(outputPath);
|
||||
await fs.ensureDir(tempPath);
|
||||
await fs.emptyDir(tempPath);
|
||||
|
||||
await generate({
|
||||
schema,
|
||||
output: outputPath,
|
||||
output: tempPath,
|
||||
scalarTypes: {
|
||||
DateTime: 'string',
|
||||
JSON: 'Record<string, unknown>',
|
||||
@@ -46,7 +47,10 @@ export class ClientService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.injectTwentyClient(outputPath);
|
||||
await this.injectTwentyClient(tempPath);
|
||||
|
||||
await fs.remove(outputPath);
|
||||
await fs.move(tempPath, outputPath);
|
||||
}
|
||||
|
||||
private resolveGeneratedPath(appPath: string): string {
|
||||
@@ -70,22 +74,16 @@ const defaultOptions: ClientOptions = {
|
||||
|
||||
export default class Twenty {
|
||||
private client: Client;
|
||||
private apiUrl: string;
|
||||
private authorizationToken: string;
|
||||
|
||||
constructor(options?: ClientOptions) {
|
||||
const merged: ClientOptions = {
|
||||
this.client = createClient({
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
this.client = createClient(merged);
|
||||
this.apiUrl = merged.url;
|
||||
this.authorizationToken = merged.headers.Authorization;
|
||||
});
|
||||
}
|
||||
|
||||
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
|
||||
@@ -95,41 +93,6 @@ export default class Twenty {
|
||||
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
|
||||
return this.client.mutation(request);
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string = 'application/octet-stream',
|
||||
fileFolder: string = 'Attachment',
|
||||
): Promise<{ path: string; token: string }> {
|
||||
const form = new FormData();
|
||||
|
||||
form.append('operations', JSON.stringify({
|
||||
query: \`mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) { path token }
|
||||
}\`,
|
||||
variables: { file: null, fileFolder },
|
||||
}));
|
||||
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
form.append('0', new Blob([fileBuffer], { type: contentType }), filename);
|
||||
|
||||
|
||||
const response = await fetch(\`\${this.apiUrl}/graphql\`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: this.authorizationToken,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.errors) {
|
||||
throw new GenqlError(result.errors, result.data);
|
||||
}
|
||||
|
||||
return result.data.uploadFile;
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
@@ -7,7 +7,10 @@ import { CheckServerOrchestratorStep } from '@/cli/utilities/dev/orchestrator/st
|
||||
import { EnsureValidTokensOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step';
|
||||
import { GenerateApiClientOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step';
|
||||
import { ResolveApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
|
||||
import { StartWatchersOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
|
||||
import {
|
||||
StartWatchersOrchestratorStep,
|
||||
type FileBuiltEvent,
|
||||
} from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
|
||||
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
|
||||
import { UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
|
||||
import * as fs from 'fs-extra';
|
||||
@@ -69,7 +72,7 @@ export class DevModeOrchestrator {
|
||||
this.startWatchersStep = new StartWatchersOrchestratorStep({
|
||||
...stepDeps,
|
||||
scheduleSync: this.scheduleSync.bind(this),
|
||||
uploadFilesStep: this.uploadFilesStep,
|
||||
onFileBuilt: this.handleFileBuilt.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +93,16 @@ export class DevModeOrchestrator {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private handleFileBuilt(event: FileBuiltEvent): void {
|
||||
if (this.state.steps.uploadFiles.output.fileUploader) {
|
||||
this.uploadFilesStep.uploadFile(
|
||||
event.builtPath,
|
||||
event.sourcePath,
|
||||
event.fileFolder,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
if (this.syncTimer) {
|
||||
clearTimeout(this.syncTimer);
|
||||
@@ -150,11 +163,9 @@ export class DevModeOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.hasObjectsOrFieldsChanged(buildResult.manifest!)) {
|
||||
await this.generateApiClientStep.execute({
|
||||
appPath: this.state.appPath,
|
||||
});
|
||||
}
|
||||
const objectsOrFieldsChanged = this.state.hasObjectsOrFieldsChanged(
|
||||
buildResult.manifest!,
|
||||
);
|
||||
|
||||
await this.uploadFilesStep.waitForUploads();
|
||||
|
||||
@@ -163,6 +174,16 @@ export class DevModeOrchestrator {
|
||||
builtFileInfos: this.state.steps.uploadFiles.output.builtFileInfos,
|
||||
appPath: this.state.appPath,
|
||||
});
|
||||
|
||||
if (objectsOrFieldsChanged) {
|
||||
await this.generateApiClientStep.execute({
|
||||
appPath: this.state.appPath,
|
||||
});
|
||||
|
||||
await this.uploadFilesStep.copyAndUploadApiClientFiles(
|
||||
this.state.appPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async initializePipeline(manifest: Manifest): Promise<boolean> {
|
||||
|
||||
+51
-24
@@ -4,15 +4,23 @@ import {
|
||||
type EsbuildWatcher,
|
||||
} from '@/cli/utilities/build/common/esbuild-watcher';
|
||||
import { FileUploadWatcher } from '@/cli/utilities/build/common/file-upload-watcher';
|
||||
import { TscWatcher } from '@/cli/utilities/build/common/tsc-watcher';
|
||||
import { type TypecheckError } from '@/cli/utilities/build/common/typecheck-plugin';
|
||||
import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-update-checksums';
|
||||
import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
|
||||
import type { Location } from 'esbuild';
|
||||
import { type EventName } from 'chokidar/handler.js';
|
||||
import { ASSETS_DIR } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
export type FileBuiltEvent = {
|
||||
fileFolder: FileFolder;
|
||||
builtPath: string;
|
||||
sourcePath: string;
|
||||
checksum: string;
|
||||
};
|
||||
|
||||
export type StartWatchersOrchestratorStepOutput = {
|
||||
watchersStarted: boolean;
|
||||
};
|
||||
@@ -21,24 +29,25 @@ export class StartWatchersOrchestratorStep {
|
||||
private state: OrchestratorState;
|
||||
private scheduleSync: () => void;
|
||||
private notify: () => void;
|
||||
private uploadFilesStep: UploadFilesOrchestratorStep;
|
||||
private onFileBuilt: (event: FileBuiltEvent) => void;
|
||||
|
||||
private manifestWatcher: ManifestWatcher | null = null;
|
||||
private logicFunctionsWatcher: EsbuildWatcher | null = null;
|
||||
private frontComponentsWatcher: EsbuildWatcher | null = null;
|
||||
private assetWatcher: FileUploadWatcher | null = null;
|
||||
private dependencyWatcher: FileUploadWatcher | null = null;
|
||||
private tscWatcher: TscWatcher | null = null;
|
||||
|
||||
constructor(options: {
|
||||
state: OrchestratorState;
|
||||
scheduleSync: () => void;
|
||||
notify: () => void;
|
||||
uploadFilesStep: UploadFilesOrchestratorStep;
|
||||
onFileBuilt: (event: FileBuiltEvent) => void;
|
||||
}) {
|
||||
this.state = options.state;
|
||||
this.scheduleSync = options.scheduleSync;
|
||||
this.notify = options.notify;
|
||||
this.uploadFilesStep = options.uploadFilesStep;
|
||||
this.onFileBuilt = options.onFileBuilt;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -74,6 +83,8 @@ export class StartWatchersOrchestratorStep {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.tscWatcher?.close();
|
||||
|
||||
await Promise.all([
|
||||
this.manifestWatcher?.close(),
|
||||
this.logicFunctionsWatcher?.close(),
|
||||
@@ -117,32 +128,20 @@ export class StartWatchersOrchestratorStep {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private handleFileBuilt({
|
||||
fileFolder,
|
||||
builtPath,
|
||||
sourcePath,
|
||||
checksum,
|
||||
}: {
|
||||
fileFolder: FileFolder;
|
||||
builtPath: string;
|
||||
sourcePath: string;
|
||||
checksum: string;
|
||||
}): void {
|
||||
private handleFileBuilt(event: FileBuiltEvent): void {
|
||||
this.state.addEvent({
|
||||
message: `Successfully built ${builtPath}`,
|
||||
message: `Successfully built ${event.builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
this.state.steps.uploadFiles.output.builtFileInfos.set(builtPath, {
|
||||
checksum,
|
||||
builtPath,
|
||||
sourcePath,
|
||||
fileFolder,
|
||||
this.state.steps.uploadFiles.output.builtFileInfos.set(event.builtPath, {
|
||||
checksum: event.checksum,
|
||||
builtPath: event.builtPath,
|
||||
sourcePath: event.sourcePath,
|
||||
fileFolder: event.fileFolder,
|
||||
});
|
||||
|
||||
if (this.state.steps.uploadFiles.output.fileUploader) {
|
||||
this.uploadFilesStep.uploadFile(builtPath, sourcePath, fileFolder);
|
||||
}
|
||||
this.onFileBuilt(event);
|
||||
|
||||
this.notify();
|
||||
this.scheduleSync();
|
||||
@@ -153,6 +152,7 @@ export class StartWatchersOrchestratorStep {
|
||||
frontComponents: string[],
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
this.startTscWatcher(),
|
||||
this.startLogicFunctionsWatcher(logicFunctions),
|
||||
this.startFrontComponentsWatcher(frontComponents),
|
||||
this.startAssetWatcher(),
|
||||
@@ -207,4 +207,31 @@ export class StartWatchersOrchestratorStep {
|
||||
|
||||
this.dependencyWatcher.start();
|
||||
}
|
||||
|
||||
private async startTscWatcher(): Promise<void> {
|
||||
this.tscWatcher = new TscWatcher({
|
||||
appPath: this.state.appPath,
|
||||
onErrors: this.handleTypecheckErrors.bind(this),
|
||||
});
|
||||
|
||||
await this.tscWatcher.start();
|
||||
}
|
||||
|
||||
private handleTypecheckErrors(errors: TypecheckError[]): void {
|
||||
if (errors.length === 0) {
|
||||
this.state.addEvent({
|
||||
message: 'Typecheck passed',
|
||||
status: 'success',
|
||||
});
|
||||
} else {
|
||||
this.state.applyStepEvents(
|
||||
errors.map((error) => ({
|
||||
message: `Type error in ${error.file}(${error.line},${error.column}): ${error.text}`,
|
||||
status: 'error' as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
+49
-1
@@ -3,7 +3,13 @@ import {
|
||||
type OrchestratorStateBuiltFileInfo,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { FileUploader } from '@/cli/utilities/file/file-uploader';
|
||||
import { type FileFolder } from 'twenty-shared/types';
|
||||
import crypto from 'crypto';
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { OUTPUT_DIR } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
const API_CLIENT_FILES = ['types.ts', 'schema.ts'];
|
||||
|
||||
export type UploadFilesOrchestratorStepOutput = {
|
||||
fileUploader: FileUploader | null;
|
||||
@@ -103,6 +109,48 @@ export class UploadFilesOrchestratorStep {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async copyAndUploadApiClientFiles(appPath: string): Promise<void> {
|
||||
const generatedDir = join(
|
||||
appPath,
|
||||
'node_modules',
|
||||
'twenty-sdk',
|
||||
'generated',
|
||||
);
|
||||
|
||||
if (!(await fs.pathExists(generatedDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputDir = join(appPath, OUTPUT_DIR, 'api-client');
|
||||
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
for (const fileName of API_CLIENT_FILES) {
|
||||
const absoluteSourcePath = join(generatedDir, fileName);
|
||||
|
||||
if (!(await fs.pathExists(absoluteSourcePath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.copy(absoluteSourcePath, join(outputDir, fileName));
|
||||
|
||||
const content = await fs.readFile(absoluteSourcePath);
|
||||
const checksum = crypto.createHash('md5').update(content).digest('hex');
|
||||
|
||||
const builtPath = join(OUTPUT_DIR, 'api-client', fileName);
|
||||
const sourcePath = join('api-client', fileName);
|
||||
|
||||
this.state.steps.uploadFiles.output.builtFileInfos.set(builtPath, {
|
||||
checksum,
|
||||
builtPath,
|
||||
sourcePath,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
});
|
||||
|
||||
this.uploadFile(builtPath, sourcePath, FileFolder.Dependencies);
|
||||
}
|
||||
}
|
||||
|
||||
private uploadPendingFiles(): void {
|
||||
for (const [
|
||||
builtPath,
|
||||
|
||||
@@ -46,7 +46,6 @@ export default defineLogicFunction({
|
||||
// databaseEventTriggerSettings: {
|
||||
// eventName: 'objectName.created',
|
||||
// },
|
||||
],
|
||||
});
|
||||
`;
|
||||
};
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
|
||||
|
||||
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
|
||||
|
||||
const errorHandler = fn();
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/Other Frameworks',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
onError: errorHandler,
|
||||
applicationAccessToken: 'fake-token',
|
||||
},
|
||||
beforeEach: () => {
|
||||
errorHandler.mockClear();
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
const createComponentStory = (
|
||||
name: string,
|
||||
options?: { runtime?: 'preact'; play?: Story['play'] },
|
||||
): Story => ({
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
`${name}.front-component`,
|
||||
options?.runtime,
|
||||
),
|
||||
},
|
||||
...(options?.play ? { play: options.play } : {}),
|
||||
});
|
||||
|
||||
const createCounterTest =
|
||||
(testIdPrefix: string): Story['play'] =>
|
||||
async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
`${testIdPrefix}-component`,
|
||||
{},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
expect(await canvas.findByText('Count: 0')).toBeVisible();
|
||||
|
||||
const button = await canvas.findByTestId(`${testIdPrefix}-button`);
|
||||
await userEvent.click(button);
|
||||
expect(await canvas.findByText('Count: 1')).toBeVisible();
|
||||
|
||||
await userEvent.click(button);
|
||||
expect(await canvas.findByText('Count: 2')).toBeVisible();
|
||||
};
|
||||
|
||||
const vueTest = createCounterTest('vue');
|
||||
|
||||
export const Vue: Story = createComponentStory('vue-example', {
|
||||
play: vueTest,
|
||||
});
|
||||
|
||||
const svelteTest = createCounterTest('svelte');
|
||||
|
||||
export const Svelte: Story = createComponentStory('svelte-example', {
|
||||
play: svelteTest,
|
||||
});
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<script>
|
||||
let count = 0;
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="svelte-component"
|
||||
style="padding: 24px; background-color: #fff7ed; border: 2px solid #fb923c; border-radius: 12px; font-family: system-ui, sans-serif;"
|
||||
>
|
||||
<h2
|
||||
style="color: #9a3412; font-weight: 700; font-size: 18px; margin-bottom: 12px;"
|
||||
>
|
||||
Svelte Component
|
||||
</h2>
|
||||
<span
|
||||
style="display: inline-block; padding: 2px 8px; background-color: #fb923c; color: white; border-radius: 4px; font-size: 11px; font-weight: 600; margin-bottom: 16px;"
|
||||
>
|
||||
svelte
|
||||
</span>
|
||||
<p
|
||||
data-testid="svelte-count"
|
||||
style="font-size: 28px; font-weight: 800; color: #c2410c; margin-top: 16px; margin-bottom: 16px;"
|
||||
>
|
||||
Count: {count}
|
||||
</p>
|
||||
<button
|
||||
data-testid="svelte-button"
|
||||
on:click={() => count++}
|
||||
style="padding: 10px 20px; background-color: #f97316; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;"
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
// @ts-expect-error -- esbuild-svelte handles .svelte imports at build time
|
||||
import SvelteCounter from './svelte-counter.svelte';
|
||||
|
||||
const SvelteExampleComponent = (container: HTMLElement) => {
|
||||
const component = new SvelteCounter({ target: container });
|
||||
|
||||
return () => component.$destroy();
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-svel-00000000-0000-0000-0000-000000000011',
|
||||
name: 'svelte-example',
|
||||
description: 'A Svelte framework front component example',
|
||||
framework: 'svelte',
|
||||
component: SvelteExampleComponent,
|
||||
});
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import { createApp, defineComponent, h, ref } from 'vue';
|
||||
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
const VueCounter = defineComponent({
|
||||
setup: () => {
|
||||
const count = ref(0);
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'vue-component',
|
||||
style: {
|
||||
padding: '24px',
|
||||
backgroundColor: '#f0fdf4',
|
||||
border: '2px solid #4ade80',
|
||||
borderRadius: '12px',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
},
|
||||
},
|
||||
[
|
||||
h(
|
||||
'h2',
|
||||
{
|
||||
style: {
|
||||
color: '#166534',
|
||||
fontWeight: '700',
|
||||
fontSize: '18px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
},
|
||||
'Vue Component',
|
||||
),
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
backgroundColor: '#4ade80',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
},
|
||||
'vue',
|
||||
),
|
||||
h('br'),
|
||||
h(
|
||||
'p',
|
||||
{
|
||||
'data-testid': 'vue-count',
|
||||
style: {
|
||||
fontSize: '28px',
|
||||
fontWeight: '800',
|
||||
color: '#15803d',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
},
|
||||
`Count: ${count.value}`,
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'vue-button',
|
||||
onClick: () => count.value++,
|
||||
style: {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#22c55e',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
'Increment',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const VueExampleComponent = (container: HTMLElement) => {
|
||||
const app = createApp(VueCounter);
|
||||
|
||||
app.mount(container);
|
||||
|
||||
return () => app.unmount();
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-vue0-00000000-0000-0000-0000-000000000010',
|
||||
name: 'vue-example',
|
||||
description: 'A Vue framework front component example',
|
||||
framework: 'vue',
|
||||
component: VueExampleComponent,
|
||||
});
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
import { HTML_TAG_TO_CUSTOM_ELEMENT_TAG } from '@/sdk/front-component-api/constants/HtmlTagToRemoteComponent';
|
||||
|
||||
// Frameworks like Vue and Svelte call document.createElement /
|
||||
// document.createElementNS directly instead of going through React's JSX
|
||||
// runtime. This patch remaps standard HTML tag names (e.g. "div") to their
|
||||
// remote-dom custom element equivalents (e.g. "html-div") so the host
|
||||
// component registry can resolve them.
|
||||
export const patchDocumentCreateElementForRemoteDom = (): void => {
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
|
||||
document.createElement = ((
|
||||
tagName: string,
|
||||
options?: ElementCreationOptions,
|
||||
): HTMLElement => {
|
||||
const remappedTag =
|
||||
HTML_TAG_TO_CUSTOM_ELEMENT_TAG[tagName.toLowerCase()] ?? tagName;
|
||||
|
||||
return originalCreateElement(remappedTag, options);
|
||||
}) as typeof document.createElement;
|
||||
|
||||
const originalCreateElementNS = document.createElementNS.bind(document);
|
||||
|
||||
document.createElementNS = ((
|
||||
namespaceURI: string | null,
|
||||
qualifiedName: string,
|
||||
options?: ElementCreationOptions,
|
||||
): Element => {
|
||||
const remappedTag =
|
||||
HTML_TAG_TO_CUSTOM_ELEMENT_TAG[qualifiedName.toLowerCase()] ??
|
||||
qualifiedName;
|
||||
|
||||
return originalCreateElementNS(namespaceURI, remappedTag, options);
|
||||
}) as typeof document.createElementNS;
|
||||
};
|
||||
@@ -14,7 +14,6 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { installStyleBridge } from '@/front-component-renderer/polyfills/installStyleBridge';
|
||||
import { installStylePropertyOnRemoteElements } from '@/front-component-renderer/remote/utils/installStylePropertyOnRemoteElements';
|
||||
import { patchDocumentCreateElementForRemoteDom } from '@/front-component-renderer/remote/utils/patchDocumentCreateElementForRemoteDom';
|
||||
import { patchRemoteElementSetAttribute } from '@/front-component-renderer/remote/utils/patchRemoteElementSetAttribute';
|
||||
import { HTML_TAG_TO_CUSTOM_ELEMENT_TAG } from '@/sdk/front-component-api/constants/HtmlTagToRemoteComponent';
|
||||
import { setFrontComponentExecutionContext } from '@/sdk/front-component-api/context/frontComponentContext';
|
||||
@@ -29,7 +28,6 @@ import { setWorkerEnv } from './utils/setWorkerEnv';
|
||||
|
||||
installStylePropertyOnRemoteElements();
|
||||
patchRemoteElementSetAttribute();
|
||||
patchDocumentCreateElementForRemoteDom();
|
||||
|
||||
exposeGlobals({
|
||||
__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__: HTML_TAG_TO_CUSTOM_ELEMENT_TAG,
|
||||
|
||||
@@ -2,5 +2,5 @@ import { type ApplicationManifest } from 'twenty-shared/application';
|
||||
|
||||
export type ApplicationConfig = Omit<
|
||||
ApplicationManifest,
|
||||
'packageJsonChecksum' | 'yarnLockChecksum'
|
||||
'packageJsonChecksum' | 'yarnLockChecksum' | 'apiClientChecksum'
|
||||
>;
|
||||
|
||||
@@ -2,8 +2,6 @@ import { createValidationResult } from '@/sdk';
|
||||
import type { DefineEntity } from '@/sdk/common/types/define-entity.type';
|
||||
import { type FrontComponentConfig } from '@/sdk/front-component-config';
|
||||
|
||||
const VALID_FRAMEWORKS = ['react', 'vue', 'svelte', 'angular'] as const;
|
||||
|
||||
export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
|
||||
config,
|
||||
) => {
|
||||
@@ -17,25 +15,8 @@ export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
|
||||
errors.push('Front component must have a component');
|
||||
}
|
||||
|
||||
const framework = config.framework ?? 'react';
|
||||
|
||||
if (!VALID_FRAMEWORKS.includes(framework)) {
|
||||
errors.push(
|
||||
`Front component framework must be one of: ${VALID_FRAMEWORKS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (framework === 'react' && typeof config.component !== 'function') {
|
||||
errors.push('React front component must be a function component');
|
||||
}
|
||||
|
||||
if (
|
||||
framework !== 'react' &&
|
||||
typeof config.component !== 'function'
|
||||
) {
|
||||
errors.push(
|
||||
'Non-React front component must be a render function (container: HTMLElement) => void',
|
||||
);
|
||||
if (typeof config.component !== 'function') {
|
||||
errors.push('Front component component must be a React component');
|
||||
}
|
||||
|
||||
return createValidationResult({
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { type FrontComponentManifest } from 'twenty-shared/application';
|
||||
|
||||
export type FrontComponentRenderFunction = (
|
||||
container: HTMLElement,
|
||||
) => void | (() => void);
|
||||
|
||||
export type FrontComponentType =
|
||||
| React.ComponentType<any>
|
||||
| FrontComponentRenderFunction;
|
||||
export type FrontComponentType = React.ComponentType<any>;
|
||||
|
||||
export type FrontComponentConfig = Omit<
|
||||
FrontComponentManifest,
|
||||
|
||||
@@ -25,7 +25,6 @@ export { RelationType } from './fields/relation-type';
|
||||
export { validateFields } from './fields/validate-fields';
|
||||
export type {
|
||||
FrontComponentConfig,
|
||||
FrontComponentRenderFunction,
|
||||
FrontComponentType,
|
||||
} from './front-component-config';
|
||||
export { defineLogicFunction } from './logic-functions/define-logic-function';
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
|
||||
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-17:migrate-date-time-is-filter-values',
|
||||
description: 'Migrate DATE_TIME IS operand values from Instant to Plain Date',
|
||||
})
|
||||
export class MigrateDateTimeIsFilterValuesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
protected readonly logger = new Logger(
|
||||
MigrateDateTimeIsFilterValuesCommand.name,
|
||||
{},
|
||||
);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
protected readonly dataSourceService: DataSourceService,
|
||||
@InjectRepository(ViewFilterEntity)
|
||||
private readonly viewFilterRepository: Repository<ViewFilterEntity>,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
) {
|
||||
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun || false;
|
||||
|
||||
this.logger.log(`Processing workspace ${workspaceId}`);
|
||||
|
||||
const viewFilters = await this.viewFilterRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
relations: ['fieldMetadata'],
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
const filtersToUpdate = viewFilters.filter((filter) => {
|
||||
if (
|
||||
!filter.fieldMetadata ||
|
||||
filter.fieldMetadata.type !== FieldMetadataType.DATE_TIME ||
|
||||
filter.operand !== ViewFilterOperand.IS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = filter.value;
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.includes('T');
|
||||
});
|
||||
|
||||
if (filtersToUpdate.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Found ${filtersToUpdate.length} filters to migrate in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`Dry run: would update ${filtersToUpdate.length} filters`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const filter of filtersToUpdate) {
|
||||
try {
|
||||
const newDate = (filter.value as string).split('T')[0];
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(newDate)) {
|
||||
this.logger.warn(
|
||||
`Skipping invalid date extraction for filter ${filter.id}: ${filter.value} -> ${newDate}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.viewFilterRepository.update(filter.id, {
|
||||
value: newDate,
|
||||
});
|
||||
updatedCount++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to migrate filter ${filter.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Migrated ${updatedCount} filters in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
[FeatureFlagKey.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED],
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Enabled IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const cacheKeysToInvalidate: WorkspaceCacheKeyName[] = [
|
||||
'flatViewFilterMaps',
|
||||
];
|
||||
|
||||
this.logger.log(`Invalidating caches: ${cacheKeysToInvalidate.join(' ')}`);
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(
|
||||
workspaceId,
|
||||
cacheKeysToInvalidate,
|
||||
);
|
||||
|
||||
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.logger.log(`Cache flushed`);
|
||||
|
||||
this.logger.log(`Flush cache for workspace ${workspaceId}`);
|
||||
|
||||
await this.workspaceCacheStorageService.flush(workspaceId);
|
||||
}
|
||||
}
|
||||
+5
@@ -7,6 +7,7 @@ import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade
|
||||
import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command';
|
||||
import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command';
|
||||
import { MigrateDateTimeIsFilterValuesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-date-time-is-filter-values.command';
|
||||
import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command';
|
||||
import { MigrateSendEmailRecipientsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command';
|
||||
import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command';
|
||||
@@ -27,6 +28,7 @@ import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/
|
||||
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
|
||||
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module';
|
||||
@@ -49,6 +51,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
|
||||
NoteTargetWorkspaceEntity,
|
||||
TaskTargetWorkspaceEntity,
|
||||
FileEntity,
|
||||
ViewFilterEntity,
|
||||
LogicFunctionEntity,
|
||||
]),
|
||||
DataSourceModule,
|
||||
@@ -75,6 +78,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
|
||||
MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
DeleteFileRecordsAndUpdateTableCommand,
|
||||
MigrateSendEmailRecipientsCommand,
|
||||
MigrateDateTimeIsFilterValuesCommand,
|
||||
MigrateWorkflowCodeStepsCommand,
|
||||
SeedWorkflowV1_16Command,
|
||||
BackfillApplicationPackageFilesCommand,
|
||||
@@ -87,6 +91,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
|
||||
IdentifyWebhookMetadataCommand,
|
||||
MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
MigrateSendEmailRecipientsCommand,
|
||||
MigrateDateTimeIsFilterValuesCommand,
|
||||
DeleteFileRecordsAndUpdateTableCommand,
|
||||
MigrateWorkflowCodeStepsCommand,
|
||||
SeedWorkflowV1_16Command,
|
||||
|
||||
+58
-53
@@ -26,6 +26,7 @@ import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
|
||||
import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
|
||||
@@ -121,63 +122,67 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus
|
||||
);
|
||||
}
|
||||
|
||||
const attachmentRepository =
|
||||
await this.twentyORMGlobalManager.getRepository<AttachmentWorkspaceEntity>(
|
||||
const systemAuthContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
await this.twentyORMGlobalManager.executeInWorkspaceContext(async () => {
|
||||
const attachmentRepository =
|
||||
await this.twentyORMGlobalManager.getRepository<AttachmentWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'attachment',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const noteRepository =
|
||||
await this.twentyORMGlobalManager.getRepository<NoteWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'note',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const taskRepository =
|
||||
await this.twentyORMGlobalManager.getRepository<TaskWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'task',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const fileRepository = this.coreDataSource.getRepository(FileEntity);
|
||||
|
||||
await this.migrateActivityTable({
|
||||
activityRepository: noteRepository,
|
||||
attachmentRepository,
|
||||
fileRepository,
|
||||
activityType: 'note',
|
||||
targetColumnName: 'targetNoteId',
|
||||
workspaceId,
|
||||
'attachment',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
twentyStandardApplication: twentyStandardFlatApplication,
|
||||
fileFieldMetadata: attachmentFileFieldMetadata,
|
||||
isDryRun,
|
||||
});
|
||||
|
||||
const noteRepository =
|
||||
await this.twentyORMGlobalManager.getRepository<NoteWorkspaceEntity>(
|
||||
await this.migrateActivityTable({
|
||||
activityRepository: taskRepository,
|
||||
attachmentRepository,
|
||||
fileRepository,
|
||||
activityType: 'task',
|
||||
targetColumnName: 'targetTaskId',
|
||||
workspaceId,
|
||||
'note',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
twentyStandardApplication: twentyStandardFlatApplication,
|
||||
fileFieldMetadata: attachmentFileFieldMetadata,
|
||||
isDryRun,
|
||||
});
|
||||
|
||||
if (!isDryRun) {
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
[FeatureFlagKey.IS_FILES_FIELD_MIGRATED],
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Completed activity rich text attachment file IDs migration for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const taskRepository =
|
||||
await this.twentyORMGlobalManager.getRepository<TaskWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'task',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const fileRepository = this.coreDataSource.getRepository(FileEntity);
|
||||
|
||||
await this.migrateActivityTable({
|
||||
activityRepository: noteRepository,
|
||||
attachmentRepository,
|
||||
fileRepository,
|
||||
activityType: 'note',
|
||||
targetColumnName: 'targetNoteId',
|
||||
workspaceId,
|
||||
twentyStandardApplication: twentyStandardFlatApplication,
|
||||
fileFieldMetadata: attachmentFileFieldMetadata,
|
||||
isDryRun,
|
||||
});
|
||||
|
||||
await this.migrateActivityTable({
|
||||
activityRepository: taskRepository,
|
||||
attachmentRepository,
|
||||
fileRepository,
|
||||
activityType: 'task',
|
||||
targetColumnName: 'targetTaskId',
|
||||
workspaceId,
|
||||
twentyStandardApplication: twentyStandardFlatApplication,
|
||||
fileFieldMetadata: attachmentFileFieldMetadata,
|
||||
isDryRun,
|
||||
});
|
||||
|
||||
if (!isDryRun) {
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
[FeatureFlagKey.IS_FILES_FIELD_MIGRATED],
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Completed activity rich text attachment file IDs migration for workspace ${workspaceId}`,
|
||||
);
|
||||
}, systemAuthContext);
|
||||
}
|
||||
|
||||
private async migrateActivityTable({
|
||||
|
||||
+116
-5
@@ -1,6 +1,7 @@
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import FileType from 'file-type';
|
||||
import { Command } from 'nest-commander';
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
@@ -20,6 +21,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
@@ -27,6 +29,7 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-18:migrate-workspace-pictures',
|
||||
@@ -44,6 +47,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
@InjectDataSource()
|
||||
private readonly coreDataSource: DataSource,
|
||||
) {
|
||||
@@ -135,14 +139,60 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
|
||||
return;
|
||||
}
|
||||
|
||||
const isInWorkspaceLogo = workspace.logo.startsWith(
|
||||
FileFolder.WorkspaceLogo,
|
||||
);
|
||||
|
||||
const isTwentyIconLogo = workspace.logo.includes('twenty-icons');
|
||||
|
||||
if (!isTwentyIconLogo && !isInWorkspaceLogo) {
|
||||
this.logger.log(
|
||||
`Workspace logo is not a twenty icon or a workspace logo, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Migrating workspace logo for workspace ${workspaceId}: ${workspace.logo}`,
|
||||
);
|
||||
|
||||
if (isInWorkspaceLogo) {
|
||||
await this.migrateWorkspaceLogoFromWorkspaceFolder({
|
||||
workspaceId,
|
||||
logoPath: workspace.logo,
|
||||
isDryRun,
|
||||
workspaceCustomFlatApplication,
|
||||
fileRepository,
|
||||
});
|
||||
}
|
||||
|
||||
if (isTwentyIconLogo) {
|
||||
await this.migrateWorkspaceLogoFromTwentyIcons({
|
||||
workspaceId,
|
||||
logoUrl: workspace.logo,
|
||||
isDryRun,
|
||||
workspaceCustomFlatApplication,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateWorkspaceLogoFromWorkspaceFolder({
|
||||
workspaceId,
|
||||
logoPath,
|
||||
isDryRun,
|
||||
workspaceCustomFlatApplication,
|
||||
fileRepository,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
logoPath: string;
|
||||
isDryRun: boolean;
|
||||
workspaceCustomFlatApplication: FlatApplication;
|
||||
fileRepository: Repository<FileEntity>;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow(
|
||||
workspace.logo,
|
||||
);
|
||||
const { type: fileExtension } =
|
||||
extractFolderPathFilenameAndTypeOrThrow(logoPath);
|
||||
|
||||
const fileId = v4();
|
||||
const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`;
|
||||
@@ -152,7 +202,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
|
||||
await this.fileStorageService.copyLegacy({
|
||||
from: {
|
||||
folderPath: `workspace-${workspaceId}`,
|
||||
filename: workspace.logo,
|
||||
filename: logoPath,
|
||||
},
|
||||
to: {
|
||||
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
|
||||
@@ -181,7 +231,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Migrated workspace logo for workspace ${workspaceId} (${workspace.logo} -> ${newResourcePath})`,
|
||||
`Migrated workspace logo for workspace ${workspaceId} (${logoPath} -> ${newResourcePath})`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
@@ -191,6 +241,67 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateWorkspaceLogoFromTwentyIcons({
|
||||
workspaceId,
|
||||
logoUrl,
|
||||
isDryRun,
|
||||
workspaceCustomFlatApplication,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
logoUrl: string;
|
||||
isDryRun: boolean;
|
||||
workspaceCustomFlatApplication: FlatApplication;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const httpClient = this.secureHttpClientService.getHttpClient();
|
||||
const buffer = await getImageBufferFromUrl(logoUrl, httpClient);
|
||||
|
||||
const type = await FileType.fromBuffer(buffer);
|
||||
|
||||
if (!isDefined(type) || !type.mime.startsWith('image/')) {
|
||||
this.logger.warn(
|
||||
`Unable to detect image type for workspace logo ${logoUrl}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fileId = v4();
|
||||
const newFilename = `${fileId}.${type.ext}`;
|
||||
const newResourcePath = `${newFilename}`;
|
||||
|
||||
if (!isDryRun) {
|
||||
const fileEntity = await this.fileStorageService.writeFile({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
resourcePath: newResourcePath,
|
||||
sourceFile: buffer,
|
||||
mimeType: type.mime,
|
||||
fileId,
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
},
|
||||
});
|
||||
|
||||
await this.workspaceRepository.update(
|
||||
{ id: workspaceId },
|
||||
{ logoFileId: fileEntity.id },
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Migrated workspace logo from twenty-icons for workspace ${workspaceId} (${logoUrl} -> ${FileFolder.CorePicture}/${newResourcePath})`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to migrate workspace logo from twenty-icons for workspace ${workspaceId}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateWorkspaceMemberAvatars({
|
||||
workspaceId,
|
||||
isDryRun,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user