Untitled records for CreatedBy (#11914)

# Display "Soft-Deleted Workspace Members" in Actor Field Display

Reminder of the issue :
<img width="154" alt="Screenshot 2025-05-07 at 12 11 59"
src="https://github.com/user-attachments/assets/168f8743-2684-4d9a-b1a4-e86bb335f7a4"
/>

- `ActorFieldDisplay` component : display soft-deleted members
- `UserService` includes soft-deleted records when fetching workspace
members. This is the tricky part : do we want that for all workspace
members or maybe i could create another property dedicated to workspace
members and softdeleted ones. To be discussed

Result looks like this (we loose the source and the context in this
impleentation)
<img width="114" alt="Screenshot 2025-05-07 at 12 05 28"
src="https://github.com/user-attachments/assets/3cdddd91-454f-4e96-8d6d-6fe671658945"
/>


Fixes https://github.com/twentyhq/twenty/issues/11870


Another way we could also get into :
We could also, when a workspace user is softDeleted, change the current
implementation : we could avoid to delete the ActorMetadata like
CreatedByName (and context and source) in the "Person" table.

It would look more like this
<img width="111" alt="Screenshot 2025-05-07 at 12 06 16"
src="https://github.com/user-attachments/assets/daa4ece2-200a-41f0-ba24-177375c72983"
/>

However, this implementation is requires more work, and IMO harder to
maintain since is decouples completely the record from the workspace
member. This could be an issue in case we want tohard delete a user, or
decide another logic to display the Actor name.

Since the usecase should be pretty rare, I chose the first one but
willing to discuss it

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Guillim
2025-05-12 15:54:56 +02:00
committed by GitHub
parent 679530020c
commit a942642b83
17 changed files with 294 additions and 46 deletions

View File

@ -591,6 +591,15 @@ export type DeleteWorkflowVersionStepInput = {
workflowVersionId: Scalars['String']['input'];
};
export type DeletedWorkspaceMember = {
__typename?: 'DeletedWorkspaceMember';
avatarUrl?: Maybe<Scalars['String']['output']>;
id: Scalars['UUID']['output'];
name: FullName;
userEmail: Scalars['String']['output'];
userWorkspaceId?: Maybe<Scalars['String']['output']>;
};
/** Schema update on a table */
export enum DistantTableUpdate {
COLUMNS_ADDED = 'COLUMNS_ADDED',
@ -2426,6 +2435,7 @@ export type User = {
currentWorkspace?: Maybe<Workspace>;
defaultAvatarUrl?: Maybe<Scalars['String']['output']>;
deletedAt?: Maybe<Scalars['DateTime']['output']>;
deletedWorkspaceMembers?: Maybe<Array<DeletedWorkspaceMember>>;
disabled?: Maybe<Scalars['Boolean']['output']>;
email: Scalars['String']['output'];
firstName: Scalars['String']['output'];

View File

@ -522,6 +522,15 @@ export type DeleteWorkflowVersionStepInput = {
workflowVersionId: Scalars['String'];
};
export type DeletedWorkspaceMember = {
__typename?: 'DeletedWorkspaceMember';
avatarUrl?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
name: FullName;
userEmail: Scalars['String'];
userWorkspaceId?: Maybe<Scalars['String']>;
};
/** Schema update on a table */
export enum DistantTableUpdate {
COLUMNS_ADDED = 'COLUMNS_ADDED',
@ -2213,6 +2222,7 @@ export type User = {
currentWorkspace?: Maybe<Workspace>;
defaultAvatarUrl?: Maybe<Scalars['String']>;
deletedAt?: Maybe<Scalars['DateTime']>;
deletedWorkspaceMembers?: Maybe<Array<DeletedWorkspaceMember>>;
disabled?: Maybe<Scalars['Boolean']>;
email: Scalars['String'];
firstName: Scalars['String'];
@ -2915,7 +2925,7 @@ export type OnDbEventSubscriptionVariables = Exact<{
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 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, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, 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; }>;
@ -2932,7 +2942,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __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 GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __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, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, 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 ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@ -3030,6 +3040,8 @@ export type GetWorkspaceInvitationsQueryVariables = Exact<{ [key: string]: never
export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> };
export type DeletedWorkspaceMemberQueryFragmentFragment = { __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } };
export type WorkspaceMemberQueryFragmentFragment = { __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 } };
export type ActivateWorkspaceMutationVariables = Exact<{
@ -3203,6 +3215,17 @@ export const WorkspaceMemberQueryFragmentFragmentDoc = gql`
timeFormat
}
`;
export const DeletedWorkspaceMemberQueryFragmentFragmentDoc = gql`
fragment DeletedWorkspaceMemberQueryFragment on DeletedWorkspaceMember {
id
name {
firstName
lastName
}
avatarUrl
userEmail
}
`;
export const RoleFragmentFragmentDoc = gql`
fragment RoleFragment on Role {
id
@ -3233,6 +3256,9 @@ export const UserQueryFragmentFragmentDoc = gql`
workspaceMembers {
...WorkspaceMemberQueryFragment
}
deletedWorkspaceMembers {
...DeletedWorkspaceMemberQueryFragment
}
currentUserWorkspace {
settingsPermissions
objectRecordsPermissions
@ -3304,6 +3330,7 @@ export const UserQueryFragmentFragmentDoc = gql`
userVars
}
${WorkspaceMemberQueryFragmentFragmentDoc}
${DeletedWorkspaceMemberQueryFragmentFragmentDoc}
${RoleFragmentFragmentDoc}`;
export const GetTimelineCalendarEventsFromCompanyIdDocument = gql`
query GetTimelineCalendarEventsFromCompanyId($companyId: UUID!, $page: Int!, $pageSize: Int!) {

View File

@ -2,13 +2,22 @@ import React from 'react';
import { useRecoilValue } from 'recoil';
import { AuthContext } from '@/auth/contexts/AuthContext';
import { currentWorkspaceDeletedMembersState } from '@/auth/states/currentWorkspaceDeletedMembersStates';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
export const AuthProvider = ({ children }: React.PropsWithChildren) => {
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const currentWorkspaceDeletedMembers = useRecoilValue(
currentWorkspaceDeletedMembersState,
);
return (
<AuthContext.Provider value={{ currentWorkspaceMembers }}>
<AuthContext.Provider
value={{
currentWorkspaceMembers,
currentWorkspaceDeletedMembers,
}}
>
{children}
</AuthContext.Provider>
);

View File

@ -1,8 +1,10 @@
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { createContext } from 'react';
import { DeletedWorkspaceMember } from '~/generated-metadata/graphql';
export type AuthContextType = {
currentWorkspaceMembers: CurrentWorkspaceMember[];
currentWorkspaceDeletedMembers: DeletedWorkspaceMember[];
};
export const AuthContext = createContext<AuthContextType>(

View File

@ -0,0 +1,9 @@
import { createState } from 'twenty-ui/utilities';
import { DeletedWorkspaceMember } from '~/generated-metadata/graphql';
export const currentWorkspaceDeletedMembersState = createState<
DeletedWorkspaceMember[]
>({
key: 'currentWorkspaceDeletedMembersState',
defaultValue: [],
});

View File

@ -1,27 +1,22 @@
import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty';
import { useActorFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useActorFieldDisplay';
import { ActorDisplay } from '@/ui/field/display/components/ActorDisplay';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
export const ActorFieldDisplay = () => {
const { fieldValue } = useActorFieldDisplay();
const name = !fieldValue.workspaceMemberId
? fieldValue.name
: [
fieldValue.workspaceMember?.name.firstName,
fieldValue.workspaceMember?.name.lastName,
]
.filter(isNonEmptyString)
.join(' ');
const actorFieldDisplay = useActorFieldDisplay();
const displayActorField = !useIsFieldEmpty();
if (!isDefined(actorFieldDisplay)) {
return null;
}
const { fieldValue, name, avatarUrl } = actorFieldDisplay;
return displayActorField ? (
<ActorDisplay
name={name}
source={fieldValue.source}
avatarUrl={fieldValue.workspaceMember?.avatarUrl}
avatarUrl={avatarUrl}
workspaceMemberId={fieldValue.workspaceMemberId}
context={fieldValue.context}
/>

View File

@ -1,15 +1,17 @@
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay';
import { Meta, StoryObj } from '@storybook/react';
import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { ComponentDecorator } from 'twenty-ui/testing';
import { AuthContextDecorator } from '~/testing/decorators/AuthContextDecorator';
import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/ActorFieldDisplay',
decorators: [
AuthContextDecorator,
MemoryRouterDecorator,
ChipGeneratorsDecorator,
getFieldDecorator('company', 'createdBy', {

View File

@ -4,12 +4,20 @@ import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadat
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { AuthContext } from '@/auth/contexts/AuthContext';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceMember } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext';
export const useActorFieldDisplay = () => {
export type ActorFieldDisplayValue = {
fieldValue: FieldActorValue;
name: string;
} & Pick<WorkspaceMember, 'avatarUrl'>;
export const useActorFieldDisplay = (): ActorFieldDisplayValue | undefined => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const { currentWorkspaceMembers } = useContext(AuthContext);
const { currentWorkspaceDeletedMembers, currentWorkspaceMembers } =
useContext(AuthContext);
const fieldName = fieldDefinition.metadata.fieldName;
@ -17,14 +25,27 @@ export const useActorFieldDisplay = () => {
recordId,
fieldName,
);
if (!isDefined(fieldValue)) {
return undefined;
}
const relatedWorkspaceMember = [
...currentWorkspaceDeletedMembers,
...currentWorkspaceMembers,
].find(
(workspaceMember) => workspaceMember.id === fieldValue.workspaceMemberId,
);
if (!isDefined(relatedWorkspaceMember)) {
return {
fieldValue,
name: fieldValue.name,
};
}
const { name, avatarUrl } = relatedWorkspaceMember;
return {
fieldDefinition,
fieldValue: {
...fieldValue,
workspaceMember: currentWorkspaceMembers?.find(
(member) => member.id === fieldValue?.workspaceMemberId,
),
},
fieldValue,
name: `${name.firstName} ${name.lastName}`,
avatarUrl: avatarUrl,
};
};

View File

@ -3,6 +3,7 @@ import { useRecoilState, useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceDeletedMembersState } from '@/auth/states/currentWorkspaceDeletedMembersStates';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@ -45,6 +46,9 @@ export const UserProviderEffect = () => {
const setCurrentWorkspaceMembers = useSetRecoilState(
currentWorkspaceMembersState,
);
const setCurrentWorkspaceMembersWithDeleted = useSetRecoilState(
currentWorkspaceDeletedMembersState,
);
const { loading: queryLoading, data: queryData } = useGetCurrentUserQuery({
skip:
@ -77,6 +81,7 @@ export const UserProviderEffect = () => {
const {
workspaceMember,
workspaceMembers,
deletedWorkspaceMembers,
workspaces: userWorkspaces,
} = queryData.currentUser;
@ -122,6 +127,10 @@ export const UserProviderEffect = () => {
);
}
if (isDefined(deletedWorkspaceMembers)) {
setCurrentWorkspaceMembersWithDeleted(deletedWorkspaceMembers);
}
if (isDefined(userWorkspaces)) {
const workspaces = userWorkspaces
.map(({ workspace }) => workspace)
@ -141,6 +150,7 @@ export const UserProviderEffect = () => {
queryData?.currentUser,
setIsCurrentUserLoaded,
setDateTimeFormat,
setCurrentWorkspaceMembersWithDeleted,
]);
return <></>;

View File

@ -1,4 +1,5 @@
import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment';
import { DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment';
import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment';
import { gql } from '@apollo/client';
@ -19,6 +20,9 @@ export const USER_QUERY_FRAGMENT = gql`
workspaceMembers {
...WorkspaceMemberQueryFragment
}
deletedWorkspaceMembers {
...DeletedWorkspaceMemberQueryFragment
}
currentUserWorkspace {
settingsPermissions
objectRecordsPermissions
@ -91,4 +95,5 @@ export const USER_QUERY_FRAGMENT = gql`
}
${WORKSPACE_MEMBER_QUERY_FRAGMENT}
${DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT}
`;

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT = gql`
fragment DeletedWorkspaceMemberQueryFragment on DeletedWorkspaceMember {
id
name {
firstName
lastName
}
avatarUrl
userEmail
}
`;

View File

@ -0,0 +1,16 @@
import { Decorator } from '@storybook/react';
import { AuthContext } from '@/auth/contexts/AuthContext';
export const AuthContextDecorator: Decorator = (Story) => {
return (
<AuthContext.Provider
value={{
currentWorkspaceMembers: [],
currentWorkspaceDeletedMembers: [],
}}
>
<Story />
</AuthContext.Provider>
);
};

View File

@ -0,0 +1,24 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FullName } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
@ObjectType()
export class DeletedWorkspaceMember {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => FullName)
name: FullName;
@Field({ nullable: false })
userEmail: string;
@Field(() => String, { nullable: true })
avatarUrl: string | null;
@Field(() => String, { nullable: true })
userWorkspaceId: string | null;
}

View File

@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { DeletedWorkspaceMember } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class DeletedWorkspaceMemberTranspiler {
constructor(private readonly fileService: FileService) {}
generateSignedAvatarUrl({
workspaceId,
workspaceMember,
}: {
workspaceMember: Pick<WorkspaceMemberWorkspaceEntity, 'avatarUrl' | 'id'>;
workspaceId: string;
}): string {
const avatarUrlToken = this.fileService.encodeFileToken({
workspaceMemberId: workspaceMember.id,
workspaceId: workspaceId,
});
return `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
}
toDeletedWorkspaceMemberDto(
workspaceMember: WorkspaceMemberWorkspaceEntity,
userWorkspaceId?: string,
): DeletedWorkspaceMember {
const {
avatarUrl: avatarUrlFromEntity,
id,
name,
userEmail,
} = workspaceMember;
const avatarUrl = userWorkspaceId
? this.generateSignedAvatarUrl({
workspaceId: userWorkspaceId,
workspaceMember: {
avatarUrl: avatarUrlFromEntity,
id,
},
})
: null;
return {
id,
name,
userEmail,
avatarUrl,
userWorkspaceId: userWorkspaceId ?? null,
} satisfies DeletedWorkspaceMember;
}
toDeletedWorkspaceMemberDtos(
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
userWorkspaceId?: string,
): DeletedWorkspaceMember[] {
return workspaceMembers.map((workspaceMember) =>
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
);
}
}

View File

@ -4,7 +4,7 @@ import assert from 'assert';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { IsNull, Not, Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import {
@ -63,7 +63,7 @@ export class UserService extends TypeOrmQueryService<User> {
});
}
async loadWorkspaceMembers(workspace: Workspace) {
async loadWorkspaceMembers(workspace: Workspace, withDeleted = false) {
if (!isWorkspaceActiveOrSuspended(workspace)) {
return [];
}
@ -74,7 +74,24 @@ export class UserService extends TypeOrmQueryService<User> {
'workspaceMember',
);
return workspaceMemberRepository.find();
return await workspaceMemberRepository.find({ withDeleted: withDeleted });
}
async loadDeletedWorkspaceMembersOnly(workspace: Workspace) {
if (!isWorkspaceActiveOrSuspended(workspace)) {
return [];
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspace.id,
'workspaceMember',
);
return await workspaceMemberRepository.find({
where: { deletedAt: Not(IsNull()) },
withDeleted: true,
});
}
private async deleteUserFromWorkspace({

View File

@ -24,6 +24,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { DeletedWorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service';
import { userAutoResolverOpts } from './user.auto-resolver-opts';
@ -53,7 +54,12 @@ import { UserService } from './services/user.service';
PermissionsModule,
UserWorkspaceModule,
],
exports: [UserService],
providers: [UserService, UserResolver, TypeORMService],
exports: [UserService, DeletedWorkspaceMemberTranspiler],
providers: [
UserService,
UserResolver,
TypeORMService,
DeletedWorkspaceMemberTranspiler,
],
})
export class UserModule {}

View File

@ -34,7 +34,9 @@ import {
} from 'src/engine/core-modules/onboarding/onboarding.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { DeletedWorkspaceMember } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { DeletedWorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@ -79,6 +81,7 @@ export class UserResolver {
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly userRoleService: UserRoleService,
private readonly permissionsService: PermissionsService,
private readonly deletedWorkspaceMemberTranspiler: DeletedWorkspaceMemberTranspiler,
) {}
@Query(() => User)
@ -187,7 +190,7 @@ export class UserResolver {
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
}
// TODO: Fix typing disrepency between Entity and DTO
// TODO Refactor to be transpiled to WorkspaceMember instead
return workspaceMember as WorkspaceMember | null;
}
@ -195,17 +198,15 @@ export class UserResolver {
nullable: true,
})
async workspaceMembers(
@Parent() user: User,
@Parent() _user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<WorkspaceMember[]> {
const workspaceMemberEntities =
await this.userService.loadWorkspaceMembers(workspace);
const workspaceMemberEntities = await this.userService.loadWorkspaceMembers(
workspace,
false,
);
const workspaceMembers: WorkspaceMember[] = [];
let userWorkspacesByUserId = new Map<string, UserWorkspace>();
let rolesByUserWorkspaces = new Map<string, RoleDTO[]>();
const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
userId: In(workspaceMemberEntities.map((entity) => entity.userId)),
@ -213,21 +214,20 @@ export class UserResolver {
},
});
userWorkspacesByUserId = new Map(
const userWorkspacesByUserId = new Map<string, UserWorkspace>(
userWorkspaces.map((userWorkspace) => [
userWorkspace.userId,
userWorkspace,
]),
);
rolesByUserWorkspaces = await this.userRoleService.getRolesByUserWorkspaces(
{
const rolesByUserWorkspaces: Map<string, RoleDTO[]> =
await this.userRoleService.getRolesByUserWorkspaces({
userWorkspaceIds: userWorkspaces.map(
(userWorkspace) => userWorkspace.id,
),
workspaceId: workspace.id,
},
);
});
for (const workspaceMemberEntity of workspaceMemberEntities) {
if (workspaceMemberEntity.avatarUrl) {
@ -239,12 +239,14 @@ export class UserResolver {
workspaceMemberEntity.avatarUrl = `${workspaceMemberEntity.avatarUrl}?token=${avatarUrlToken}`;
}
// TODO Refactor to be transpiled to WorkspaceMember instead
const workspaceMember = workspaceMemberEntity as WorkspaceMember;
const userWorkspace = userWorkspacesByUserId.get(
workspaceMemberEntity.userId,
);
// TODO Refactor should not throw ? typed as nullable ?
if (!userWorkspace) {
throw new Error('User workspace not found');
}
@ -279,6 +281,22 @@ export class UserResolver {
return workspaceMembers;
}
@ResolveField(() => [DeletedWorkspaceMember], {
nullable: true,
})
async deletedWorkspaceMembers(
@Parent() _user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<DeletedWorkspaceMember[]> {
const workspaceMemberEntities =
await this.userService.loadDeletedWorkspaceMembersOnly(workspace);
return this.deletedWorkspaceMemberTranspiler.toDeletedWorkspaceMemberDtos(
workspaceMemberEntities,
workspace.id,
);
}
@ResolveField(() => String, {
nullable: true,
})