Activity as standard object (#6219)

In this PR I layout the first steps to migrate Activity to a traditional
Standard objects

Since this is a big transition, I'd rather split it into several
deployments / PRs

<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/012e2bbf-9d1b-4723-aaf6-269ef588b050">

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: bosiraphael <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: Faisal-imtiyaz123 <142205282+Faisal-imtiyaz123@users.noreply.github.com>
Co-authored-by: Prateek Jain <prateekj1171998@gmail.com>
This commit is contained in:
Félix Malfait
2024-07-31 15:36:11 +02:00
committed by GitHub
parent defcee2a02
commit 80c0fc7ff1
239 changed files with 18418 additions and 8671 deletions

View File

@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';

View File

@ -3,9 +3,7 @@ import { AvatarChip, AvatarChipVariant } from 'twenty-ui';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isNonEmptyString } from '@sniptt/guards';
import { MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
export type RecordChipProps = {
objectNameSingular: string;
@ -20,31 +18,22 @@ export const RecordChip = ({
className,
variant,
}: RecordChipProps) => {
const navigate = useNavigate();
const { recordChipData } = useRecordChipData({
objectNameSingular,
record,
});
const handleAvatarChipClick = (event: MouseEvent) => {
const linkToShowPage = getLinkToShowPage(objectNameSingular, record);
if (isNonEmptyString(linkToShowPage)) {
event.stopPropagation();
navigate(linkToShowPage);
}
};
return (
<AvatarChip
placeholderColorSeed={record.id}
name={recordChipData.name}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
onClick={handleAvatarChipClick}
className={className}
variant={variant}
/>
<UndecoratedLink to={getLinkToShowPage(objectNameSingular, record)}>
<AvatarChip
placeholderColorSeed={record.id}
name={recordChipData.name}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
className={className}
variant={variant}
onClick={() => {}}
/>
</UndecoratedLink>
);
};

View File

@ -3,6 +3,7 @@ import { print } from 'graphql';
import { RecoilRoot } from 'recoil';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { normalizeGQLQuery } from '~/utils/normalizeGQLQuery';
const expectedQueryTemplate = `
mutation UpdateOnePerson($idToUpdate: ID!, $input: PersonUpdateInput!) {
@ -32,8 +33,7 @@ mutation UpdateOnePerson($idToUpdate: ID!, $input: PersonUpdateInput!) {
avatarUrl
companyId
}
}
`.replace(/\s/g, '');
}`;
describe('useUpdateOneRecordMutation', () => {
it('should return a valid createManyRecordsMutation', () => {
@ -53,11 +53,10 @@ describe('useUpdateOneRecordMutation', () => {
expect(updateOneRecordMutation).toBeDefined();
const printedReceivedQuery = print(updateOneRecordMutation).replace(
/\s/g,
'',
);
const printedReceivedQuery = print(updateOneRecordMutation);
expect(printedReceivedQuery).toEqual(expectedQueryTemplate);
expect(normalizeGQLQuery(printedReceivedQuery)).toEqual(
normalizeGQLQuery(expectedQueryTemplate),
);
});
});

View File

@ -96,8 +96,8 @@ export const useRecordActionBar = ({
const { deleteTableData } = useDeleteTableData(baseTableDataParams);
const handleDeleteClick = useCallback(() => {
deleteTableData();
}, [deleteTableData]);
deleteTableData(selectedRecordIds);
}, [deleteTableData, selectedRecordIds]);
const handleExecuteQuickActionOnClick = useCallback(async () => {
callback?.();

View File

@ -4,7 +4,7 @@ import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/dis
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 { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
@ -12,6 +12,7 @@ import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldL
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
@ -93,5 +94,7 @@ export const FieldDisplay = () => {
<BooleanFieldDisplay />
) : isFieldRating(fieldDefinition) ? (
<RatingFieldDisplay />
) : isFieldRichText(fieldDefinition) ? (
<RichTextFieldDisplay />
) : null;
};

View File

@ -20,6 +20,8 @@ import { isFieldRelationToOneObject } from '@/object-record/record-field/types/g
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { RichTextFieldInput } from '@/object-record/record-field/meta-types/input/components/RichTextFieldInput';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { FieldContext } from '../contexts/FieldContext';
import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput';
import { CurrencyFieldInput } from '../meta-types/input/components/CurrencyFieldInput';
@ -175,6 +177,8 @@ export const FieldInput = ({
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldRichText(fieldDefinition) ? (
<RichTextFieldInput />
) : (
<></>
)}

View File

@ -0,0 +1,14 @@
import { useContext } from 'react';
import { FieldMetadataType } from '~/generated-metadata/graphql';
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
);
};

View File

@ -0,0 +1,11 @@
import { useTextFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useTextFieldDisplay';
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
import { PartialBlock } from '@blocknote/core';
export const RichTextFieldDisplay = () => {
const { fieldValue } = useTextFieldDisplay();
const parsedField =
fieldValue === '' ? null : (JSON.parse(fieldValue) as PartialBlock[]);
return <>{getFirstNonEmptyLineOfRichText(parsedField)}</>;
};

View File

@ -0,0 +1,5 @@
import { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay';
export const RichTextFieldInput = () => {
return <RichTextFieldDisplay />;
};

View File

@ -95,6 +95,11 @@ export type FieldRawJsonMetadata = {
placeHolder: string;
};
export type FieldRichTextMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldDefinitionRelationType =
| 'FROM_MANY_OBJECTS'
| 'FROM_ONE_OBJECT'

View File

@ -61,7 +61,9 @@ type AssertFieldMetadataFunction = <
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: never,
: E extends 'RICH_TEXT'
? FieldTextMetadata
: never,
>(
fieldType: E,
fieldTypeGuard: (

View File

@ -0,0 +1,9 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadata, FieldRichTextMetadata } from '../FieldMetadata';
export const isFieldRichText = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldRichTextMetadata> =>
field.type === FieldMetadataType.RichText;

View File

@ -23,6 +23,7 @@ import { isFieldPhone } from '@/object-record/record-field/types/guards/isFieldP
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
@ -54,6 +55,7 @@ export const isFieldValueEmpty = ({
isFieldBoolean(fieldDefinition) ||
isFieldRelation(fieldDefinition) ||
isFieldRawJson(fieldDefinition) ||
isFieldRichText(fieldDefinition) ||
isFieldPhone(fieldDefinition)
) {
return isValueEmpty(fieldValue);

View File

@ -34,6 +34,18 @@ export const useRecordTableRecordGqlFields = ({
),
...identifierQueryFields,
position: true,
noteTargets: {
note: {
id: true,
title: true,
},
},
taskTargets: {
task: {
id: true,
title: true,
},
},
};
return recordGqlFields;

View File

@ -17,13 +17,10 @@ export const useDeleteTableData = ({
objectNameSingular,
});
const {
resetTableRowSelection,
selectedRowIdsSelector,
hasUserSelectedAllRowsState,
} = useRecordTable({
recordTableId: recordIndexId,
});
const { resetTableRowSelection, hasUserSelectedAllRowsState } =
useRecordTable({
recordTableId: recordIndexId,
});
const tableRowIds = useRecoilValue(
tableRowIdsComponentState({
@ -36,18 +33,14 @@ export const useDeleteTableData = ({
});
const { favorites, deleteFavorite } = useFavorites();
const selectedRowIds = useRecoilValue(selectedRowIdsSelector());
const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState);
const deleteRecords = async () => {
let recordIdsToDelete = selectedRowIds;
const deleteRecords = async (recordIdsToDelete: string[]) => {
if (hasUserSelectedAllRows) {
const allRecordIds = await fetchAllRecordIds();
const unselectedRecordIds = tableRowIds.filter(
(recordId) => !selectedRowIds.includes(recordId),
(recordId) => !recordIdsToDelete.includes(recordId),
);
recordIdsToDelete = allRecordIds.filter(

View File

@ -1,8 +1,12 @@
import groupBy from 'lodash.groupby';
import { useRecoilState, useRecoilValue } from 'recoil';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
@ -62,7 +66,7 @@ export const RecordShowContainer = ({
recordLoadingFamilyState(objectRecordId),
);
const [recordFromStore] = useRecoilState(
const [recordFromStore] = useRecoilState<any>(
recordStoreFamilyState(objectRecordId),
);
@ -131,90 +135,147 @@ export const RecordShowContainer = ({
: 'inlineFieldMetadataItems',
);
const inlineRelationFieldMetadataItems = relationFieldMetadataItems?.filter(
(fieldMetadataItem) =>
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets'),
);
const boxedRelationFieldMetadataItems = relationFieldMetadataItems?.filter(
(fieldMetadataItem) =>
objectNameSingular !== CoreObjectNameSingular.Note &&
fieldMetadataItem.name !== 'noteTargets' &&
objectNameSingular !== CoreObjectNameSingular.Task &&
fieldMetadataItem.name !== 'taskTargets',
);
const isReadOnly = objectMetadataItem.isRemote;
const isMobile = useIsMobile() || isInRightDrawer;
const isPrefetchLoading = useIsPrefetchLoading();
const summary = (
const summaryCard = isDefined(recordFromStore) ? (
<ShowPageSummaryCard
id={objectRecordId}
logoOrAvatar={recordIdentifier?.avatarUrl ?? ''}
avatarPlaceholder={recordIdentifier?.name ?? ''}
date={recordFromStore.createdAt ?? ''}
loading={isPrefetchLoading || loading || recordLoading}
title={
<FieldContext.Provider
value={{
entityId: objectRecordId,
recoilScopeId:
objectRecordId + labelIdentifierFieldMetadataItem?.id,
isLabelIdentifier: false,
fieldDefinition: {
type:
labelIdentifierFieldMetadataItem?.type ||
FieldMetadataType.Text,
iconName: '',
fieldMetadataId: labelIdentifierFieldMetadataItem?.id ?? '',
label: labelIdentifierFieldMetadataItem?.label || '',
metadata: {
fieldName: labelIdentifierFieldMetadataItem?.name || '',
objectMetadataNameSingular: objectNameSingular,
},
defaultValue: labelIdentifierFieldMetadataItem?.defaultValue,
},
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
isCentered: true,
}}
>
<RecordInlineCell readonly={isReadOnly} isCentered={true} />
</FieldContext.Provider>
}
avatarType={recordIdentifier?.avatarType ?? 'rounded'}
onUploadPicture={
objectNameSingular === 'person' ? onUploadPicture : undefined
}
/>
) : (
<></>
);
const fieldsBox = (
<>
{isDefined(recordFromStore) && (
<>
<ShowPageSummaryCard
id={objectRecordId}
logoOrAvatar={recordIdentifier?.avatarUrl ?? ''}
avatarPlaceholder={recordIdentifier?.name ?? ''}
date={recordFromStore.createdAt ?? ''}
loading={isPrefetchLoading || loading || recordLoading}
title={
<FieldContext.Provider
value={{
entityId: objectRecordId,
recoilScopeId:
objectRecordId + labelIdentifierFieldMetadataItem?.id,
isLabelIdentifier: false,
fieldDefinition: {
type:
labelIdentifierFieldMetadataItem?.type ||
FieldMetadataType.Text,
iconName: '',
fieldMetadataId: labelIdentifierFieldMetadataItem?.id ?? '',
label: labelIdentifierFieldMetadataItem?.label || '',
metadata: {
fieldName: labelIdentifierFieldMetadataItem?.name || '',
objectMetadataNameSingular: objectNameSingular,
},
defaultValue:
labelIdentifierFieldMetadataItem?.defaultValue,
},
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
isCentered: true,
}}
>
<RecordInlineCell readonly={isReadOnly} isCentered={true} />
</FieldContext.Provider>
}
avatarType={recordIdentifier?.avatarType ?? 'rounded'}
onUploadPicture={
objectNameSingular === 'person' ? onUploadPicture : undefined
}
/>
<PropertyBox>
{isPrefetchLoading ? (
<PropertyBoxSkeletonLoader />
) : (
inlineFieldMetadataItems.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
entityId: objectRecordId,
maxWidth: 200,
recoilScopeId: objectRecordId + fieldMetadataItem.id,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell
loading={loading || recordLoading}
readonly={isReadOnly}
/>
</FieldContext.Provider>
))
<>
{inlineRelationFieldMetadataItems?.map(
(fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
entityId: objectRecordId,
maxWidth: 200,
recoilScopeId: objectRecordId + fieldMetadataItem.id,
isLabelIdentifier: false,
fieldDefinition:
formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<ActivityTargetsInlineCell
activityObjectNameSingular={
objectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task
}
activity={recordFromStore as Task | Note}
showLabel={true}
maxWidth={200}
/>
</FieldContext.Provider>
),
)}
{inlineFieldMetadataItems?.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
entityId: objectRecordId,
maxWidth: 200,
recoilScopeId: objectRecordId + fieldMetadataItem.id,
isLabelIdentifier: false,
fieldDefinition:
formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell
loading={loading || recordLoading}
readonly={isReadOnly}
/>
</FieldContext.Provider>
))}
</>
)}
</PropertyBox>
<RecordDetailDuplicatesSection
objectRecordId={objectRecordId}
objectNameSingular={objectNameSingular}
/>
{relationFieldMetadataItems?.map((fieldMetadataItem, index) => (
{boxedRelationFieldMetadataItems?.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
@ -244,25 +305,23 @@ export const RecordShowContainer = ({
<RecoilScope CustomRecoilScopeContext={ShowPageRecoilScopeContext}>
<ShowPageContainer>
<ShowPageLeftContainer forceMobile={isInRightDrawer}>
{!isMobile && summary}
{!isMobile && summaryCard}
{!isMobile && fieldsBox}
</ShowPageLeftContainer>
{recordFromStore ? (
<ShowPageRightContainer
targetableObject={{
id: objectRecordId,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
timeline
tasks
notes
emails
isRightDrawer={isInRightDrawer}
summary={summary}
loading={isPrefetchLoading || loading || recordLoading}
/>
) : (
<></>
)}
<ShowPageRightContainer
targetableObject={{
id: objectRecordId,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
timeline
tasks
notes
emails
isInRightDrawer={isInRightDrawer}
summaryCard={isMobile ? summaryCard : <></>}
fieldsBox={fieldsBox}
loading={isPrefetchLoading || loading || recordLoading}
/>
</ShowPageContainer>
</RecoilScope>
);

View File

@ -1,10 +1,46 @@
import { generateActivityTargetMorphFieldKeys } from '@/activities/utils/generateActivityTargetMorphFieldKeys';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
export const buildFindOneRecordForShowPageOperationSignature: RecordGqlOperationSignatureFactory =
({ objectMetadataItem }: { objectMetadataItem: ObjectMetadataItem }) => ({
({
objectMetadataItem,
objectMetadataItems,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
}) => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
fields: generateDepthOneRecordGqlFields({ objectMetadataItem }),
fields: {
...generateDepthOneRecordGqlFields({ objectMetadataItem }),
...(objectMetadataItem.nameSingular === CoreObjectNameSingular.Task
? {
taskTargets: {
id: true,
__typename: true,
createdAt: true,
updatedAt: true,
note: true,
noteId: true,
...generateActivityTargetMorphFieldKeys(objectMetadataItems),
},
}
: {}),
...(objectMetadataItem.nameSingular === 'Note'
? {
noteTargets: {
id: true,
__typename: true,
createdAt: true,
updatedAt: true,
task: true,
taskId: true,
...generateActivityTargetMorphFieldKeys(objectMetadataItems),
},
}
: {}),
},
});

View File

@ -6,6 +6,7 @@ import { useIcons } from 'twenty-ui';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { buildFindOneRecordForShowPageOperationSignature } from '@/object-record/record-show/graphql/operations/factories/findOneRecordForShowPageOperationSignatureFactory';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
@ -30,6 +31,7 @@ export const useRecordShowPage = (
}
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const { objectMetadataItems } = useObjectMetadataItems();
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({ objectNameSingular });
const { favorites, createFavorite, deleteFavorite } = useFavorites();
@ -39,7 +41,10 @@ export const useRecordShowPage = (
const { getIcon } = useIcons();
const headerIcon = getIcon(objectMetadataItem?.icon);
const FIND_ONE_RECORD_FOR_SHOW_PAGE_OPERATION_SIGNATURE =
buildFindOneRecordForShowPageOperationSignature({ objectMetadataItem });
buildFindOneRecordForShowPageOperationSignature({
objectMetadataItem,
objectMetadataItems,
});
const { record, loading } = useFindOneRecord({
objectRecordId,

View File

@ -1,4 +1,6 @@
/* eslint-disable @nx/workspace-no-navigate-prefer-link */
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
@ -10,8 +12,6 @@ import { useRecordIdsFromFindManyCacheRootQuery } from '@/object-record/record-s
import { buildShowPageURL } from '@/object-record/record-show/utils/buildShowPageURL';
import { buildIndexTablePageURL } from '@/object-record/record-table/utils/buildIndexTableURL';
import { useQueryVariablesFromActiveFieldsOfViewOrDefaultView } from '@/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { capitalize } from '~/utils/string/capitalize';
export const useRecordShowPagePagination = (

View File

@ -1,6 +1,6 @@
import { useCallback, useContext } from 'react';
import styled from '@emotion/styled';
import qs from 'qs';
import { useCallback, useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { IconForbid, IconPencil, IconPlus } from 'twenty-ui';

View File

@ -4,13 +4,11 @@ import { FieldInput } from '@/object-record/record-field/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
export const RecordTableCellFieldInput = () => {
const { onUpsertRecord, onMoveFocus, onCloseTableCell } =
useContext(RecordTableContext);
const { entityId, fieldDefinition } = useContext(FieldContext);
const { isReadOnly } = useContext(RecordTableRowContext);
const handleEnter: FieldInputEvent = (persistField) => {
onUpsertRecord({
@ -89,7 +87,7 @@ export const RecordTableCellFieldInput = () => {
onShiftTab={handleShiftTab}
onSubmit={handleSubmit}
onTab={handleTab}
isReadOnly={isReadOnly}
isReadOnly={true}
/>
);
};

View File

@ -22,6 +22,7 @@ import { isDefined } from '~/utils/isDefined';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useIsFieldReadOnly } from '@/object-record/record-field/hooks/useIsFieldReadOnly';
import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer';
type RecordTableCellSoftFocusModeProps = {
@ -35,7 +36,11 @@ export const RecordTableCellSoftFocusMode = ({
}: RecordTableCellSoftFocusModeProps) => {
const { columnIndex } = useContext(RecordTableCellContext);
const closeCurrentTableCell = useCloseCurrentTableCellInEditMode();
const { isReadOnly } = useContext(RecordTableRowContext);
const { isReadOnly: isRowReadOnly } = useContext(RecordTableRowContext);
const isFieldReadOnly = useIsFieldReadOnly();
const isCellReadOnly = isFieldReadOnly || isRowReadOnly;
const { openTableCell } = useOpenRecordTableCellFromCell();
@ -73,7 +78,7 @@ export const RecordTableCellSoftFocusMode = ({
useScopedHotkeys(
Key.Enter,
() => {
if (!isFieldInputOnly) {
if (!isFieldInputOnly && !isCellReadOnly) {
openTableCell();
} else {
toggleEditOnlyInput();
@ -111,7 +116,7 @@ export const RecordTableCellSoftFocusMode = ({
);
const handleClick = () => {
if (!isFieldInputOnly) {
if (!isFieldInputOnly && !isCellReadOnly) {
openTableCell();
}
};
@ -143,7 +148,7 @@ export const RecordTableCellSoftFocusMode = ({
isDefined(buttonIcon) &&
!editModeContentOnly &&
(!isFirstColumn || !isEmpty) &&
!isReadOnly;
!isCellReadOnly;
return (
<>

View File

@ -3,7 +3,9 @@ import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { createState } from 'twenty-ui';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { textfieldDefinition } from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
@ -53,6 +55,8 @@ const updateOneRecordMock = jest.fn();
createOneRecord: createOneRecordMock,
});
const objectMetadataItems = getObjectMetadataItemsMock();
const Wrapper = ({
children,
pendingRecordIdMockedValue,
@ -64,6 +68,7 @@ const Wrapper = ({
}) => (
<RecoilRoot
initializeState={(snapshot) => {
snapshot.set(objectMetadataItemsState, objectMetadataItems);
snapshot.set(pendingRecordIdState, pendingRecordIdMockedValue);
snapshot.set(draftValueState, draftValueMockedValue);
}}

View File

@ -1,5 +1,7 @@
import { useRecoilCallback } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { recordFieldInputDraftValueComponentSelector } from '@/object-record/record-field/states/selectors/recordFieldInputDraftValueComponentSelector';
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
@ -26,6 +28,21 @@ export const useUpsertRecord = ({
fieldName: string,
recordTableId: string,
) => {
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue();
const foundObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
if (!foundObjectMetadataItem) {
throw new Error('Object metadata item cannot be found');
}
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(foundObjectMetadataItem);
const tableScopeId = getScopeIdFromComponentId(recordTableId);
const recordTablePendingRecordIdState = extractComponentState(
@ -51,14 +68,14 @@ export const useUpsertRecord = ({
if (isDefined(recordTablePendingRecordId) && isDefined(draftValue)) {
createOneRecord({
id: recordTablePendingRecordId,
name: draftValue,
[labelIdentifierFieldMetadataItem?.name ?? 'name']: draftValue,
position: 'first',
});
} else if (!recordTablePendingRecordId) {
persistField();
}
},
[createOneRecord],
[createOneRecord, objectNameSingular],
);
return { upsertRecord };

View File

@ -7,6 +7,7 @@ import {
} from 'recoil';
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { objectRecordMultiSelectComponentFamilyState } from '@/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import {
@ -48,9 +49,14 @@ export const ActivityTargetInlineCellEditModeMultiRecordsEffect = ({
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const { filteredSelectedObjectRecords, loading, objectRecordsToSelect } =
useMultiObjectSearch({
searchFilterValue: relationPickerSearchFilter,
excludedObjects: [
CoreObjectNameSingular.Task,
CoreObjectNameSingular.Note,
],
selectedObjectRecordIds,
excludedObjectRecordIds: [],
limit: 10,

View File

@ -1,3 +1,4 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery';
import { useMultiObjectSearchMatchesSearchFilterAndToSelectQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery';
@ -30,11 +31,13 @@ export const useMultiObjectSearch = ({
selectedObjectRecordIds,
limit,
excludedObjectRecordIds = [],
excludedObjects,
}: {
searchFilterValue: string;
selectedObjectRecordIds: SelectedObjectRecordId[];
limit?: number;
excludedObjectRecordIds?: SelectedObjectRecordId[];
excludedObjects?: CoreObjectNameSingular[];
}): MultiObjectSearch => {
const { selectedObjectRecords, selectedObjectRecordsLoading } =
useMultiObjectSearchSelectedItemsQuery({
@ -54,6 +57,7 @@ export const useMultiObjectSearch = ({
toSelectAndMatchesSearchFilterObjectRecords,
toSelectAndMatchesSearchFilterObjectRecordsLoading,
} = useMultiObjectSearchMatchesSearchFilterAndToSelectQuery({
excludedObjects,
excludedObjectRecordIds,
searchFilterValue,
selectedObjectRecordIds,

View File

@ -2,6 +2,7 @@ import { useQuery } from '@apollo/client';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
@ -21,17 +22,21 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
excludedObjectRecordIds,
searchFilterValue,
limit,
excludedObjects,
}: {
selectedObjectRecordIds: SelectedObjectRecordId[];
excludedObjectRecordIds: SelectedObjectRecordId[];
searchFilterValue: string;
limit?: number;
excludedObjects?: CoreObjectNameSingular[];
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const selectableObjectMetadataItems = objectMetadataItems.filter(
({ isSystem, isRemote }) => !isSystem && !isRemote,
);
const selectableObjectMetadataItems = objectMetadataItems
.filter(({ isSystem, isRemote }) => !isSystem && !isRemote)
.filter(({ nameSingular }) => {
return !excludedObjects?.includes(nameSingular as CoreObjectNameSingular);
});
const { searchFilterPerMetadataItemNameSingular } =
useSearchFilterPerMetadataItem({

View File

@ -1,8 +1,4 @@
export const getObjectFilterFields = (objectSingleName: string) => {
if (objectSingleName === 'company') {
return ['name'];
}
if (['workspaceMember', 'person'].includes(objectSingleName)) {
return ['name.firstName', 'name.lastName'];
}

View File

@ -84,6 +84,9 @@ export const generateEmptyFieldValue = (
case FieldMetadataType.RawJson: {
return null;
}
case FieldMetadataType.RichText: {
return null;
}
default: {
throw new Error('Unhandled FieldMetadataType');
}

View File

@ -1,3 +1,4 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import {
@ -7,9 +8,11 @@ import {
export const isFieldCellSupported = (fieldMetadataItem: FieldMetadataItem) => {
if (
[FieldMetadataType.Uuid, FieldMetadataType.Position].includes(
fieldMetadataItem.type,
)
[
FieldMetadataType.Uuid,
FieldMetadataType.Position,
FieldMetadataType.RichText,
].includes(fieldMetadataItem.type)
) {
return false;
}
@ -22,6 +25,25 @@ export const isFieldCellSupported = (fieldMetadataItem: FieldMetadataItem) => {
fieldMetadataItem.fromRelationMetadata?.toObjectMetadata ??
fieldMetadataItem.toRelationMetadata?.fromObjectMetadata;
// Hack to display targets on Notes and Tasks
if (
fieldMetadataItem.fromRelationMetadata?.toObjectMetadata?.nameSingular ===
CoreObjectNameSingular.NoteTarget &&
fieldMetadataItem.relationDefinition?.sourceObjectMetadata
.nameSingular === CoreObjectNameSingular.Note
) {
return true;
}
if (
fieldMetadataItem.fromRelationMetadata?.toObjectMetadata?.nameSingular ===
CoreObjectNameSingular.TaskTarget &&
fieldMetadataItem.relationDefinition?.sourceObjectMetadata
.nameSingular === CoreObjectNameSingular.Task
) {
return true;
}
if (
!relationMetadata ||
// TODO: Many to many relations are not supported yet.

View File

@ -36,7 +36,7 @@ export const sanitizeRecordInput = ({
(field) => field.name === relationIdFieldName,
);
return relationIdFieldMetadataItem
return relationIdFieldMetadataItem && fieldValue?.id
? [relationIdFieldName, fieldValue?.id ?? null]
: undefined;
}