Compare commits

..
Author SHA1 Message Date
Sonarly Claude CodeandClaude Opus 4.6 7d62f50f5e fix: skip fields with missing metadata in formatData instead of throwing
When a workspace hasn't run the v1.18 migration yet, the throttleRetryAfter
field metadata doesn't exist. The formatData utility was throwing an error
when encountering fields without metadata, crashing the entire message
import job. Changed to gracefully skip fields without metadata, as these
fields won't exist in the database either during the migration transition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:22:07 +00:00
martmullandGitHub 53c314d0fa 2094 extensibility define postinstall orand preinstall function to run in application (#18037)
- add a new optional key `postInstallLogicFunctionUniversalIdentifier`
in applicationConfig
- seed postInstall function in create-twenty-app
- update execute:function options
- update doc
2026-02-18 15:38:22 +00:00
618df704e6 [Chore] : Generate migration for DATE_TIME to DATE for DATE_TIME + IS operand filters (#17564)
migration command in response to the fix :
https://github.com/twentyhq/twenty/pull/17529 for the issue
https://github.com/twentyhq/core-team-issues/issues/2027

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
2026-02-18 15:15:38 +00:00
EtienneandGitHub 058489b5cc Fixes - Workspace logo migration (#18035)
- Update migration command to handle case where workspace logo is
originated from workspace email and point to twenty-icons.com
- Update same logic for new workspaces
- Add feature-flag for all newly created workspaces
2026-02-18 16:35:48 +01:00
3bd431e95d Use proper PostgreSQL identifier/literal escaping in workspace DDL (#18024)
## Summary

- Replace the character-stripping approach (`removeSqlDDLInjection`)
with standard PostgreSQL `escapeIdentifier` and `escapeLiteral`
functions across all workspace schema manager services
- Add missing identifier escaping to `createForeignKey` (was the only
method in the FK manager without it)
- Add allowlist validation for index WHERE clauses and FK action types
- Harden tsvector expression builder with proper identifier quoting

## Context

The workspace schema managers build DDL dynamically from metadata (table
names, column names, enum values, etc.). The previous approach stripped
all non-alphanumeric characters — safe but lossy (silently corrupts
values with legitimate special characters). The new approach uses
PostgreSQL's standard escaping:

- **Identifiers**: double internal `"` and wrap → `"my""table"` (same
algorithm as `pg` driver's `escapeIdentifier`)
- **Literals**: double internal `'` and wrap → `'it''s a value'` (same
algorithm as `pg` driver's `escapeLiteral`)

`removeSqlDDLInjection` is kept only for name generation (e.g.,
`computePostgresEnumName`) where stripping to `[a-zA-Z0-9_]` is the
correct behavior.

## Files changed

| File | What |
|------|------|
| `remove-sql-injection.util.ts` | Added `escapeIdentifier` +
`escapeLiteral` |
| `validate-index-where-clause.util.ts` | New — allowlist for partial
index WHERE clauses |
| 5 schema manager services | Replaced strip+manual-quote with
`escapeIdentifier`/`escapeLiteral` |
| `build-sql-column-definition.util.ts` | `escapeIdentifier` for column
names, validated `generatedType` |
| `sanitize-default-value.util.ts` | `escapeLiteral` instead of
stripping |
| `serialize-default-value.util.ts` | `escapeLiteral` for values,
`escapeIdentifier` for enum casts |
| `get-ts-vector-column-expression.util.ts` | `escapeIdentifier` for
field names in expressions |
| `sanitize-default-value.util.spec.ts` | Updated tests for escape
behavior |

## Test plan

- [x] All 64 existing tests pass across 6 test suites
- [x] `lint:diff-with-main` passes
- [x] TypeScript typecheck — no new errors
- [ ] Verify workspace sync-metadata still works end-to-end
- [ ] Verify custom object/field creation works
- [ ] Verify enum field option changes work


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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 14:24:10 +00:00
f4a61f26c0 Replace generic "Unknown error" messages with descriptive error details (#18019)
## Summary

- Replace all generic `"Unknown error"` fallback messages across the
server codebase with messages that include the actual error details
- The most impactful change is in `guard-redirect.service.ts`, which
handles OAuth redirect errors — non-`AuthException` errors (e.g.,
passport state verification failures) now show `"Authentication error:
<actual message>"` instead of the opaque `"Unknown error"`
- Gmail/Google error handler services now include the error message in
the thrown exception instead of discarding it
- Other catch blocks (workflow delay resume, migration runner rollback,
code interpreter, marketplace) now use `String(error)` for non-Error
objects instead of a static fallback

Fixes the class of issues reported in
https://github.com/twentyhq/twenty/issues/17812, where a user saw
"Unknown error" during Google OAuth and had no way to diagnose the root
cause (which turned out to be a session cookie / SSL configuration
issue).

## Test plan

- [ ] Verify OAuth error flows (e.g., Google Auth with misconfigured
callback URL) now display the actual error message on the `/verify` page
instead of "Unknown error"
- [ ] Verify Gmail sync error handling still correctly classifies and
re-throws errors with descriptive messages
- [ ] Verify workflow delay resume failures include the error details in
the workflow run status


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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 14:12:47 +00:00
8e6b267ff3 Allow DATE_TIME IS operand to filter on a whole day (#17529)
Fixes:  https://github.com/twentyhq/core-team-issues/issues/2027

We've replaced the DATE_TIME picker with DATE picker, and changed the
logic to filter for complete day period.



https://github.com/user-attachments/assets/ba7e1078-bab3-4c62-a803-d6a851f14b7d

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
2026-02-18 13:44:05 +00:00
neo773andGitHub 88146c2170 Fix Gmail thread awareness for custom labels (#18031)
This fixes two edge cases for Gmail

- When policy was set to `SELECTED_FOLDERS` excluding root INBOX, it
missed label changes, so messages with newly applied labels weren't
imported until a full resync.

- Gmail thread replies by default by default do no inherit parent
message's label properties so thread context was also lost because only
individually labeled messages were returned, dropping earlier parts of
the conversation.

Fixed by subscribing to `labelAdded`/`labelRemoved` history events and
fetching full thread context when at least one message in a thread
carries a synced label. `ALL_FOLDERS` path is untouched.
2026-02-18 14:10:38 +01:00
EtienneandGitHub 3706da9bcb v1.18 - Fix command (#18032)
Same as here https://github.com/twentyhq/twenty/pull/18016
2026-02-18 13:52:13 +01:00
9bac8f15d4 i18n - translations (#18029)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-02-18 13:26:54 +01:00
Charles BochetandGitHub 7332379d26 Improve API Client usage and add Typescript check (#18023)
## Summary


https://github.com/user-attachments/assets/1e75cc9d-d9d2-4ef2-99f9-34450f5d8de7



Add background incremental type checking (`tsc --watch`) to the SDK dev
mode, so type regressions are caught when the generated API client
changes — without requiring a full rebuild of source files.

Previously, removing a field from the data model would regenerate the
API client, but existing front components/logic functions referencing
the removed field wouldn't surface type errors (since their source
didn't change, esbuild wouldn't rebuild them).

## What changed

- **Background `tsc --watch`**: a long-lived TypeScript watcher runs
alongside esbuild watchers, incrementally re-checking all files when the
generated client changes. Only logs on state transitions (errors appear
/ errors clear) to stay quiet.
- **Atomic client generation**: API client is now generated into a temp
directory and swapped in atomically, avoiding a race condition where
`tsc --watch` could see an empty `generated/` directory
mid-regeneration.
- **Step decoupling**: orchestrator steps no longer receive
`uploadFilesStep` directly. Instead, they use callbacks (`onFileBuilt`,
`onApiClientGenerated`), and each step manages its own `builtFileInfos`
state.
- **`apiClientChecksum` omitted from `ApplicationConfig`**: it's a
build-time computed value, same as `packageJsonChecksum`.
<img width="327" height="177" alt="image"
src="https://github.com/user-attachments/assets/02bd25bb-fa41-42b0-8d96-01c51bd4580c"
/>

<img width="529" height="452" alt="image"
src="https://github.com/user-attachments/assets/61f6e968-365b-4a5b-8f2b-a8419d6b1bd3"
/>
2026-02-18 13:26:30 +01:00
Raphaël BosiandGitHub 2455c859b4 Add SSE for metadata and plug front components (#17998)
Create the necessary tooling to listen to metadata events and plug it to
the front components. Now we have a hot reload like experience when we
edit a component in an app.

## Backend

- Split `EventWithQueryIds` into `ObjectRecordEventWithQueryIds` and
`MetadataEventWithQueryIds`
- Publish metadata event batches to active SSE streams in
`MetadataEventsToDbListener`

## Frontend

- Create a metadata event dispatching pipeline: SSE metadata events are
grouped by metadata name, transformed into
`MetadataOperationBrowserEventDetail` objects, and dispatched as browser
`CustomEvents`
- Add `useListenToMetadataOperationBrowserEvent` hook for consuming
metadata operation events filtered by metadata name and operation type
- Rename `useListenToObjectRecordEventsForQuery` to
`useListenToEventsForQuery`, now accepting both
`RecordGqlOperationSignature` and `MetadataGqlOperationSignature`
- Implement `useOnFrontComponentUpdated` which subscribes to front
component metadata events and updates the Apollo cache when the
component is modified
- Add `builtComponentChecksum` to the front component query and appends
it to the component URL for browser cache invalidation
2026-02-18 11:26:20 +00:00
martmullandGitHub e3753bf822 App feedbacks (#18028)
as title
2026-02-18 11:04:54 +00:00
235 changed files with 3876 additions and 1407 deletions
+4
View File
@@ -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 -1
View File
@@ -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**: keyvalue 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
@@ -0,0 +1,2 @@
export const METADATA_OPERATION_BROWSER_EVENT_NAME =
'metadata-operation-browser-event';
@@ -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]);
};
@@ -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;
};
@@ -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>;
};
@@ -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,
}),
);
};
@@ -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,
@@ -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) ||
@@ -6,6 +6,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
id
name
applicationId
builtComponentChecksum
applicationTokenPair {
applicationAccessToken {
token
@@ -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,
});
};
@@ -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}`;
};
@@ -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,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';
@@ -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';
@@ -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';
@@ -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';
@@ -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}
@@ -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 />
@@ -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();
}
}
};
@@ -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);
@@ -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';
@@ -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,
@@ -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,
@@ -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)) {
@@ -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',
},
},
],
@@ -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,
@@ -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 = () => {
@@ -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;
@@ -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
}
}
}
`;
@@ -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 };
};
@@ -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({
@@ -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 }) =>
@@ -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: [],
@@ -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: [],
@@ -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);
}
}
@@ -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,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 = () => {
@@ -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,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,
+6 -2
View File
@@ -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 -3
View File
@@ -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> => {
@@ -28,6 +28,7 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
packageJsonChecksum: '2851d0e2c3621a57e1fd103a245b6fde',
apiClientChecksum: null,
},
frontComponents: [
{
@@ -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;
}
@@ -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,
),
@@ -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) };
});
},
});
@@ -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> {
@@ -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();
}
}
@@ -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',
// },
],
});
`;
};
@@ -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,
});
@@ -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>
@@ -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,
});
@@ -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,
});
@@ -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,
-1
View File
@@ -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';
@@ -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);
}
}
@@ -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,
@@ -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({
@@ -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