Ws poc (#11293)
related to https://github.com/twentyhq/core-team-issues/issues/601 ## Done - add a `onDbEvent` `Subscription` graphql endpoint to listen to database_event using what we have done with webhooks: - you can subscribe to any `action` (created, updated, ...) for any `objectNameSingular` or a specific `recordId`. Parameters are nullable and treated as wildcards when null. - returns events with following shape ```typescript @Field(() => String) eventId: string; @Field() emittedAt: string; @Field(() => DatabaseEventAction) action: DatabaseEventAction; @Field(() => String) objectNameSingular: string; @Field(() => GraphQLJSON) record: ObjectRecord; @Field(() => [String], { nullable: true }) updatedFields?: string[]; ``` - front provide a componentEffect `<ListenRecordUpdatesEffect />` that listen for an `objectNameSingular`, a `recordId` and a list of `listenedFields`. It subscribes to record updates and updates its apollo cached value for specified `listenedFields` - subscription is protected with credentials ## Result Here is an application with `workflowRun` https://github.com/user-attachments/assets/c964d857-3b54-495f-bf14-587ba26c5a8c --------- Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
@ -453,6 +453,15 @@ export type CustomDomainValidRecords = {
|
||||
records: Array<CustomDomainRecord>;
|
||||
};
|
||||
|
||||
/** Database Event Action */
|
||||
export enum DatabaseEventAction {
|
||||
CREATED = 'CREATED',
|
||||
DELETED = 'DELETED',
|
||||
DESTROYED = 'DESTROYED',
|
||||
RESTORED = 'RESTORED',
|
||||
UPDATED = 'UPDATED'
|
||||
}
|
||||
|
||||
export type DateFilter = {
|
||||
eq?: InputMaybe<Scalars['Date']>;
|
||||
gt?: InputMaybe<Scalars['Date']>;
|
||||
@ -1366,6 +1375,21 @@ export type ObjectStandardOverrides = {
|
||||
translations?: Maybe<Scalars['JSON']>;
|
||||
};
|
||||
|
||||
export type OnDbEventDto = {
|
||||
__typename?: 'OnDbEventDTO';
|
||||
action: DatabaseEventAction;
|
||||
eventDate: Scalars['DateTime'];
|
||||
objectNameSingular: Scalars['String'];
|
||||
record: Scalars['JSON'];
|
||||
updatedFields?: Maybe<Array<Scalars['String']>>;
|
||||
};
|
||||
|
||||
export type OnDbEventInput = {
|
||||
action?: InputMaybe<DatabaseEventAction>;
|
||||
objectNameSingular?: InputMaybe<Scalars['String']>;
|
||||
recordId?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** Onboarding status */
|
||||
export enum OnboardingStatus {
|
||||
COMPLETED = 'COMPLETED',
|
||||
@ -1885,6 +1909,16 @@ export type SubmitFormStepInput = {
|
||||
workflowRunId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
onDbEvent: OnDbEventDto;
|
||||
};
|
||||
|
||||
|
||||
export type SubscriptionOnDbEventArgs = {
|
||||
input: OnDbEventInput;
|
||||
};
|
||||
|
||||
export enum SubscriptionInterval {
|
||||
Day = 'Day',
|
||||
Month = 'Month',
|
||||
@ -2796,6 +2830,13 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never
|
||||
|
||||
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
|
||||
|
||||
export type OnDbEventSubscriptionVariables = Exact<{
|
||||
input: OnDbEventInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type OnDbEventSubscription = { __typename?: 'Subscription', onDbEvent: { __typename?: 'OnDbEventDTO', eventDate: string, action: DatabaseEventAction, objectNameSingular: string, updatedFields?: Array<string> | null, record: any } };
|
||||
|
||||
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
|
||||
|
||||
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
@ -5420,6 +5461,40 @@ export function useGetSsoIdentityProvidersLazyQuery(baseOptions?: Apollo.LazyQue
|
||||
export type GetSsoIdentityProvidersQueryHookResult = ReturnType<typeof useGetSsoIdentityProvidersQuery>;
|
||||
export type GetSsoIdentityProvidersLazyQueryHookResult = ReturnType<typeof useGetSsoIdentityProvidersLazyQuery>;
|
||||
export type GetSsoIdentityProvidersQueryResult = Apollo.QueryResult<GetSsoIdentityProvidersQuery, GetSsoIdentityProvidersQueryVariables>;
|
||||
export const OnDbEventDocument = gql`
|
||||
subscription OnDbEvent($input: OnDbEventInput!) {
|
||||
onDbEvent(input: $input) {
|
||||
eventDate
|
||||
action
|
||||
objectNameSingular
|
||||
updatedFields
|
||||
record
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useOnDbEventSubscription__
|
||||
*
|
||||
* To run a query within a React component, call `useOnDbEventSubscription` and pass it any options that fit your needs.
|
||||
* When your component renders, `useOnDbEventSubscription` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useOnDbEventSubscription({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useOnDbEventSubscription(baseOptions: Apollo.SubscriptionHookOptions<OnDbEventSubscription, OnDbEventSubscriptionVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useSubscription<OnDbEventSubscription, OnDbEventSubscriptionVariables>(OnDbEventDocument, options);
|
||||
}
|
||||
export type OnDbEventSubscriptionHookResult = ReturnType<typeof useOnDbEventSubscription>;
|
||||
export type OnDbEventSubscriptionResult = Apollo.SubscriptionResult<OnDbEventSubscription>;
|
||||
export const DeleteUserAccountDocument = gql`
|
||||
mutation DeleteUserAccount {
|
||||
deleteUser {
|
||||
|
||||
@ -23,6 +23,7 @@ import { cookieStorage } from '~/utils/cookie-storage';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
import { ApolloManager } from '../types/apolloManager.interface';
|
||||
import { loggerLink } from '../utils/loggerLink';
|
||||
import { getTokenPair } from '../utils/getTokenPair';
|
||||
|
||||
const logger = loggerLink(() => 'Twenty');
|
||||
|
||||
@ -55,14 +56,6 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
|
||||
this.currentWorkspaceMember = currentWorkspaceMember;
|
||||
|
||||
const getTokenPair = () => {
|
||||
const stringTokenPair = cookieStorage.getItem('tokenPair');
|
||||
const tokenPair = isDefined(stringTokenPair)
|
||||
? (JSON.parse(stringTokenPair) as AuthTokenPair)
|
||||
: undefined;
|
||||
return tokenPair;
|
||||
};
|
||||
|
||||
const buildApolloLink = (): ApolloLink => {
|
||||
const httpLink = createUploadLink({
|
||||
uri,
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { cookieStorage } from '~/utils/cookie-storage';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { AuthTokenPair } from '~/generated/graphql';
|
||||
|
||||
export const getTokenPair = () => {
|
||||
const stringTokenPair = cookieStorage.getItem('tokenPair');
|
||||
return isDefined(stringTokenPair)
|
||||
? (JSON.parse(stringTokenPair) as AuthTokenPair)
|
||||
: undefined;
|
||||
};
|
||||
@ -15,6 +15,7 @@ import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/com
|
||||
import { WorkflowVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVisualizer';
|
||||
import { WorkflowVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVisualizerEffect';
|
||||
import styled from '@emotion/styled';
|
||||
import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenUpdatesEffect';
|
||||
|
||||
const StyledGreyBox = styled.div<{ isInRightDrawer?: boolean }>`
|
||||
background: ${({ theme, isInRightDrawer }) =>
|
||||
@ -99,6 +100,11 @@ export const CardComponents: Record<CardType, CardComponentType> = {
|
||||
[CardType.WorkflowRunCard]: ({ targetableObject }) => (
|
||||
<>
|
||||
<WorkflowRunVisualizerEffect workflowRunId={targetableObject.id} />
|
||||
<ListenRecordUpdatesEffect
|
||||
objectNameSingular={targetableObject.targetObjectNameSingular}
|
||||
recordId={targetableObject.id}
|
||||
listenedFields={['status', 'output']}
|
||||
/>
|
||||
|
||||
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
|
||||
</>
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useOnDbEvent } from '@/subscription/hooks/useOnDbEvent';
|
||||
import { DatabaseEventAction } from '~/generated/graphql';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type ListenRecordUpdatesEffectProps = {
|
||||
objectNameSingular: string;
|
||||
recordId: string;
|
||||
listenedFields: string[];
|
||||
};
|
||||
|
||||
export const ListenRecordUpdatesEffect = ({
|
||||
objectNameSingular,
|
||||
recordId,
|
||||
listenedFields,
|
||||
}: ListenRecordUpdatesEffectProps) => {
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
useOnDbEvent({
|
||||
input: { recordId, action: DatabaseEventAction.UPDATED },
|
||||
onData: (data) => {
|
||||
const updatedRecord = data.onDbEvent.record;
|
||||
|
||||
const fieldsUpdater = listenedFields.reduce((acc, listenedField) => {
|
||||
if (!isDefined(updatedRecord[listenedField])) {
|
||||
return acc;
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[listenedField]: () => updatedRecord[listenedField],
|
||||
};
|
||||
}, {});
|
||||
|
||||
apolloClient.cache.modify({
|
||||
id: apolloClient.cache.identify({
|
||||
__typename: capitalize(objectNameSingular),
|
||||
id: recordId,
|
||||
}),
|
||||
fields: fieldsUpdater,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ON_DB_EVENT = gql`
|
||||
subscription OnDbEvent($input: OnDbEventInput!) {
|
||||
onDbEvent(input: $input) {
|
||||
eventDate
|
||||
action
|
||||
objectNameSingular
|
||||
updatedFields
|
||||
record
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,58 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { createClient } from 'graphql-sse';
|
||||
import { ON_DB_EVENT } from '@/subscription/graphql/subscriptions/onDbEvent';
|
||||
import { Subscription, SubscriptionOnDbEventArgs } from '~/generated/graphql';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||
|
||||
type OnDbEventArgs = SubscriptionOnDbEventArgs & {
|
||||
skip?: boolean;
|
||||
onData?: (data: Subscription) => void;
|
||||
onError?: (err: any) => void;
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
export const useOnDbEvent = ({
|
||||
onData,
|
||||
onError,
|
||||
onComplete,
|
||||
input,
|
||||
skip = false,
|
||||
}: OnDbEventArgs) => {
|
||||
const tokenPair = getTokenPair();
|
||||
|
||||
const sseClient = useMemo(() => {
|
||||
return createClient({
|
||||
url: `${REACT_APP_SERVER_BASE_URL}/graphql`,
|
||||
headers: {
|
||||
Authorization: tokenPair?.accessToken.token
|
||||
? `Bearer ${tokenPair?.accessToken.token}`
|
||||
: '',
|
||||
},
|
||||
});
|
||||
}, [tokenPair?.accessToken.token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skip === true) {
|
||||
return;
|
||||
}
|
||||
const next = (value: { data: Subscription }) => onData?.(value.data);
|
||||
const error = (err: unknown) => onError?.(err);
|
||||
const complete = () => onComplete?.();
|
||||
const unsubscribe = sseClient.subscribe(
|
||||
{
|
||||
query: ON_DB_EVENT.loc?.source.body || '',
|
||||
variables: { input },
|
||||
},
|
||||
{
|
||||
next,
|
||||
error,
|
||||
complete,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [input, onComplete, onData, onError, skip, sseClient]);
|
||||
};
|
||||
Reference in New Issue
Block a user