feat: add new ACTOR field type and createdBy standard fields (#6324)

This pull request introduces a new `FieldMetadataType` called `ACTOR`.
The primary objective of this new type is to add an extra column to the
following objects: `person`, `company`, `opportunity`, `note`, `task`,
and all custom objects.

This composite type contains three properties:

- `source`
    ```typescript
    export enum FieldActorSource {
      EMAIL = 'EMAIL',
      CALENDAR = 'CALENDAR',
      API = 'API',
      IMPORT = 'IMPORT',
      MANUAL = 'MANUAL',
    }
    ```
- `workspaceMemberId`
- This property can be `undefined` in some cases and refers to the
member who created the record.
- `name`
- Serves as a fallback if the `workspaceMember` is deleted and is used
for other source types like `API`.

### Functionality

The pre-hook system has been updated to allow real-time argument
updates. When a record is created, a pre-hook can now compute and update
the arguments accordingly. This enhancement enables the `createdBy`
field to be populated with the correct values based on the
`authContext`.

The `authContext` now includes:
- An optional User entity
- An optional ApiKey entity
- The workspace entity

This provides access to the necessary data for the `createdBy` field.

In the GraphQL API, only the `source` can be specified in the
`createdBy` input. This allows the front-end to specify the source when
creating records from a CSV file.

### Front-End Handling

On the front-end, `orderBy` and `filter` are only applied to the name
property of the `ACTOR` composite type. Currently, we are unable to
apply these operations to the workspace member relation. This means that
if a workspace member changes their first name or last name, there may
be a mismatch because the name will differ from the new one. The name
displayed on the screen is based on the workspace member entity when
available.

### Missing Components

Currently, this PR does not include a `createdBy` value for the `MAIL`
and `CALENDAR` sources. These records are created in a job, and at
present, we only have access to the workspaceId within the job. To
address this, we should use a function similar to
`loadServiceWithContext`, which was recently removed from `TwentyORM`.
This function would allow us to pass the `authContext` to the jobs
without disrupting existing jobs.
Another PR will be created to handle these cases.

### Related Issues

Fixes issue #5155.

### Additional Notes

This PR doesn't include the migrations of the current records and views.
Everything works properly when the database is reset but this part is
still missing for now. We'll add that in another PR.

- There is a minor issue: front-end tests are broken since this commit:
[https://github.com/twentyhq/twenty/commit/80c0fc7ff108d61c0e8d89fd334da64bd224f7aa](https://github.com/twentyhq/twenty/commit/80c0fc7ff108d61c0e8d89fd334da64bd224f7aa).

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-08-03 15:43:31 +02:00
committed by GitHub
parent 9cf08d912a
commit 6432ad39b9
152 changed files with 24425 additions and 14968 deletions
@@ -7,6 +7,7 @@ import { filterOutInvalidTimelineActivities } from '@/activities/timelineActivit
import { groupEventsByMonth } from '@/activities/timelineActivities/utils/groupEventsByMonth';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
type EventListProps = {
@@ -34,9 +35,12 @@ export const EventList = ({ events, targetableObject }: EventListProps) => {
objectNameSingular: targetableObject.targetObjectNameSingular,
}).objectMetadataItem;
const { objectMetadataItems } = useObjectMetadataItems();
const filteredEvents = filterOutInvalidTimelineActivities(
events,
mainObjectMetadataItem,
targetableObject.targetObjectNameSingular,
objectMetadataItems,
);
const groupedEvents = groupEventsByMonth(filteredEvents);
@@ -22,7 +22,9 @@ export const EventRowActivity = ({
authorFullName,
objectNameSingular,
}: EventRowActivityProps & { objectNameSingular: CoreObjectNameSingular }) => {
const [, eventAction] = event.name.split('.');
const [eventLinkedObject, eventAction] = event.name.split('.');
const eventObject = eventLinkedObject.replace('linked-', '');
if (!event.linkedRecordId) {
throw new Error('Could not find linked record id for event');
@@ -39,7 +41,9 @@ export const EventRowActivity = ({
return (
<>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
<StyledEventRowItemAction>{eventAction}</StyledEventRowItemAction>
<StyledEventRowItemAction>
{`${eventAction} a related ${eventObject}`}
</StyledEventRowItemAction>
{activityInStore ? (
<StyledLinkedActivity
onClick={() => openActivityRightDrawer(event.linkedRecordId)}
@@ -1,14 +1,34 @@
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const filterOutInvalidTimelineActivities = (
timelineActivities: TimelineActivity[],
mainObjectMetadataItem: ObjectMetadataItem,
mainObjectSingularName: string,
objectMetadataItems: ObjectMetadataItem[],
): TimelineActivity[] => {
const mainObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === mainObjectSingularName,
);
const noteObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === CoreObjectNameSingular.Note,
);
if (!mainObjectMetadataItem || !noteObjectMetadataItem) {
throw new Error('Object metadata items not found');
}
const fieldMetadataItemMap = new Map(
mainObjectMetadataItem.fields.map((field) => [field.name, field]),
);
const noteFieldMetadataItemMap = new Map(
noteObjectMetadataItem.fields.map((field) => [field.name, field]),
);
return timelineActivities.filter((timelineActivity) => {
const diff = timelineActivity.properties?.diff;
const canSkipValidation = !diff;
@@ -17,8 +37,14 @@ export const filterOutInvalidTimelineActivities = (
return true;
}
const isNoteOrTask =
timelineActivity.name.startsWith('linked-note') ||
timelineActivity.name.startsWith('linked-task');
const validDiffEntries = Object.entries(diff).filter(([diffKey]) =>
fieldMetadataItemMap.has(diffKey),
isNoteOrTask
? noteFieldMetadataItemMap.has(diffKey)
: fieldMetadataItemMap.has(diffKey),
);
if (validDiffEntries.length === 0) {
@@ -0,0 +1,19 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { AuthContext } from '@/auth/contexts/AuthContext';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
export const AuthProvider = ({ children }: React.PropsWithChildren) => {
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
return (
<AuthContext.Provider
value={{
currentWorkspaceMembers,
}}
>
{children}
</AuthContext.Provider>
);
};
@@ -0,0 +1,10 @@
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { createContext } from 'react';
export type AuthContextType = {
currentWorkspaceMembers: CurrentWorkspaceMember[];
};
export const AuthContext = createContext<AuthContextType>(
{} as AuthContextType,
);
@@ -32,6 +32,7 @@ import {
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
@@ -47,6 +48,9 @@ export const useAuth = () => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
const setCurrentWorkspaceMembers = useSetRecoilState(
currentWorkspaceMembersState,
);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
@@ -109,6 +113,18 @@ export const useAuth = () => {
setCurrentUser(user);
if (isDefined(user.workspaceMembers)) {
const workspaceMembers = user.workspaceMembers.map(
(workspaceMember) => ({
...workspaceMember,
colorScheme: workspaceMember.colorScheme as ColorScheme,
locale: workspaceMember.locale ?? 'en',
}),
);
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(user.workspaceMember)) {
workspaceMember = {
...user.workspaceMember,
@@ -163,10 +179,11 @@ export const useAuth = () => {
verify,
setTokenPair,
setCurrentUser,
setCurrentWorkspaceMember,
setCurrentWorkspace,
setWorkspaces,
setCurrentWorkspaceMembers,
setCurrentWorkspaceMember,
setDateTimeFormat,
setWorkspaces,
],
);
@@ -0,0 +1,9 @@
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { createState } from 'twenty-ui';
export const currentWorkspaceMembersState = createState<
CurrentWorkspaceMember[]
>({
key: 'currentWorkspaceMembersState',
defaultValue: [],
});
@@ -1,6 +1,6 @@
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { useRecoilTransactionObserver_UNSTABLE, useRecoilValue } from 'recoil';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { logDebug } from '~/utils/logDebug';
const formatTitle = (stateName: string) => {
@@ -12,5 +12,6 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
FieldMetadataType.FullName,
FieldMetadataType.Rating,
FieldMetadataType.Currency,
FieldMetadataType.Actor,
FieldMetadataType.Links,
];
@@ -35,6 +35,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Select,
FieldMetadataType.Currency,
FieldMetadataType.Rating,
FieldMetadataType.Actor,
].includes(field.type)
) {
return acc;
@@ -88,6 +89,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
return 'ADDRESS';
case FieldMetadataType.Rating:
return 'RATING';
case FieldMetadataType.Actor:
return 'ACTOR';
default:
return 'TEXT';
}
@@ -26,6 +26,14 @@ export const getOrderByForFieldMetadataType = (
},
},
];
case FieldMetadataType.Actor:
return [
{
[field.name]: {
name: direction ?? 'AscNullsLast',
},
},
];
case FieldMetadataType.Links:
return [
{
@@ -147,5 +147,14 @@ ${mapObjectMetadataToGraphQLQuery({
}`;
}
if (fieldType === FieldMetadataType.Actor) {
return `${field.name}
{
source
workspaceMemberId
name
}`;
}
return '';
};
@@ -90,6 +90,10 @@ export type LinksFilter = {
primaryLinkLabel?: StringFilter;
};
export type ActorFilter = {
name?: StringFilter;
};
export type LeafFilter =
| UUIDFilter
| StringFilter
@@ -101,6 +105,7 @@ export type LeafFilter =
| BooleanFilter
| AddressFilter
| LinksFilter
| ActorFilter
| undefined;
export type AndObjectRecordFilter = {
@@ -65,6 +65,7 @@ export const MultipleFiltersDropdownContent = ({
'LINK',
'LINKS',
'ADDRESS',
'ACTOR',
].includes(filterDefinitionUsedInDropdown.type) && (
<ObjectFilterDropdownTextSearchInput />
)}
@@ -13,4 +13,5 @@ export type FilterType =
| 'ADDRESS'
| 'SELECT'
| 'RATING'
| 'MULTI_SELECT';
| 'MULTI_SELECT'
| 'ACTOR';
@@ -28,6 +28,7 @@ describe('getOperandsForFilterType', () => {
['ADDRESS', [...containsOperands, ...emptyOperands]],
['LINK', [...containsOperands, ...emptyOperands]],
['LINKS', [...containsOperands, ...emptyOperands]],
['ACTOR', [...containsOperands, ...emptyOperands]],
['CURRENCY', [...numberOperands, ...emptyOperands]],
['NUMBER', [...numberOperands, ...emptyOperands]],
['DATE_TIME', [...numberOperands, ...emptyOperands]],
@@ -20,6 +20,7 @@ export const getOperandsForFilterType = (
case 'PHONE':
case 'LINK':
case 'LINKS':
case 'ACTOR':
return [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
@@ -1,11 +1,12 @@
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import {
FieldActorMetadata,
FieldFullNameMetadata,
FieldLinkMetadata,
FieldRatingMetadata,
FieldSelectMetadata,
FieldTextMetadata,
FieldTextMetadata
} from '@/object-record/record-field/types/FieldMetadata';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
@@ -79,7 +80,7 @@ export const phoneFieldDefinition = formatFieldMetadataItemAsFieldDefinition({
objectMetadataItem: mockedPersonObjectMetadataItem,
});
export const ratingfieldDefinition: FieldDefinition<FieldRatingMetadata> = {
export const ratingFieldDefinition: FieldDefinition<FieldRatingMetadata> = {
fieldMetadataId,
label: 'Rating',
iconName: 'iconName',
@@ -97,3 +98,14 @@ export const booleanFieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: booleanFieldMetadataItem!,
objectMetadataItem: mockedCompanyObjectMetadataItem,
});
export const actorFieldDefinition: FieldDefinition<FieldActorMetadata> = {
fieldMetadataId,
label: 'Created By',
iconName: 'restart',
type: FieldMetadataType.Actor,
defaultValue: { source: 'MANUAL', name: '' },
metadata: {
fieldName: 'actor',
},
};
@@ -1,11 +1,13 @@
import { useContext } from 'react';
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay';
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
import { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay';
import { isFieldIdentifierDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldIdentifierDisplay';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
@@ -96,5 +98,7 @@ export const FieldDisplay = () => {
<RatingFieldDisplay />
) : isFieldRichText(fieldDefinition) ? (
<RichTextFieldDisplay />
) : isFieldActor(fieldDefinition) ? (
<ActorFieldDisplay />
) : null;
};
@@ -0,0 +1,50 @@
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import {
actorFieldDefinition,
phoneFieldDefinition,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useIsFieldReadOnly } from '@/object-record/record-field/hooks/useIsFieldReadOnly';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
const entityId = 'entityId';
const getWrapper =
(fieldDefinition: FieldDefinition<FieldMetadata>) =>
({ children }: { children: ReactNode }) => (
<FieldContext.Provider
value={{
fieldDefinition,
entityId,
hotkeyScope: 'hotkeyScope',
isLabelIdentifier: false,
}}
>
<RecoilRoot>{children}</RecoilRoot>
</FieldContext.Provider>
);
const ActorWrapper = getWrapper(actorFieldDefinition);
const PhoneWrapper = getWrapper(phoneFieldDefinition);
describe('useIsFieldReadOnly', () => {
it('should return true', () => {
const { result } = renderHook(() => useIsFieldReadOnly(), {
wrapper: ActorWrapper,
});
expect(result.current).toBe(true);
});
it('should return false', () => {
const { result } = renderHook(() => useIsFieldReadOnly(), {
wrapper: PhoneWrapper,
});
expect(result.current).toBe(false);
});
});
@@ -1,14 +1,16 @@
import { useContext } from 'react';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { FieldContext } from '../contexts/FieldContext';
export const useIsFieldReadOnly = () => {
const { fieldDefinition } = useContext(FieldContext);
return (
fieldDefinition.type === FieldMetadataType.RichText ||
fieldDefinition.metadata.fieldName === 'noteTargets' ||
fieldDefinition.metadata.fieldName === 'taskTargets' // TODO: do something cleaner
fieldDefinition.metadata.fieldName === 'taskTargets' ||
isFieldActor(fieldDefinition) ||
isFieldRichText(fieldDefinition)
);
};
@@ -0,0 +1,25 @@
import { useActorFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useActorFieldDisplay';
import { ActorDisplay } from '@/ui/field/display/components/ActorDisplay';
import { isNonEmptyString } from '@sniptt/guards';
export const ActorFieldDisplay = () => {
const { fieldValue } = useActorFieldDisplay();
const name = !fieldValue.workspaceMemberId
? fieldValue.name
: [
fieldValue.workspaceMember?.name.firstName,
fieldValue.workspaceMember?.name.lastName,
]
.filter(isNonEmptyString)
.join(' ');
return (
<ActorDisplay
name={name}
source={fieldValue.source}
avatarUrl={fieldValue.workspaceMember?.avatarUrl}
workspaceMemberId={fieldValue.workspaceMemberId}
/>
);
};
@@ -0,0 +1,41 @@
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay';
import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadata';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
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';
const meta: Meta = {
title: 'UI/Data/Field/Display/ActorFieldDisplay',
decorators: [
MemoryRouterDecorator,
ChipGeneratorsDecorator,
getFieldDecorator('person', 'actor', {
name: 'John Doe',
source: 'API',
workspaceMemberId: undefined,
} satisfies FieldActorValue),
ComponentDecorator,
],
component: ActorFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof ActorFieldDisplay>;
export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'ActorFieldDisplay',
averageThresholdInMs: 0.2,
numberOfRuns: 20,
numberOfTestsPerRun: 100,
});
@@ -0,0 +1,30 @@
import { useContext } from 'react';
import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadata';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { AuthContext } from '@/auth/contexts/AuthContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useActorFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const { currentWorkspaceMembers } = useContext(AuthContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<FieldActorValue | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue: {
...fieldValue,
workspaceMember: currentWorkspaceMembers?.find(
(member) => member.id === fieldValue?.workspaceMemberId,
),
},
};
};
@@ -8,6 +8,7 @@ import { isFieldText } from '@/object-record/record-field/types/guards/isFieldTe
import { useRecordValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { isDefined } from '~/utils/isDefined';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { FieldContext } from '../../contexts/FieldContext';
export const useChipFieldDisplay = () => {
@@ -25,7 +26,8 @@ export const useChipFieldDisplay = () => {
const objectNameSingular =
isFieldText(fieldDefinition) ||
isFieldFullName(fieldDefinition) ||
isFieldNumber(fieldDefinition)
isFieldNumber(fieldDefinition) ||
isFieldActor(fieldDefinition)
? fieldDefinition.metadata.objectMetadataNameSingular
: undefined;
@@ -1,5 +1,6 @@
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import {
FieldActorValue,
FieldAddressValue,
FieldBooleanValue,
FieldCurrencyValue,
@@ -51,6 +52,11 @@ export type FieldAddressDraftValue = {
addressLng: number | null;
};
export type FieldJsonDraftValue = string;
export type FieldActorDraftValue = {
source: string;
workspaceMemberId?: string;
name: string;
};
export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldTextDraftValue
@@ -88,4 +94,6 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldAddressDraftValue
: FieldValue extends FieldJsonValue
? FieldJsonDraftValue
: never;
: FieldValue extends FieldActorValue
? FieldActorDraftValue
: never;
@@ -142,6 +142,11 @@ export type FieldMultiSelectMetadata = {
options: { label: string; color: ThemeColor; value: string }[];
};
export type FieldActorMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldMetadata =
| FieldBooleanMetadata
| FieldCurrencyMetadata
@@ -158,7 +163,8 @@ export type FieldMetadata =
| FieldMultiSelectMetadata
| FieldTextMetadata
| FieldUuidMetadata
| FieldAddressMetadata;
| FieldAddressMetadata
| FieldActorMetadata;
export type FieldTextValue = string;
export type FieldUUidValue = string;
@@ -204,3 +210,9 @@ export type FieldRelationValue<
export type Json = ZodHelperLiteral | { [key: string]: Json } | Json[];
export type FieldJsonValue = Record<string, Json> | Json[] | null;
export type FieldActorValue = {
source: string;
workspaceMemberId?: string;
name: string;
};
@@ -2,6 +2,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import {
FieldActorMetadata,
FieldAddressMetadata,
FieldBooleanMetadata,
FieldCurrencyMetadata,
@@ -63,7 +64,9 @@ type AssertFieldMetadataFunction = <
? FieldRawJsonMetadata
: E extends 'RICH_TEXT'
? FieldTextMetadata
: never,
: E extends 'ACTOR'
? FieldActorMetadata
: never,
>(
fieldType: E,
fieldTypeGuard: (
@@ -0,0 +1,9 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldActorMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldActor = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldActorMetadata> =>
field.type === FieldMetadataType.Actor;
@@ -0,0 +1,13 @@
import { z } from 'zod';
import { FieldActorValue } from '../FieldMetadata';
const actorSchema = z.object({
source: z.string(),
workspaceMemberId: z.optional(z.string().nullable()),
name: z.string(),
});
export const isFieldActorValue = (
fieldValue: unknown,
): fieldValue is FieldActorValue => actorSchema.safeParse(fieldValue).success;
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { FieldFullNameValue } from '../FieldMetadata';
const currencySchema = z.object({
const fullnameSchema = z.object({
firstName: z.string(),
lastName: z.string(),
});
@@ -10,4 +10,4 @@ const currencySchema = z.object({
export const isFieldFullNameValue = (
fieldValue: unknown,
): fieldValue is FieldFullNameValue =>
currencySchema.safeParse(fieldValue).success;
fullnameSchema.safeParse(fieldValue).success;
@@ -2,6 +2,8 @@ import { isString } from '@sniptt/guards';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { isFieldActorValue } from '@/object-record/record-field/types/guards/isFieldActorValue';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
@@ -13,9 +15,9 @@ import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldE
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
@@ -112,6 +114,10 @@ export const isFieldValueEmpty = ({
);
}
if (isFieldActor(fieldDefinition)) {
return !isFieldActorValue(fieldValue) || isValueEmpty(fieldValue.name);
}
throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
);
@@ -2,6 +2,7 @@ import { isObject } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
ActorFilter,
AddressFilter,
AndObjectRecordFilter,
BooleanFilter,
@@ -256,6 +257,17 @@ export const isRecordMatchingFilter = ({
value: record[filterKey].amountMicros,
});
}
case FieldMetadataType.Actor: {
const actorFilter = filterValue as ActorFilter;
return (
actorFilter.name === undefined ||
isMatchingStringFilter({
stringFilter: actorFilter.name,
value: record[filterKey].name,
})
);
}
case FieldMetadataType.Relation: {
throw new Error(
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,
@@ -1,6 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
ActorFilter,
AddressFilter,
CurrencyFilter,
DateFilter,
@@ -212,6 +213,22 @@ const applyEmptyFilters = (
[correspondingField.name + 'Id']: { is: 'NULL' } as RelationFilter,
};
break;
case 'ACTOR':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
name: { ilike: '' },
} as ActorFilter,
},
{
[correspondingField.name]: {
name: { is: 'NULL' },
} as ActorFilter,
},
],
};
break;
default:
throw new Error(`Unsupported empty filter type ${filterType}`);
}
@@ -744,6 +761,51 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
break;
}
case 'ACTOR':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
or: [
{
[correspondingField.name]: {
name: {
ilike: `%${rawUIFilter.value}%`,
},
} as ActorFilter,
},
],
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
and: [
{
not: {
[correspondingField.name]: {
name: {
ilike: `%${rawUIFilter.value}%`,
},
} as ActorFilter,
},
},
],
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
default:
throw new Error('Unknown filter type');
}
@@ -14,6 +14,7 @@ import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types
import { useInlineCell } from '../hooks/useInlineCell';
import { RecordInlineCellContainer } from './RecordInlineCellContainer';
import { useIsFieldReadOnly } from '@/object-record/record-field/hooks/useIsFieldReadOnly';
type RecordInlineCellProps = {
readonly?: boolean;
@@ -32,8 +33,12 @@ export const RecordInlineCell = ({
const isFieldInputOnly = useIsFieldInputOnly();
const isFieldReadOnly = useIsFieldReadOnly();
const { closeInlineCell } = useInlineCell();
const cellIsReadOnly = readonly || isFieldReadOnly;
const handleEnter: FieldInputEvent = (persistField) => {
persistField();
closeInlineCell();
@@ -72,7 +77,7 @@ export const RecordInlineCell = ({
return (
<FieldFocusContextProvider>
<RecordInlineCellContainer
readonly={readonly}
readonly={cellIsReadOnly}
buttonIcon={buttonIcon}
customEditHotkeyScope={
isFieldRelation(fieldDefinition)
@@ -100,7 +105,7 @@ export const RecordInlineCell = ({
onTab={handleTab}
onShiftTab={handleShiftTab}
onClickOutside={handleClickOutside}
isReadOnly={readonly}
isReadOnly={cellIsReadOnly}
/>
}
displayModeContent={<FieldDisplay />}
@@ -78,7 +78,11 @@ export const RecordTableCellSoftFocusMode = ({
useScopedHotkeys(
Key.Enter,
() => {
if (!isFieldInputOnly && !isCellReadOnly) {
if (isCellReadOnly) {
return;
}
if (!isFieldInputOnly) {
openTableCell();
} else {
toggleEditOnlyInput();
@@ -91,6 +95,10 @@ export const RecordTableCellSoftFocusMode = ({
useScopedHotkeys(
'*',
(keyboardEvent) => {
if (isCellReadOnly) {
return;
}
if (!isFieldInputOnly) {
const isWritingText =
!isNonTextWritingKey(keyboardEvent.key) &&
@@ -150,6 +158,8 @@ export const RecordTableCellSoftFocusMode = ({
(!isFirstColumn || !isEmpty) &&
!isCellReadOnly;
const dontShowContent = isEmpty && isCellReadOnly;
return (
<>
<RecordTableCellDisplayContainer
@@ -157,7 +167,13 @@ export const RecordTableCellSoftFocusMode = ({
scrollRef={scrollRef}
softFocus
>
{editModeContentOnly ? editModeContent : nonEditModeContent}
{dontShowContent ? (
<></>
) : editModeContentOnly ? (
editModeContent
) : (
nonEditModeContent
)}
</RecordTableCellDisplayContainer>
{showButton && (
<RecordTableCellButton onClick={handleButtonClick} Icon={buttonIcon} />
@@ -30,4 +30,7 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = {
primaryLinkUrlLabel: 'Link URL',
primaryLinkLabelLabel: 'Link Label',
} satisfies Partial<CompositeFieldLabels<FieldLinksValue>>,
[FieldMetadataType.Actor]: {
sourceLabel: 'Source',
},
};
@@ -160,6 +160,11 @@ export const buildRecordFromImportedStructuredRow = (
};
}
break;
case FieldMetadataType.Actor:
recordToBuild[field.name] = {
source: 'IMPORT',
};
break;
default:
recordToBuild[field.name] = importedFieldValue;
break;
@@ -87,6 +87,12 @@ export const generateEmptyFieldValue = (
case FieldMetadataType.RichText: {
return null;
}
case FieldMetadataType.Actor: {
return {
source: 'MANUAL',
name: '',
};
}
default: {
throw new Error('Unhandled FieldMetadataType');
}
@@ -4,6 +4,7 @@ import {
IconCheck,
IconCoins,
IconComponent,
IconCreativeCommonsSa,
IconFilePencil,
IconJson,
IconKey,
@@ -137,6 +138,10 @@ export const SETTINGS_FIELD_TYPE_CONFIGS = {
Icon: IconFilePencil,
defaultValue: { key: 'value' },
},
[FieldMetadataType.Actor]: {
label: 'Actor',
Icon: IconCreativeCommonsSa,
},
} as const satisfies Record<
SettingsSupportedFieldType,
SettingsFieldTypeConfig
@@ -0,0 +1,50 @@
import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadata';
import { useMemo } from 'react';
import {
AvatarChip,
AvatarChipVariant,
IconApi,
IconCalendar,
IconCsv,
IconGmail,
} from 'twenty-ui';
type ActorDisplayProps = Partial<FieldActorValue> & {
avatarUrl?: string | null;
};
export const ActorDisplay = ({
name,
source,
workspaceMemberId,
avatarUrl,
}: ActorDisplayProps) => {
const LeftIcon = useMemo(() => {
switch (source) {
case 'API':
return IconApi;
case 'IMPORT':
return IconCsv;
case 'EMAIL':
return IconGmail;
case 'CALENDAR':
return IconCalendar;
default:
return undefined;
}
}, [source]);
const isIconInverted = source === 'API' || source === 'IMPORT';
return (
<AvatarChip
placeholderColorSeed={workspaceMemberId ?? undefined}
name={name ?? ''}
avatarType={workspaceMemberId ? 'rounded' : 'squared'}
LeftIcon={LeftIcon}
avatarUrl={avatarUrl ?? undefined}
isIconInverted={isIconInverted}
variant={AvatarChipVariant.Transparent}
/>
);
};
@@ -0,0 +1,46 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { ActorDisplay } from '@/ui/field/display/components/ActorDisplay';
import { CatalogDecorator } from 'twenty-ui';
const meta: Meta = {
title: 'UI/Display/ActorDisplay',
component: ActorDisplay,
args: {
name: 'John Doe',
},
decorators: [ComponentWithRouterDecorator],
};
export default meta;
type Story = StoryObj<typeof ActorDisplay>;
export const Default: Story = {};
export const Catalog: Story = {
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'source',
values: ['API', 'IMPORT', 'EMAIL', 'CALENDAR', 'MANUAL'],
props: (source: string) => ({ source }),
},
{
name: 'workspaceMemberId',
values: [null, '123'],
props: (workspaceMemberId: string) => ({ workspaceMemberId }),
},
{
name: 'avatarUrl',
values: [null, 'https://picsum.photos/16'],
props: (avatarUrl: string) => ({ avatarUrl }),
},
],
},
},
};
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState';
@@ -13,6 +14,7 @@ import { detectTimeZone } from '@/localization/utils/detectTimeZone';
import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDateFormatFromWorkspaceDateFormat';
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { WorkspaceMember } from '~/generated-metadata/graphql';
import { useGetCurrentUserQuery } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
@@ -31,6 +33,9 @@ export const UserProviderEffect = () => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
const setCurrentWorkspaceMembers = useSetRecoilState(
currentWorkspaceMembersState,
);
const { loading: queryLoading, data: queryData } = useGetCurrentUserQuery({
skip: isCurrentUserLoaded,
@@ -47,15 +52,26 @@ export const UserProviderEffect = () => {
setCurrentUser(queryData.currentUser);
setCurrentWorkspace(queryData.currentUser.defaultWorkspace);
const { workspaceMember, workspaces: userWorkspaces } =
queryData.currentUser;
const {
workspaceMember,
workspaceMembers,
workspaces: userWorkspaces,
} = queryData.currentUser;
if (isDefined(workspaceMember)) {
setCurrentWorkspaceMember({
const affectDefaultValuesOnEmptyWorkspaceMemberFields = (
workspaceMember: WorkspaceMember,
) => {
return {
...workspaceMember,
colorScheme: (workspaceMember.colorScheme as ColorScheme) ?? 'Light',
locale: workspaceMember.locale ?? 'en',
});
};
};
if (isDefined(workspaceMember)) {
setCurrentWorkspaceMember(
affectDefaultValuesOnEmptyWorkspaceMemberFields(workspaceMember),
);
// TODO: factorize
setDateTimeFormat({
@@ -72,6 +88,13 @@ export const UserProviderEffect = () => {
});
}
if (isDefined(workspaceMembers)) {
setCurrentWorkspaceMembers(
workspaceMembers.map(affectDefaultValuesOnEmptyWorkspaceMemberFields) ??
[],
);
}
if (isDefined(userWorkspaces)) {
const workspaces = userWorkspaces
.map(({ workspace }) => workspace)
@@ -81,6 +104,7 @@ export const UserProviderEffect = () => {
}
}, [
setCurrentUser,
setCurrentWorkspaceMembers,
isLoading,
queryLoading,
setCurrentWorkspace,
@@ -10,17 +10,10 @@ export const USER_QUERY_FRAGMENT = gql`
supportUserHash
onboardingStatus
workspaceMember {
id
name {
firstName
lastName
}
colorScheme
avatarUrl
locale
timeZone
dateFormat
timeFormat
...WorkspaceMemberQueryFragment
}
workspaceMembers {
...WorkspaceMemberQueryFragment
}
defaultWorkspace {
id
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const USER_QUERY_FRAGMENT = gql`
fragment WorkspaceMemberQueryFragment on WorkspaceMember {
id
name {
firstName
lastName
}
colorScheme
avatarUrl
locale
timeZone
dateFormat
timeFormat
}
`;