Feat/activities custom objects (#3213)

* WIP

* WIP - MultiObjectSearch

* WIP

* WIP

* Finished working version

* Fix

* Fixed and cleaned

* Fix

* Disabled files and emails for custom objects

* Cleaned console.log

* Fixed attachment

* Fixed

* fix lint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2024-01-05 09:08:33 +01:00
committed by GitHub
parent c15e138d72
commit b112b74022
72 changed files with 1611 additions and 551 deletions

View File

@ -1,8 +1,7 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { CompanyChip } from '@/companies/components/CompanyChip'; import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject';
import { PersonChip } from '@/people/components/PersonChip'; import { RecordChip } from '@/object-record/components/RecordChip';
import { getLogoUrlFromDomainName } from '~/utils';
const StyledContainer = styled.div` const StyledContainer = styled.div`
display: flex; display: flex;
@ -10,37 +9,21 @@ const StyledContainer = styled.div`
gap: ${({ theme }) => theme.spacing(1)}; gap: ${({ theme }) => theme.spacing(1)};
`; `;
// TODO: fix edges pagination formatting on n+N export const ActivityTargetChips = ({
export const ActivityTargetChips = ({ targets }: { targets?: any }) => { activityTargetObjectRecords,
if (!targets) { }: {
return null; activityTargetObjectRecords: ActivityTargetObjectRecord[];
} }) => {
return ( return (
<StyledContainer> <StyledContainer>
{targets?.map(({ company, person }: any) => { {activityTargetObjectRecords?.map((activityTargetObjectRecord) => (
if (company) { <RecordChip
return ( record={activityTargetObjectRecord.targetObjectRecord}
<CompanyChip objectNameSingular={
key={company.id} activityTargetObjectRecord.targetObjectMetadataItem.nameSingular
id={company.id} }
name={company.name} />
avatarUrl={getLogoUrlFromDomainName(company.domainName)} ))}
/>
);
}
if (person) {
return (
<PersonChip
key={person.id}
id={person.id}
name={person.name.firstName + ' ' + person.name.lastName}
avatarUrl={person.avatarUrl ?? undefined}
/>
);
}
return <></>;
})}
</StyledContainer> </StyledContainer>
); );
}; };

View File

@ -4,7 +4,7 @@ import styled from '@emotion/styled';
import { ThreadPreview } from '@/activities/emails/components/ThreadPreview'; import { ThreadPreview } from '@/activities/emails/components/ThreadPreview';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { import {
H1Title, H1Title,
H1TitleFontColor, H1TitleFontColor,
@ -29,14 +29,14 @@ const StyledEmailCount = styled.span`
color: ${({ theme }) => theme.font.color.light}; color: ${({ theme }) => theme.font.color.light};
`; `;
export const Threads = ({ entity }: { entity: ActivityTargetableEntity }) => { export const Threads = ({ entity }: { entity: ActivityTargetableObject }) => {
const threadQuery = const threadQuery =
entity.type === 'Person' entity.targetObjectNameSingular === 'person'
? getTimelineThreadsFromPersonId ? getTimelineThreadsFromPersonId
: getTimelineThreadsFromCompanyId; : getTimelineThreadsFromCompanyId;
const threadQueryVariables = const threadQueryVariables =
entity.type === 'Person' entity.targetObjectNameSingular === 'person'
? { personId: entity.id } ? { personId: entity.id }
: { companyId: entity.id }; : { companyId: entity.id };
@ -50,7 +50,7 @@ export const Threads = ({ entity }: { entity: ActivityTargetableEntity }) => {
const timelineThreads: TimelineThread[] = const timelineThreads: TimelineThread[] =
threads.data[ threads.data[
entity.type === 'Person' entity.targetObjectNameSingular === 'Person'
? 'getTimelineThreadsFromPersonId' ? 'getTimelineThreadsFromPersonId'
: 'getTimelineThreadsFromCompanyId' : 'getTimelineThreadsFromCompanyId'
]; ];

View File

@ -7,7 +7,7 @@ const meta: Meta<typeof Threads> = {
component: Threads, component: Threads,
args: { args: {
entity: { entity: {
type: 'Person', targetObjectNameSingular: 'person',
id: '52ba3fd0-c723-4482-8b11-5fc24a587c71', id: '52ba3fd0-c723-4482-8b11-5fc24a587c71',
}, },
}, },

View File

@ -1,12 +1,14 @@
import { ChangeEvent, useRef } from 'react'; import { ChangeEvent, useRef } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { AttachmentList } from '@/activities/files/components/AttachmentList'; import { AttachmentList } from '@/activities/files/components/AttachmentList';
import { useAttachments } from '@/activities/files/hooks/useAttachments'; import { useAttachments } from '@/activities/files/hooks/useAttachments';
import { Attachment } from '@/activities/files/types/Attachment'; import { Attachment } from '@/activities/files/types/Attachment';
import { getFileType } from '@/activities/files/utils/getFileType'; import { getFileType } from '@/activities/files/utils/getFileType';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
@ -56,13 +58,13 @@ const StyledFileInput = styled.input`
`; `;
export const Attachments = ({ export const Attachments = ({
targetableEntity, targetableObject,
}: { }: {
targetableEntity: ActivityTargetableEntity; targetableObject: ActivityTargetableObject;
}) => { }) => {
const inputFileRef = useRef<HTMLInputElement>(null); const inputFileRef = useRef<HTMLInputElement>(null);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { attachments } = useAttachments(targetableEntity); const { attachments } = useAttachments(targetableObject);
const [uploadFile] = useUploadFileMutation(); const [uploadFile] = useUploadFileMutation();
@ -92,22 +94,23 @@ export const Attachments = ({
if (!attachmentUrl) { if (!attachmentUrl) {
return; return;
} }
if (!createOneAttachment) {
return;
}
await createOneAttachment({ const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
const attachmentToCreate = {
authorId: currentWorkspaceMember?.id, authorId: currentWorkspaceMember?.id,
name: file.name, name: file.name,
fullPath: attachmentUrl, fullPath: attachmentUrl,
type: getFileType(file.name), type: getFileType(file.name),
companyId: [targetableObjectFieldIdName]: targetableObject.id,
targetableEntity.type === 'Company' ? targetableEntity.id : null, };
personId: targetableEntity.type === 'Person' ? targetableEntity.id : null,
}); await createOneAttachment(attachmentToCreate);
}; };
if (attachments?.length === 0 && targetableEntity.type !== 'Custom') { if (!isNonEmptyArray(attachments)) {
return ( return (
<StyledTaskGroupEmptyContainer> <StyledTaskGroupEmptyContainer>
<StyledFileInput <StyledFileInput

View File

@ -1,15 +1,21 @@
import { Attachment } from '@/activities/files/types/Attachment'; import { Attachment } from '@/activities/files/types/Attachment';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { ActivityTargetableEntity } from '../../types/ActivityTargetableEntity';
// do we need to test this? // do we need to test this?
export const useAttachments = (entity: ActivityTargetableEntity) => { export const useAttachments = (targetableObject: ActivityTargetableObject) => {
const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
const { records: attachments } = useFindManyRecords({ const { records: attachments } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment, objectNameSingular: CoreObjectNameSingular.Attachment,
filter: { filter: {
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id }, [targetableObjectFieldIdName]: {
eq: targetableObject.id,
},
}, },
orderBy: { orderBy: {
createdAt: 'DescNullsFirst', createdAt: 'DescNullsFirst',

View File

@ -0,0 +1,55 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { Nullable } from '~/types/Nullable';
import { isDefined } from '~/utils/isDefined';
export const useActivityTargetObjectRecords = ({
activityId,
}: {
activityId: string;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { records: activityTargets } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
filter: {
activityId: {
eq: activityId,
},
},
});
const activityTargetObjectRecords = useMemo(() => {
return activityTargets
.map<Nullable<ActivityTargetObjectRecord>>((activityTarget) => {
const correspondingObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
isDefined(activityTarget[objectMetadataItem.nameSingular]) &&
!objectMetadataItem.isSystem,
);
if (!correspondingObjectMetadataItem) {
return null;
}
return {
activityTargetRecord: activityTarget,
targetObjectRecord:
activityTarget[correspondingObjectMetadataItem.nameSingular],
targetObjectMetadataItem: correspondingObjectMetadataItem,
targetObjectNameSingular:
correspondingObjectMetadataItem.nameSingular,
};
})
.filter(isDefined);
}, [activityTargets, objectMetadataItems]);
return {
activityTargetObjectRecords,
};
};

View File

@ -0,0 +1,28 @@
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
export const useActivityTargets = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const targetObjectFieldName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
const { records: activityTargets } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
filter: {
[targetObjectFieldName]: {
eq: targetableObject.id,
},
},
});
return {
activityTargets: activityTargets as ActivityTarget[],
};
};

View File

@ -4,8 +4,10 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { Activity, ActivityType } from '@/activities/types/Activity'; import { Activity, ActivityType } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
@ -14,13 +16,13 @@ import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope
import { activityTargetableEntityArrayState } from '../states/activityTargetableEntityArrayState'; import { activityTargetableEntityArrayState } from '../states/activityTargetableEntityArrayState';
import { viewableActivityIdState } from '../states/viewableActivityIdState'; import { viewableActivityIdState } from '../states/viewableActivityIdState';
import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '../types/ActivityTargetableEntity';
import { getTargetableEntitiesWithParents } from '../utils/getTargetableEntitiesWithParents'; import { flattenTargetableObjectsAndTheirRelatedTargetableObjects } from '../utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects';
export const useOpenCreateActivityDrawer = () => { export const useOpenCreateActivityDrawer = () => {
const { openRightDrawer } = useRightDrawer(); const { openRightDrawer } = useRightDrawer();
const { createOneRecord: createOneActivityTarget } = const { createManyRecords: createManyActivityTargets } =
useCreateOneRecord<ActivityTarget>({ useCreateManyRecords<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget, objectNameSingular: CoreObjectNameSingular.ActivityTarget,
}); });
const { createOneRecord: createOneActivity } = useCreateOneRecord<Activity>({ const { createOneRecord: createOneActivity } = useCreateOneRecord<Activity>({
@ -37,15 +39,17 @@ export const useOpenCreateActivityDrawer = () => {
return useCallback( return useCallback(
async ({ async ({
type, type,
targetableEntities, targetableObjects,
assigneeId, assigneeId,
}: { }: {
type: ActivityType; type: ActivityType;
targetableEntities?: ActivityTargetableEntity[]; targetableObjects?: ActivityTargetableObject[];
assigneeId?: string; assigneeId?: string;
}) => { }) => {
const targetableEntitiesWithRelations = targetableEntities const flattenedTargetableObjects = targetableObjects
? getTargetableEntitiesWithParents(targetableEntities) ? flattenTargetableObjectsAndTheirRelatedTargetableObjects(
targetableObjects,
)
: []; : [];
const createdActivity = await createOneActivity?.({ const createdActivity = await createOneActivity?.({
@ -61,21 +65,25 @@ export const useOpenCreateActivityDrawer = () => {
return; return;
} }
await Promise.all( const activityTargetsToCreate = flattenedTargetableObjects.map(
targetableEntitiesWithRelations.map(async (targetableEntity) => { (targetableObject) => {
await createOneActivityTarget?.({ const targetableObjectFieldIdName =
companyId: getActivityTargetObjectFieldIdName({
targetableEntity.type === 'Company' ? targetableEntity.id : null, nameSingular: targetableObject.targetObjectNameSingular,
personId: });
targetableEntity.type === 'Person' ? targetableEntity.id : null,
return {
[targetableObjectFieldIdName]: targetableObject.id,
activityId: createdActivity.id, activityId: createdActivity.id,
}); };
}), },
); );
await createManyActivityTargets(activityTargetsToCreate);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
setViewableActivityId(createdActivity.id); setViewableActivityId(createdActivity.id);
setActivityTargetableEntityArray(targetableEntities ?? []); setActivityTargetableEntityArray(targetableObjects ?? []);
openRightDrawer(RightDrawerPages.CreateActivity); openRightDrawer(RightDrawerPages.CreateActivity);
}, },
[ [
@ -84,7 +92,7 @@ export const useOpenCreateActivityDrawer = () => {
setHotkeyScope, setHotkeyScope,
setViewableActivityId, setViewableActivityId,
createOneActivity, createOneActivity,
createOneActivityTarget, createManyActivityTargets,
currentWorkspaceMember, currentWorkspaceMember,
], ],
); );

View File

@ -4,10 +4,7 @@ import { ActivityType } from '@/activities/types/Activity';
import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates'; import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates';
import { getRecordTableScopeInjector } from '@/object-record/record-table/utils/getRecordTableScopeInjector'; import { getRecordTableScopeInjector } from '@/object-record/record-table/utils/getRecordTableScopeInjector';
import { import { ActivityTargetableObject } from '../types/ActivityTargetableEntity';
ActivityTargetableEntity,
ActivityTargetableEntityType,
} from '../types/ActivityTargetableEntity';
import { useOpenCreateActivityDrawer } from './useOpenCreateActivityDrawer'; import { useOpenCreateActivityDrawer } from './useOpenCreateActivityDrawer';
@ -25,8 +22,8 @@ export const useOpenCreateActivityDrawerForSelectedRowIds = (
({ snapshot }) => ({ snapshot }) =>
( (
type: ActivityType, type: ActivityType,
entityType: ActivityTargetableEntityType, objectNameSingular: string,
relatedEntities?: ActivityTargetableEntity[], relatedEntities?: ActivityTargetableObject[],
) => { ) => {
const selectedRowIds = const selectedRowIds =
injectSelectorSnapshotValueWithRecordTableScopeId( injectSelectorSnapshotValueWithRecordTableScopeId(
@ -34,18 +31,21 @@ export const useOpenCreateActivityDrawerForSelectedRowIds = (
selectedRowIdsScopeInjector, selectedRowIdsScopeInjector,
); );
let activityTargetableEntityArray: ActivityTargetableEntity[] = let activityTargetableEntityArray: ActivityTargetableObject[] =
selectedRowIds.map((id: string) => ({ selectedRowIds.map((id: string) => ({
type: entityType, type: 'Custom',
targetObjectNameSingular: objectNameSingular,
id, id,
})); }));
if (relatedEntities) { if (relatedEntities) {
activityTargetableEntityArray = activityTargetableEntityArray =
activityTargetableEntityArray.concat(relatedEntities); activityTargetableEntityArray.concat(relatedEntities);
} }
openCreateActivityDrawer({ openCreateActivityDrawer({
type, type,
targetableEntities: activityTargetableEntityArray, targetableObjects: activityTargetableEntityArray,
}); });
}, },
[ [

View File

@ -1,22 +1,15 @@
import { useCallback, useMemo, useState } from 'react';
import { useQuery } from '@apollo/client';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { v4 } from 'uuid';
import { useHandleCheckableActivityTargetChange } from '@/activities/hooks/useHandleCheckableActivityTargetChange';
import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { flatMapAndSortEntityForSelectArrayOfArrayByName } from '@/activities/utils/flatMapAndSortEntityForSelectArrayByName'; import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { MultipleEntitySelect } from '@/object-record/relation-picker/components/MultipleEntitySelect'; import { MultipleObjectRecordSelect } from '@/object-record/relation-picker/components/MultipleObjectRecordSelect';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker'; import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { assertNotNull } from '~/utils/assert';
type ActivityTargetInlineCellEditModeProps = {
activityId: string;
activityTargets: Array<Pick<ActivityTarget, 'id' | 'personId' | 'companyId'>>;
};
const StyledSelectContainer = styled.div` const StyledSelectContainer = styled.div`
left: 0px; left: 0px;
@ -24,125 +17,77 @@ const StyledSelectContainer = styled.div`
top: -8px; top: -8px;
`; `;
type ActivityTargetInlineCellEditModeProps = {
activityId: string;
activityTargetObjectRecords: ActivityTargetObjectRecord[];
};
export const ActivityTargetInlineCellEditMode = ({ export const ActivityTargetInlineCellEditMode = ({
activityId, activityId,
activityTargets, activityTargetObjectRecords,
}: ActivityTargetInlineCellEditModeProps) => { }: ActivityTargetInlineCellEditModeProps) => {
const [searchFilter, setSearchFilter] = useState(''); const selectedObjectRecordIds = activityTargetObjectRecords.map(
(activityTarget) => ({
const initialPeopleIds = useMemo( objectNameSingular: activityTarget.targetObjectNameSingular,
() => id: activityTarget.targetObjectRecord.id,
activityTargets }),
?.filter(({ personId }) => personId !== null)
.map(({ personId }) => personId)
.filter(assertNotNull) ?? [],
[activityTargets],
); );
const initialCompanyIds = useMemo( const { createManyRecords: createManyActivityTargets } =
() => useCreateManyRecords<ActivityTarget>({
activityTargets objectNameSingular: CoreObjectNameSingular.ActivityTarget,
?.filter(({ companyId }) => companyId !== null)
.map(({ companyId }) => companyId)
.filter(assertNotNull) ?? [],
[activityTargets],
);
const initialSelectedEntityIds = useMemo(
() =>
[...initialPeopleIds, ...initialCompanyIds].reduce<
Record<string, boolean>
>((result, entityId) => ({ ...result, [entityId]: true }), {}),
[initialPeopleIds, initialCompanyIds],
);
const { findManyRecordsQuery: findManyPeopleQuery } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Person,
});
const { findManyRecordsQuery: findManyCompaniesQuery } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Company,
}); });
const useFindManyPeopleQuery = (options: any) => const { deleteManyRecords: deleteManyActivityTargets } = useDeleteManyRecords(
useQuery(findManyPeopleQuery, options); {
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
},
);
const useFindManyCompaniesQuery = (options: any) =>
useQuery(findManyCompaniesQuery, options);
const [selectedEntityIds, setSelectedEntityIds] = useState<
Record<string, boolean>
>(initialSelectedEntityIds);
const { identifiersMapper, searchQuery } = useRelationPicker();
const people = useFilteredSearchEntityQuery({
queryHook: useFindManyPeopleQuery,
filters: [
{
fieldNames: searchQuery?.computeFilterFields?.('person') ?? [],
filter: searchFilter,
},
],
orderByField: 'createdAt',
mappingFunction: (record: any) => identifiersMapper?.(record, 'person'),
selectedIds: initialPeopleIds,
objectNameSingular: CoreObjectNameSingular.Person,
limit: 3,
});
const companies = useFilteredSearchEntityQuery({
queryHook: useFindManyCompaniesQuery,
filters: [
{
fieldNames: searchQuery?.computeFilterFields?.('company') ?? [],
filter: searchFilter,
},
],
orderByField: 'createdAt',
mappingFunction: (record: any) => identifiersMapper?.(record, 'company'),
selectedIds: initialCompanyIds,
objectNameSingular: CoreObjectNameSingular.Company,
limit: 3,
});
const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([
people.selectedEntities,
companies.selectedEntities,
]);
const filteredSelectedEntities =
flatMapAndSortEntityForSelectArrayOfArrayByName([
people.filteredSelectedEntities,
companies.filteredSelectedEntities,
]);
const entitiesToSelect = flatMapAndSortEntityForSelectArrayOfArrayByName([
people.entitiesToSelect,
companies.entitiesToSelect,
]);
const handleCheckItemsChange = useHandleCheckableActivityTargetChange({
activityId,
currentActivityTargets: activityTargets,
});
const { closeInlineCell: closeEditableField } = useInlineCell(); const { closeInlineCell: closeEditableField } = useInlineCell();
const handleSubmit = useCallback(() => { const handleSubmit = async (selectedRecords: ObjectRecordForSelect[]) => {
handleCheckItemsChange(
selectedEntityIds,
entitiesToSelect,
selectedEntities,
);
closeEditableField(); closeEditableField();
}, [
closeEditableField, const activityTargetRecordsToDelete = activityTargetObjectRecords.filter(
entitiesToSelect, (activityTargetObjectRecord) =>
handleCheckItemsChange, !selectedRecords.some(
selectedEntities, (selectedRecord) =>
selectedEntityIds, selectedRecord.recordIdentifier.id ===
]); activityTargetObjectRecord.targetObjectRecord.id,
),
);
const activityTargetRecordsToCreate = selectedRecords.filter(
(selectedRecord) =>
!activityTargetObjectRecords.some(
(activityTargetObjectRecord) =>
activityTargetObjectRecord.targetObjectRecord.id ===
selectedRecord.recordIdentifier.id,
),
);
if (activityTargetRecordsToCreate.length > 0) {
await createManyActivityTargets(
activityTargetRecordsToCreate.map((selectedRecord) => ({
id: v4(),
activityId,
[getActivityTargetObjectFieldIdName({
nameSingular: selectedRecord.objectMetadataItem.nameSingular,
})]: selectedRecord.recordIdentifier.id,
})),
);
}
if (activityTargetRecordsToDelete.length > 0) {
await deleteManyActivityTargets(
activityTargetRecordsToDelete.map(
(activityTargetObjectRecord) =>
activityTargetObjectRecord.activityTargetRecord.id,
),
);
}
};
const handleCancel = () => { const handleCancel = () => {
closeEditableField(); closeEditableField();
@ -150,17 +95,8 @@ export const ActivityTargetInlineCellEditMode = ({
return ( return (
<StyledSelectContainer> <StyledSelectContainer>
<MultipleEntitySelect <MultipleObjectRecordSelect
entities={{ selectedObjectRecordIds={selectedObjectRecordIds}
entitiesToSelect,
filteredSelectedEntities,
selectedEntities,
loading: false,
}}
onChange={setSelectedEntityIds}
onSearchFilterChange={setSearchFilter}
searchFilter={searchFilter}
value={selectedEntityIds}
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />

View File

@ -1,9 +1,8 @@
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips'; import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { ActivityTargetInlineCellEditMode } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditMode'; import { ActivityTargetInlineCellEditMode } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditMode';
import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity'; import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { RecordInlineCellContainer } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer'; import { RecordInlineCellContainer } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer';
import { FieldRecoilScopeContext } from '@/object-record/record-inline-cell/states/recoil-scope-contexts/FieldRecoilScopeContext'; import { FieldRecoilScopeContext } from '@/object-record/record-inline-cell/states/recoil-scope-contexts/FieldRecoilScopeContext';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
@ -23,14 +22,8 @@ type ActivityTargetsInlineCellProps = {
export const ActivityTargetsInlineCell = ({ export const ActivityTargetsInlineCell = ({
activity, activity,
}: ActivityTargetsInlineCellProps) => { }: ActivityTargetsInlineCellProps) => {
const activityTargetIds = const { activityTargetObjectRecords } = useActivityTargetObjectRecords({
activity?.activityTargets?.edges?.map( activityId: activity?.id ?? '',
(activityTarget) => activityTarget.node.id,
) ?? [];
const { records: activityTargets } = useFindManyRecords<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
filter: { id: { in: activityTargetIds } },
}); });
return ( return (
@ -44,11 +37,15 @@ export const ActivityTargetsInlineCell = ({
editModeContent={ editModeContent={
<ActivityTargetInlineCellEditMode <ActivityTargetInlineCellEditMode
activityId={activity?.id ?? ''} activityId={activity?.id ?? ''}
activityTargets={activityTargets as any} activityTargetObjectRecords={activityTargetObjectRecords as any}
/> />
} }
label="Relations" label="Relations"
displayModeContent={<ActivityTargetChips targets={activityTargets} />} displayModeContent={
<ActivityTargetChips
activityTargetObjectRecords={activityTargetObjectRecords}
/>
}
isDisplayModeContentEmpty={ isDisplayModeContentEmpty={
activity?.activityTargets?.edges?.length === 0 activity?.activityTargets?.edges?.length === 0
} }

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { NoteList } from '@/activities/notes/components/NoteList'; import { NoteList } from '@/activities/notes/components/NoteList';
import { useNotes } from '@/activities/notes/hooks/useNotes'; import { useNotes } from '@/activities/notes/hooks/useNotes';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { IconPlus } from '@/ui/display/icon'; import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
@ -44,12 +44,16 @@ const StyledNotesContainer = styled.div`
overflow: auto; overflow: auto;
`; `;
export const Notes = ({ entity }: { entity: ActivityTargetableEntity }) => { export const Notes = ({
const { notes } = useNotes(entity); targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const { notes } = useNotes(targetableObject);
const openCreateActivity = useOpenCreateActivityDrawer(); const openCreateActivity = useOpenCreateActivityDrawer();
if (notes?.length === 0 && entity.type !== 'Custom') { if (notes?.length === 0) {
return ( return (
<StyledTaskGroupEmptyContainer> <StyledTaskGroupEmptyContainer>
<StyledEmptyTaskGroupTitle>No note yet</StyledEmptyTaskGroupTitle> <StyledEmptyTaskGroupTitle>No note yet</StyledEmptyTaskGroupTitle>
@ -61,7 +65,7 @@ export const Notes = ({ entity }: { entity: ActivityTargetableEntity }) => {
onClick={() => onClick={() =>
openCreateActivity({ openCreateActivity({
type: 'Note', type: 'Note',
targetableEntities: [entity], targetableObjects: [targetableObject],
}) })
} }
/> />
@ -83,7 +87,7 @@ export const Notes = ({ entity }: { entity: ActivityTargetableEntity }) => {
onClick={() => onClick={() =>
openCreateActivity({ openCreateActivity({
type: 'Note', type: 'Note',
targetableEntities: [entity], targetableObjects: [targetableObject],
}) })
} }
></Button> ></Button>

View File

@ -1,17 +1,13 @@
import { useActivityTargets } from '@/activities/hooks/useActivityTargets';
import { Note } from '@/activities/types/Note'; import { Note } from '@/activities/types/Note';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { OrderByField } from '@/object-metadata/types/OrderByField'; import { OrderByField } from '@/object-metadata/types/OrderByField';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { ActivityTargetableEntity } from '../../types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '../../types/ActivityTargetableEntity';
export const useNotes = (entity: ActivityTargetableEntity) => { export const useNotes = (targetableObject: ActivityTargetableObject) => {
const { records: activityTargets } = useFindManyRecords({ const { activityTargets } = useActivityTargets({ targetableObject });
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
filter: {
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id },
},
});
const filter = { const filter = {
id: { id: {
@ -19,6 +15,7 @@ export const useNotes = (entity: ActivityTargetableEntity) => {
}, },
type: { eq: 'Note' }, type: { eq: 'Note' },
}; };
const orderBy = { const orderBy = {
createdAt: 'AscNullsFirst', createdAt: 'AscNullsFirst',
} as OrderByField; } as OrderByField;

View File

@ -1,9 +1,9 @@
import { atom } from 'recoil'; import { atom } from 'recoil';
import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '../types/ActivityTargetableEntity';
export const activityTargetableEntityArrayState = atom< export const activityTargetableEntityArrayState = atom<
ActivityTargetableEntity[] ActivityTargetableObject[]
>({ >({
key: 'activities/targetable-entity-array', key: 'activities/targetable-entity-array',
default: [], default: [],

View File

@ -2,6 +2,7 @@ import { Meta, StoryObj } from '@storybook/react';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext'; import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups'; import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
@ -35,9 +36,11 @@ export const Empty: Story = {};
export const WithTasks: Story = { export const WithTasks: Story = {
args: { args: {
entity: { targetableObjects: [
id: mockedTasks[0].authorId, {
type: 'Person', id: mockedTasks[0].authorId,
}, targetObjectNameSingular: 'person',
},
] as ActivityTargetableObject[],
}, },
}; };

View File

@ -1,16 +1,18 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { IconPlus } from '@/ui/display/icon'; import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
export const AddTaskButton = ({ export const AddTaskButton = ({
activityTargetEntity, activityTargetableObjects,
}: { }: {
activityTargetEntity?: ActivityTargetableEntity; activityTargetableObjects?: ActivityTargetableObject[];
}) => { }) => {
const openCreateActivity = useOpenCreateActivityDrawer(); const openCreateActivity = useOpenCreateActivityDrawer();
if (!activityTargetEntity) { if (!isNonEmptyArray(activityTargetableObjects)) {
return <></>; return <></>;
} }
@ -23,7 +25,7 @@ export const AddTaskButton = ({
onClick={() => onClick={() =>
openCreateActivity({ openCreateActivity({
type: 'Task', type: 'Task',
targetableEntities: [activityTargetEntity], targetableObjects: activityTargetableObjects,
}) })
} }
></Button> ></Button>

View File

@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext'; import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups'; import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
@ -14,16 +14,16 @@ const StyledContainer = styled.div`
overflow: auto; overflow: auto;
`; `;
export const EntityTasks = ({ export const ObjectTasks = ({
entity, targetableObject,
}: { }: {
entity: ActivityTargetableEntity; targetableObject: ActivityTargetableObject;
}) => { }) => {
return ( return (
<StyledContainer> <StyledContainer>
<RecoilScope CustomRecoilScopeContext={TasksRecoilScopeContext}> <RecoilScope CustomRecoilScopeContext={TasksRecoilScopeContext}>
<ObjectFilterDropdownScope filterScopeId="entity-tasks-filter-scope"> <ObjectFilterDropdownScope filterScopeId="entity-tasks-filter-scope">
<TaskGroups entity={entity} showAddButton /> <TaskGroups targetableObjects={[targetableObject]} showAddButton />
</ObjectFilterDropdownScope> </ObjectFilterDropdownScope>
</RecoilScope> </RecoilScope>
</StyledContainer> </StyledContainer>

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext'; import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { useTasks } from '@/activities/tasks/hooks/useTasks'; import { useTasks } from '@/activities/tasks/hooks/useTasks';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { IconPlus } from '@/ui/display/icon'; import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
import { activeTabIdScopedState } from '@/ui/layout/tab/states/activeTabIdScopedState'; import { activeTabIdScopedState } from '@/ui/layout/tab/states/activeTabIdScopedState';
@ -12,12 +12,6 @@ import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoi
import { AddTaskButton } from './AddTaskButton'; import { AddTaskButton } from './AddTaskButton';
import { TaskList } from './TaskList'; import { TaskList } from './TaskList';
type TaskGroupsProps = {
filterDropdownId?: string;
entity?: ActivityTargetableEntity;
showAddButton?: boolean;
};
const StyledTaskGroupEmptyContainer = styled.div` const StyledTaskGroupEmptyContainer = styled.div`
align-items: center; align-items: center;
align-self: stretch; align-self: stretch;
@ -52,9 +46,15 @@ const StyledContainer = styled.div`
flex-direction: column; flex-direction: column;
`; `;
type TaskGroupsProps = {
filterDropdownId?: string;
targetableObjects?: ActivityTargetableObject[];
showAddButton?: boolean;
};
export const TaskGroups = ({ export const TaskGroups = ({
filterDropdownId, filterDropdownId,
entity, targetableObjects,
showAddButton, showAddButton,
}: TaskGroupsProps) => { }: TaskGroupsProps) => {
const { const {
@ -62,7 +62,10 @@ export const TaskGroups = ({
upcomingTasks, upcomingTasks,
unscheduledTasks, unscheduledTasks,
completedTasks, completedTasks,
} = useTasks({ filterDropdownId: filterDropdownId, entity }); } = useTasks({
filterDropdownId: filterDropdownId,
targetableObjects: targetableObjects ?? [],
});
const openCreateActivity = useOpenCreateActivityDrawer(); const openCreateActivity = useOpenCreateActivityDrawer();
@ -71,10 +74,6 @@ export const TaskGroups = ({
TasksRecoilScopeContext, TasksRecoilScopeContext,
); );
if (entity?.type === 'Custom') {
return <></>;
}
if ( if (
(activeTabId !== 'done' && (activeTabId !== 'done' &&
todayOrPreviousTasks?.length === 0 && todayOrPreviousTasks?.length === 0 &&
@ -93,7 +92,7 @@ export const TaskGroups = ({
onClick={() => onClick={() =>
openCreateActivity({ openCreateActivity({
type: 'Task', type: 'Task',
targetableEntities: entity ? [entity] : undefined, targetableObjects,
}) })
} }
/> />
@ -107,7 +106,9 @@ export const TaskGroups = ({
<TaskList <TaskList
tasks={completedTasks ?? []} tasks={completedTasks ?? []}
button={ button={
showAddButton && <AddTaskButton activityTargetEntity={entity} /> showAddButton && (
<AddTaskButton activityTargetableObjects={targetableObjects} />
)
} }
/> />
) : ( ) : (
@ -116,7 +117,9 @@ export const TaskGroups = ({
title="Today" title="Today"
tasks={todayOrPreviousTasks ?? []} tasks={todayOrPreviousTasks ?? []}
button={ button={
showAddButton && <AddTaskButton activityTargetEntity={entity} /> showAddButton && (
<AddTaskButton activityTargetableObjects={targetableObjects} />
)
} }
/> />
<TaskList <TaskList
@ -125,7 +128,7 @@ export const TaskGroups = ({
button={ button={
showAddButton && showAddButton &&
!todayOrPreviousTasks?.length && ( !todayOrPreviousTasks?.length && (
<AddTaskButton activityTargetEntity={entity} /> <AddTaskButton activityTargetableObjects={targetableObjects} />
) )
} }
/> />
@ -136,7 +139,7 @@ export const TaskGroups = ({
showAddButton && showAddButton &&
!todayOrPreviousTasks?.length && !todayOrPreviousTasks?.length &&
!upcomingTasks?.length && ( !upcomingTasks?.length && (
<AddTaskButton activityTargetEntity={entity} /> <AddTaskButton activityTargetableObjects={targetableObjects} />
) )
} }
/> />

View File

@ -2,12 +2,10 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips'; import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity'; import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { getActivitySummary } from '@/activities/utils/getActivitySummary'; import { getActivitySummary } from '@/activities/utils/getActivitySummary';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { IconCalendar, IconComment } from '@/ui/display/icon'; import { IconCalendar, IconComment } from '@/ui/display/icon';
import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip'; import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip';
import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox'; import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox';
@ -76,14 +74,8 @@ export const TaskRow = ({
const body = getActivitySummary(task.body); const body = getActivitySummary(task.body);
const { completeTask } = useCompleteTask(task); const { completeTask } = useCompleteTask(task);
const activityTargetIds = const { activityTargetObjectRecords } = useActivityTargetObjectRecords({
task?.activityTargets?.edges?.map( activityId: task.id,
(activityTarget) => activityTarget.node.id,
) ?? [];
const { records: activityTargets } = useFindManyRecords<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
filter: { id: { in: activityTargetIds } },
}); });
return ( return (
@ -115,7 +107,9 @@ export const TaskRow = ({
)} )}
</StyledTaskBody> </StyledTaskBody>
<StyledFieldsContainer> <StyledFieldsContainer>
<ActivityTargetChips targets={activityTargets} /> <ActivityTargetChips
activityTargetObjectRecords={activityTargetObjectRecords}
/>
<StyledDueDate <StyledDueDate
isPast={ isPast={
!!task.dueAt && hasDatePassed(task.dueAt) && !task.completedAt !!task.dueAt && hasDatePassed(task.dueAt) && !task.completedAt

View File

@ -1,55 +1,75 @@
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Activity } from '@/activities/types/Activity'; import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { LeafObjectRecordFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { parseDate } from '~/utils/date-utils'; import { parseDate } from '~/utils/date-utils';
import { isDefined } from '~/utils/isDefined';
type UseTasksProps = { type UseTasksProps = {
filterDropdownId?: string; filterDropdownId?: string;
entity?: ActivityTargetableEntity; targetableObjects: ActivityTargetableObject[];
}; };
export const useTasks = (props?: UseTasksProps) => { export const useTasks = ({
const { filterDropdownId, entity } = props ?? {}; targetableObjects,
filterDropdownId,
}: UseTasksProps) => {
const { selectedFilter } = useFilterDropdown({ const { selectedFilter } = useFilterDropdown({
filterDropdownId: filterDropdownId, filterDropdownId,
}); });
const targetableObjectsFilter =
targetableObjects.reduce<LeafObjectRecordFilter>(
(aggregateFilter, targetableObject) => {
const targetableObjectFieldName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
if (isNonEmptyString(targetableObject.id)) {
aggregateFilter[targetableObjectFieldName] = {
eq: targetableObject.id,
};
}
return aggregateFilter;
},
{},
);
const { records: activityTargets } = useFindManyRecords({ const { records: activityTargets } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.ActivityTarget, objectNameSingular: CoreObjectNameSingular.ActivityTarget,
filter: isDefined(entity) filter: targetableObjectsFilter,
? {
[entity?.type === 'Company' ? 'companyId' : 'personId']: {
eq: entity?.id,
},
}
: undefined,
}); });
const skipRequest = !isNonEmptyArray(activityTargets) && !selectedFilter;
const idFilter = {
id: {
in: activityTargets.map((activityTarget) => activityTarget.activityId),
},
};
const assigneeIdFilter = selectedFilter
? {
assigneeId: {
in: JSON.parse(selectedFilter.value),
},
}
: undefined;
const { records: completeTasksData } = useFindManyRecords({ const { records: completeTasksData } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Activity, objectNameSingular: CoreObjectNameSingular.Activity,
skip: !entity && !selectedFilter, skip: skipRequest,
filter: { filter: {
completedAt: { is: 'NOT_NULL' }, completedAt: { is: 'NOT_NULL' },
...(isDefined(entity) && { ...idFilter,
id: {
in: activityTargets?.map(
(activityTarget) => activityTarget.activityId,
),
},
}),
type: { eq: 'Task' }, type: { eq: 'Task' },
...(isNonEmptyString(selectedFilter?.value) && { ...assigneeIdFilter,
assigneeId: {
in: JSON.parse(selectedFilter?.value),
},
}),
}, },
orderBy: { orderBy: {
createdAt: 'DescNullsFirst', createdAt: 'DescNullsFirst',
@ -58,22 +78,12 @@ export const useTasks = (props?: UseTasksProps) => {
const { records: incompleteTaskData } = useFindManyRecords({ const { records: incompleteTaskData } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Activity, objectNameSingular: CoreObjectNameSingular.Activity,
skip: !entity && !selectedFilter, skip: skipRequest,
filter: { filter: {
completedAt: { is: 'NULL' }, completedAt: { is: 'NULL' },
...(isDefined(entity) && { ...idFilter,
id: {
in: activityTargets?.map(
(activityTarget) => activityTarget.activityId,
),
},
}),
type: { eq: 'Task' }, type: { eq: 'Task' },
...(isNonEmptyString(selectedFilter?.value) && { ...assigneeIdFilter,
assigneeId: {
in: JSON.parse(selectedFilter?.value),
},
}),
}, },
orderBy: { orderBy: {
createdAt: 'DescNullsFirst', createdAt: 'DescNullsFirst',

View File

@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { ActivityCreateButton } from '@/activities/components/ActivityCreateButton'; import { ActivityCreateButton } from '@/activities/components/ActivityCreateButton';
import { useActivityTargets } from '@/activities/hooks/useActivityTargets';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { Activity } from '@/activities/types/Activity'; import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -48,20 +50,21 @@ const StyledEmptyTimelineSubTitle = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(2)}; margin-bottom: ${({ theme }) => theme.spacing(2)};
`; `;
export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => { export const Timeline = ({
const { records: activityTargets, loading } = useFindManyRecords({ targetableObject,
objectNameSingular: CoreObjectNameSingular.ActivityTarget, }: {
filter: { targetableObject: ActivityTargetableObject;
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id }, }) => {
}, const { activityTargets } = useActivityTargets({ targetableObject });
});
const { records: activities } = useFindManyRecords({ const { records: activities } = useFindManyRecords({
skip: !activityTargets?.length, skip: !activityTargets?.length,
objectNameSingular: CoreObjectNameSingular.Activity, objectNameSingular: CoreObjectNameSingular.Activity,
filter: { filter: {
id: { id: {
in: activityTargets?.map((activityTarget) => activityTarget.activityId), in: activityTargets
?.map((activityTarget) => activityTarget.activityId)
.filter(isNonEmptyString),
}, },
}, },
orderBy: { orderBy: {
@ -71,10 +74,6 @@ export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => {
const openCreateActivity = useOpenCreateActivityDrawer(); const openCreateActivity = useOpenCreateActivityDrawer();
if (loading || entity.type === 'Custom') {
return <></>;
}
if (!activities.length) { if (!activities.length) {
return ( return (
<StyledTimelineEmptyContainer> <StyledTimelineEmptyContainer>
@ -84,13 +83,13 @@ export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => {
onNoteClick={() => onNoteClick={() =>
openCreateActivity({ openCreateActivity({
type: 'Note', type: 'Note',
targetableEntities: [entity], targetableObjects: [targetableObject],
}) })
} }
onTaskClick={() => onTaskClick={() =>
openCreateActivity({ openCreateActivity({
type: 'Task', type: 'Task',
targetableEntities: [entity], targetableObjects: [targetableObject],
}) })
} }
/> />

View File

@ -145,7 +145,7 @@ type TimelineActivityProps = {
| 'type' | 'type'
| 'comments' | 'comments'
| 'dueAt' | 'dueAt'
> & { author: Pick<WorkspaceMember, 'name' | 'avatarUrl'> } & { > & { author?: Pick<WorkspaceMember, 'name' | 'avatarUrl'> } & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null; assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
}; };
isLastActivity?: boolean; isLastActivity?: boolean;
@ -165,8 +165,8 @@ export const TimelineActivity = ({
<StyledTimelineItemContainer> <StyledTimelineItemContainer>
<StyledAvatarContainer> <StyledAvatarContainer>
<Avatar <Avatar
avatarUrl={activity.author.avatarUrl} avatarUrl={activity.author?.avatarUrl}
placeholder={activity.author.name.firstName ?? ''} placeholder={activity.author?.name.firstName ?? ''}
size="sm" size="sm"
type="rounded" type="rounded"
/> />
@ -175,7 +175,8 @@ export const TimelineActivity = ({
<StyledItemTitleContainer> <StyledItemTitleContainer>
<StyledItemAuthorText> <StyledItemAuthorText>
<span> <span>
{activity.author.name.firstName} {activity.author.name.lastName} {activity.author?.name.firstName}{' '}
{activity.author?.name.lastName}
</span> </span>
created a {activity.type.toLowerCase()} created a {activity.type.toLowerCase()}
</StyledItemAuthorText> </StyledItemAuthorText>

View File

@ -0,0 +1,9 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type ActivityTargetObjectRecord = {
targetObjectMetadataItem: ObjectMetadataItem;
activityTargetRecord: ObjectRecord;
targetObjectRecord: ObjectRecord;
targetObjectNameSingular: string;
};

View File

@ -1,7 +1,5 @@
export type ActivityTargetableEntityType = 'Person' | 'Company' | 'Custom'; export type ActivityTargetableObject = {
export type ActivityTargetableEntity = {
id: string; id: string;
type: ActivityTargetableEntityType; targetObjectNameSingular: string;
relatedEntities?: ActivityTargetableEntity[]; relatedTargetableObjects?: ActivityTargetableObject[];
}; };

View File

@ -1,7 +0,0 @@
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { ActivityTargetableEntityType } from './ActivityTargetableEntity';
export type ActivityTargetableEntityForSelect = EntityForSelect & {
entityType: ActivityTargetableEntityType;
};

View File

@ -1,40 +1,41 @@
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getTargetableEntitiesWithParents } from '@/activities/utils/getTargetableEntitiesWithParents'; import { flattenTargetableObjectsAndTheirRelatedTargetableObjects } from '@/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects';
describe('getTargetableEntitiesWithParents', () => { describe('getTargetableEntitiesWithParents', () => {
it('should return the correct value', () => { it('should return the correct value', () => {
const entities: ActivityTargetableEntity[] = [ const entities: ActivityTargetableObject[] = [
{ {
id: '1', id: '1',
type: 'Person', targetObjectNameSingular: 'person',
relatedEntities: [ relatedTargetableObjects: [
{ {
id: '2', id: '2',
type: 'Company', targetObjectNameSingular: 'company',
}, },
], ],
}, },
{ {
id: '4', id: '4',
type: 'Company', targetObjectNameSingular: 'person',
}, },
{ {
id: '3', id: '3',
type: 'Custom', targetObjectNameSingular: 'car',
relatedEntities: [ relatedTargetableObjects: [
{ {
id: '6', id: '6',
type: 'Person', targetObjectNameSingular: 'person',
}, },
{ {
id: '5', id: '5',
type: 'Company', targetObjectNameSingular: 'company',
}, },
], ],
}, },
]; ];
const res = getTargetableEntitiesWithParents(entities); const res =
flattenTargetableObjectsAndTheirRelatedTargetableObjects(entities);
expect(res).toHaveLength(6); expect(res).toHaveLength(6);
expect(res[0].id).toBe('1'); expect(res[0].id).toBe('1');

View File

@ -0,0 +1,21 @@
import { ActivityTargetableObject } from '../types/ActivityTargetableEntity';
export const flattenTargetableObjectsAndTheirRelatedTargetableObjects = (
targetableObjectsWithRelatedTargetableObjects: ActivityTargetableObject[],
): ActivityTargetableObject[] => {
const flattenedTargetableObjects: ActivityTargetableObject[] = [];
for (const targetableObject of targetableObjectsWithRelatedTargetableObjects ??
[]) {
flattenedTargetableObjects.push(targetableObject);
if (targetableObject.relatedTargetableObjects) {
for (const relatedEntity of targetableObject.relatedTargetableObjects ??
[]) {
flattenedTargetableObjects.push(relatedEntity);
}
}
}
return flattenedTargetableObjects;
};

View File

@ -0,0 +1,15 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
export const getActivityTargetObjectFieldIdName = ({
nameSingular,
}: {
nameSingular: string;
}) => {
const isCoreObject =
nameSingular === CoreObjectNameSingular.Company ||
nameSingular === CoreObjectNameSingular.Person;
const objectFieldIdName = `${!isCoreObject ? '_' : ''}${nameSingular}Id`;
return objectFieldIdName;
};

View File

@ -1,16 +0,0 @@
import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity';
export const getTargetableEntitiesWithParents = (
entities: ActivityTargetableEntity[],
): ActivityTargetableEntity[] => {
const entitiesWithRelations: ActivityTargetableEntity[] = [];
for (const entity of entities ?? []) {
entitiesWithRelations.push(entity);
if (entity.relatedEntities) {
for (const relatedEntity of entity.relatedEntities ?? []) {
entitiesWithRelations.push(relatedEntity);
}
}
}
return entitiesWithRelations;
};

View File

@ -28,39 +28,44 @@ export const useSpreadsheetCompanyImport = () => {
...options, ...options,
onSubmit: async (data) => { onSubmit: async (data) => {
// TODO: Add better type checking in spreadsheet import later // TODO: Add better type checking in spreadsheet import later
const createInputs = data.validData.map((company) => ({ const createInputs = data.validData.map(
name: company.name as string | undefined, (company) =>
domainName: company.domainName as string | undefined, ({
...(company.linkedinUrl name: company.name as string | undefined,
? { domainName: company.domainName as string | undefined,
linkedinLink: { ...(company.linkedinUrl
label: 'linkedinUrl', ? {
url: company.linkedinUrl as string | undefined, linkedinLink: {
}, label: 'linkedinUrl',
} url: company.linkedinUrl as string | undefined,
: {}), },
...(company.annualRecurringRevenue }
? { : {}),
annualRecurringRevenue: { ...(company.annualRecurringRevenue
amountMicros: Number(company.annualRecurringRevenue), ? {
currencyCode: 'USD', annualRecurringRevenue: {
}, amountMicros: Number(company.annualRecurringRevenue),
} currencyCode: 'USD',
: {}), },
idealCustomerProfile: }
company.idealCustomerProfile && : {}),
['true', true].includes(company.idealCustomerProfile), idealCustomerProfile:
...(company.xUrl company.idealCustomerProfile &&
? { ['true', true].includes(company.idealCustomerProfile),
xLink: { ...(company.xUrl
label: 'xUrl', ? {
url: company.xUrl as string | undefined, xLink: {
}, label: 'xUrl',
} url: company.xUrl as string | undefined,
: {}), },
address: company.address as string | undefined, }
employees: company.employees ? Number(company.employees) : undefined, : {}),
})); address: company.address as string | undefined,
employees: company.employees
? Number(company.employees)
: undefined,
}) as Company,
);
// TODO: abstract this part for any object // TODO: abstract this part for any object
try { try {
await createManyCompanies(createInputs); await createManyCompanies(createInputs);

View File

@ -0,0 +1,22 @@
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsByNameSingularMapSelector } from '@/object-metadata/states/objectMetadataItemsByNameSingularMapSelector';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isDefined } from '~/utils/isDefined';
export const useFilterOutUnexistingObjectMetadataItems = () => {
const objectMetadataItemsByNameSingularMap = useRecoilValue(
objectMetadataItemsByNameSingularMapSelector,
);
const filterOutUnexistingObjectMetadataItems = (
objectMetadatItem: ObjectMetadataItem,
) =>
isDefined(
objectMetadataItemsByNameSingularMap.get(objectMetadatItem.nameSingular),
);
return {
filterOutUnexistingObjectMetadataItems,
};
};

View File

@ -1,16 +1,16 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
export const useMapToObjectRecordIdentifier = ({ export const useMapToObjectRecordIdentifier = ({
objectMetadataItem, objectMetadataItem,
}: { }: {
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
}) => { }): ((record: ObjectRecord) => ObjectRecordIdentifier) => {
return (record: any): ObjectRecordIdentifier => { return (record: ObjectRecord) =>
return getObjectRecordIdentifier({ getObjectRecordIdentifier({
objectMetadataItem, objectMetadataItem,
record, record,
}); });
};
}; };

View File

@ -7,6 +7,7 @@ import { useGetObjectOrderByField } from '@/object-metadata/hooks/useGetObjectOr
import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGenerateCreateManyRecordMutation'; import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation'; import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
@ -121,7 +122,9 @@ export const useObjectMetadataItem = (
({ name }) => name === 'name', ({ name }) => name === 'name',
); );
const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`; const basePathToShowPage = getBasePathToShowPage({
objectMetadataItem,
});
return { return {
labelIdentifierFieldMetadata, labelIdentifierFieldMetadata,

View File

@ -0,0 +1,20 @@
import { selector } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const objectMetadataItemsByNamePluralMapSelector = selector<
Map<string, ObjectMetadataItem>
>({
key: 'objectMetadataItemsByNamePluralMapSelector',
get: ({ get }) => {
const objectMetadataItems = get(objectMetadataItemsState);
return new Map(
objectMetadataItems.map((objectMetadataItem) => [
objectMetadataItem.namePlural,
objectMetadataItem,
]),
);
},
});

View File

@ -0,0 +1,20 @@
import { selector } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const objectMetadataItemsByNameSingularMapSelector = selector<
Map<string, ObjectMetadataItem>
>({
key: 'objectMetadataItemsByNameSingularMapSelector',
get: ({ get }) => {
const objectMetadataItems = get(objectMetadataItemsState);
return new Map(
objectMetadataItems.map((objectMetadataItem) => [
objectMetadataItem.nameSingular,
objectMetadataItem,
]),
);
},
});

View File

@ -0,0 +1,11 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const getBasePathToShowPage = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`;
return basePathToShowPage;
};

View File

@ -0,0 +1,12 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const getLabelIdentifierFieldMetadataItem = (
objectMetadataItem: ObjectMetadataItem,
): FieldMetadataItem | undefined => {
return objectMetadataItem.fields.find(
(field) =>
field.id === objectMetadataItem.labelIdentifierFieldMetadataId ||
field.name === 'name',
);
};

View File

@ -5,7 +5,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getObjectOrderByField = ( export const getObjectOrderByField = (
objectMetadataItem: ObjectMetadataItem, objectMetadataItem: ObjectMetadataItem,
orderBy: OrderBy, orderBy?: OrderBy | null,
): OrderByField => { ): OrderByField => {
const labelIdentifierFieldMetadata = objectMetadataItem.fields.find( const labelIdentifierFieldMetadata = objectMetadataItem.fields.find(
(field) => (field) =>
@ -18,18 +18,18 @@ export const getObjectOrderByField = (
case FieldMetadataType.FullName: case FieldMetadataType.FullName:
return { return {
[labelIdentifierFieldMetadata.name]: { [labelIdentifierFieldMetadata.name]: {
firstName: orderBy, firstName: orderBy ?? 'AscNullsLast',
lastName: orderBy, lastName: orderBy ?? 'AscNullsLast',
}, },
}; };
default: default:
return { return {
[labelIdentifierFieldMetadata.name]: orderBy, [labelIdentifierFieldMetadata.name]: orderBy ?? 'AscNullsLast',
}; };
} }
} else { } else {
return { return {
createdAt: orderBy, createdAt: orderBy ?? 'DescNullsLast',
}; };
} }
}; };

View File

@ -1,7 +1,10 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { getLogoUrlFromDomainName } from '~/utils'; import { getLogoUrlFromDomainName } from '~/utils';
export const getObjectRecordIdentifier = ({ export const getObjectRecordIdentifier = ({
@ -9,30 +12,24 @@ export const getObjectRecordIdentifier = ({
record, record,
}: { }: {
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
record: any; record: ObjectRecord;
}): ObjectRecordIdentifier => { }): ObjectRecordIdentifier => {
const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`; switch (objectMetadataItem.nameSingular) {
const linkToShowPage = `${basePathToShowPage}${record.id}`; case CoreObjectNameSingular.Opportunity:
return {
if (objectMetadataItem.nameSingular === CoreObjectNameSingular.Opportunity) { id: record.id,
return { name: record?.company?.name,
id: record.id, avatarUrl: record.avatarUrl,
name: record?.company?.name, avatarType: 'rounded',
avatarUrl: record.avatarUrl, };
avatarType: 'rounded',
linkToShowPage,
};
} }
const labelIdentifierFieldMetadata = objectMetadataItem.fields.find( const labelIdentifierFieldMetadataItem =
(field) => getLabelIdentifierFieldMetadataItem(objectMetadataItem);
field.id === objectMetadataItem.labelIdentifierFieldMetadataId ||
field.name === 'name',
);
let labelIdentifierFieldValue = ''; let labelIdentifierFieldValue = '';
switch (labelIdentifierFieldMetadata?.type) { switch (labelIdentifierFieldMetadataItem?.type) {
case FieldMetadataType.FullName: { case FieldMetadataType.FullName: {
labelIdentifierFieldValue = `${record.name?.firstName ?? ''} ${ labelIdentifierFieldValue = `${record.name?.firstName ?? ''} ${
record.name?.lastName ?? '' record.name?.lastName ?? ''
@ -40,8 +37,8 @@ export const getObjectRecordIdentifier = ({
break; break;
} }
default: default:
labelIdentifierFieldValue = labelIdentifierFieldMetadata labelIdentifierFieldValue = labelIdentifierFieldMetadataItem
? record[labelIdentifierFieldMetadata.name] ? record[labelIdentifierFieldMetadataItem.name]
: ''; : '';
} }
@ -63,11 +60,17 @@ export const getObjectRecordIdentifier = ({
? getLogoUrlFromDomainName(record['domainName'] ?? '') ? getLogoUrlFromDomainName(record['domainName'] ?? '')
: imageIdentifierFieldValue ?? null; : imageIdentifierFieldValue ?? null;
const basePathToShowPage = getBasePathToShowPage({
objectMetadataItem,
});
const linkToEntity = `${basePathToShowPage}${record.id}`;
return { return {
id: record.id, id: record.id,
name: labelIdentifierFieldValue, name: labelIdentifierFieldValue,
avatarUrl, avatarUrl,
avatarType, avatarType,
linkToShowPage, linkToEntity,
}; };
}; };

View File

@ -0,0 +1,28 @@
import * as React from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { EntityChip } from '@/ui/display/chip/components/EntityChip';
export type RecordChipProps = {
objectNameSingular: string;
record: ObjectRecord;
};
export const RecordChip = ({ objectNameSingular, record }: RecordChipProps) => {
const { mapToObjectRecordIdentifier } = useObjectMetadataItem({
objectNameSingular,
});
const objectRecordIdentifier = mapToObjectRecordIdentifier(record);
return (
<EntityChip
entityId={record.id}
name={objectRecordIdentifier.name}
avatarType={objectRecordIdentifier.avatarType}
avatarUrl={objectRecordIdentifier.avatarUrl ?? undefined}
linkToEntity={objectRecordIdentifier.linkToShowPage}
/>
);
};

View File

@ -77,13 +77,6 @@ export const RecordShowPage = () => {
objectNameSingular, objectNameSingular,
}); });
const objectMetadataType =
objectMetadataItem?.nameSingular === 'company'
? 'Company'
: objectMetadataItem?.nameSingular === 'person'
? 'Person'
: 'Custom';
const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => { const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => {
const updateEntity = ({ variables }: RecordUpdateHookParams) => { const updateEntity = ({ variables }: RecordUpdateHookParams) => {
updateOneRecord?.({ updateOneRecord?.({
@ -171,7 +164,7 @@ export const RecordShowPage = () => {
hasBackButton hasBackButton
Icon={IconBuildingSkyscraper} Icon={IconBuildingSkyscraper}
> >
{record && objectMetadataType !== 'Custom' && ( {record && (
<> <>
<PageFavoriteButton <PageFavoriteButton
isFavorite={isFavorite} isFavorite={isFavorite}
@ -181,7 +174,7 @@ export const RecordShowPage = () => {
key="add" key="add"
entity={{ entity={{
id: record.id, id: record.id,
type: objectMetadataType, targetObjectNameSingular: objectMetadataItem?.nameSingular,
}} }}
/> />
<ShowPageMoreButton <ShowPageMoreButton
@ -275,15 +268,9 @@ export const RecordShowPage = () => {
)} )}
</ShowPageLeftContainer> </ShowPageLeftContainer>
<ShowPageRightContainer <ShowPageRightContainer
entity={{ targetableObject={{
id: record?.id || '', id: record?.id ?? '',
// TODO: refacto targetObjectNameSingular: objectMetadataItem?.nameSingular,
type:
objectMetadataItem?.nameSingular === 'company'
? 'Company'
: objectMetadataItem?.nameSingular === 'person'
? 'Person'
: 'Custom',
}} }}
timeline timeline
tasks tasks

View File

@ -10,6 +10,7 @@ export const ChipFieldDisplay = () => {
basePathToShowPage, basePathToShowPage,
} = useChipField(); } = useChipField();
// TODO: remove this and use ObjectRecordChip instead
const identifiers = identifiersMapper?.(record, objectNameSingular ?? ''); const identifiers = identifiersMapper?.(record, objectNameSingular ?? '');
return ( return (

View File

@ -5,11 +5,10 @@ import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimis
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord'; import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
export const useCreateManyRecords = < export const useCreateManyRecords = <T extends ObjectRecord>({
T extends Record<string, unknown> & { id: string },
>({
objectNameSingular, objectNameSingular,
}: ObjectMetadataItemIdentifier) => { }: ObjectMetadataItemIdentifier) => {
const { triggerOptimisticEffects } = useOptimisticEffect({ const { triggerOptimisticEffects } = useOptimisticEffect({
@ -27,17 +26,16 @@ export const useCreateManyRecords = <
const apolloClient = useApolloClient(); const apolloClient = useApolloClient();
const createManyRecords = async (data: Record<string, any>[]) => { const createManyRecords = async (data: Partial<T>[]) => {
const withIds = data.map((record) => ({ const withIds = data.map((record) => ({
...record, ...record,
id: (record.id as string) ?? v4(), id: (record.id as string) ?? v4(),
})); }));
withIds.forEach((record) => { withIds.forEach((record) => {
const emptyRecord: Record<string, unknown> | undefined = const emptyRecord: T | undefined = generateEmptyRecord({
generateEmptyRecord({ id: record.id,
id: record.id, } as T);
});
if (emptyRecord) { if (emptyRecord) {
triggerOptimisticEffects({ triggerOptimisticEffects({

View File

@ -34,7 +34,7 @@ export const useCreateOneRecord = <T>({
const createOneRecord = async (input: Record<string, any>) => { const createOneRecord = async (input: Record<string, any>) => {
const recordId = v4(); const recordId = v4();
const generatedEmptyRecord = generateEmptyRecord<Record<string, unknown>>({ const generatedEmptyRecord = generateEmptyRecord({
id: recordId, id: recordId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
...input, ...input,

View File

@ -1,4 +1,5 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
export const useGenerateEmptyRecord = ({ export const useGenerateEmptyRecord = ({
@ -7,11 +8,11 @@ export const useGenerateEmptyRecord = ({
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
}) => { }) => {
// Todo fix typing once we generate the return base on Metadata // Todo fix typing once we generate the return base on Metadata
const generateEmptyRecord = <T>(input: Partial<T> & { id: string }) => { const generateEmptyRecord = <T extends ObjectRecord>(input: T) => {
// Todo replace this by runtime typing // Todo replace this by runtime typing
const validatedInput = input as { id: string } & { [key: string]: any }; const validatedInput = input as T;
const emptyRecord = {} as Record<string, any>; const emptyRecord = {} as any;
for (const fieldMetadataItem of objectMetadataItem.fields) { for (const fieldMetadataItem of objectMetadataItem.fields) {
emptyRecord[fieldMetadataItem.name] = emptyRecord[fieldMetadataItem.name] =
@ -19,7 +20,7 @@ export const useGenerateEmptyRecord = ({
generateEmptyFieldValue(fieldMetadataItem); generateEmptyFieldValue(fieldMetadataItem);
} }
return emptyRecord; return emptyRecord as T;
}; };
return { return {

View File

@ -0,0 +1,84 @@
import { gql } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateFindManyRecordsForMultipleMetadataItemsQuery = ({
objectMetadataItems,
depth,
}: {
objectMetadataItems: ObjectMetadataItem[];
depth?: number;
}) => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
const capitalizedObjectNameSingulars = objectMetadataItems.map(
({ nameSingular }) => capitalize(nameSingular),
);
const filterPerMetadataItemArray = capitalizedObjectNameSingulars
.map(
(capitalizedObjectNameSingular) =>
`$filter${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}FilterInput`,
)
.join(', ');
const orderByPerMetadataItemArray = capitalizedObjectNameSingulars
.map(
(capitalizedObjectNameSingular) =>
`$orderBy${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}OrderByInput`,
)
.join(', ');
const lastCursorPerMetadataItemArray = capitalizedObjectNameSingulars
.map(
(capitalizedObjectNameSingular) =>
`$lastCursor${capitalizedObjectNameSingular}: String`,
)
.join(', ');
const limitPerMetadataItemArray = capitalizedObjectNameSingulars
.map(
(capitalizedObjectNameSingular) =>
`$limit${capitalizedObjectNameSingular}: Float = 5`,
)
.join(', ');
return gql`
query FindManyRecordsMultipleMetadataItems(
${filterPerMetadataItemArray},
${orderByPerMetadataItemArray},
${lastCursorPerMetadataItemArray},
${limitPerMetadataItemArray}
) {
${objectMetadataItems
.map(
({ namePlural, nameSingular, fields }) =>
`${namePlural}(filter: $filter${capitalize(
nameSingular,
)}, orderBy: $orderBy${capitalize(
nameSingular,
)}, first: $limit${capitalize(
nameSingular,
)}, after: $lastCursor${capitalize(nameSingular)}){
edges {
node {
id
${fields
.map((field) => mapFieldMetadataToGraphQLQuery(field, depth))
.join('\n')}
}
cursor
}
pageInfo {
hasNextPage
startCursor
endCursor
}
}`,
)
.join('\n')}
}
`;
};

View File

@ -1,7 +1,6 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
@ -14,10 +13,6 @@ export const useGenerateFindManyRecordsQuery = ({
}) => { }) => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
if (!objectMetadataItem) {
return EMPTY_QUERY;
}
return gql` return gql`
query FindMany${capitalize( query FindMany${capitalize(
objectMetadataItem.namePlural, objectMetadataItem.namePlural,

View File

@ -67,13 +67,6 @@ export const useRecordTableContextMenuEntries = (
const { createFavorite, favorites, deleteFavorite } = useFavorites(); const { createFavorite, favorites, deleteFavorite } = useFavorites();
const objectMetadataType =
objectNameSingular === 'company'
? 'Company'
: objectNameSingular === 'person'
? 'Person'
: 'Custom';
const handleFavoriteButtonClick = useRecoilCallback(({ snapshot }) => () => { const handleFavoriteButtonClick = useRecoilCallback(({ snapshot }) => () => {
const selectedRowIds = injectSelectorSnapshotValueWithRecordTableScopeId( const selectedRowIds = injectSelectorSnapshotValueWithRecordTableScopeId(
snapshot, snapshot,
@ -212,14 +205,14 @@ export const useRecordTableContextMenuEntries = (
label: 'Task', label: 'Task',
Icon: IconCheckbox, Icon: IconCheckbox,
onClick: () => { onClick: () => {
openCreateActivityDrawer('Task', objectMetadataType); openCreateActivityDrawer('Task', objectNameSingular);
}, },
}, },
{ {
label: 'Note', label: 'Note',
Icon: IconNotes, Icon: IconNotes,
onClick: () => { onClick: () => {
openCreateActivityDrawer('Note', objectMetadataType); openCreateActivityDrawer('Note', objectNameSingular);
}, },
}, },
...(dataExecuteQuickActionOnmentEnabled ...(dataExecuteQuickActionOnmentEnabled

View File

@ -0,0 +1,212 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import debounce from 'lodash.debounce';
import { v4 } from 'uuid';
import {
ObjectRecordForSelect,
SelectedObjectRecordId,
useMultiObjectSearch,
} from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { Avatar } from '@/users/components/Avatar';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
export type EntitiesForMultipleObjectRecordSelect = {
filteredSelectedObjectRecords: ObjectRecordForSelect[];
objectRecordsToSelect: ObjectRecordForSelect[];
loading: boolean;
};
export const MultipleObjectRecordSelect = ({
onChange,
onSubmit,
selectedObjectRecordIds,
}: {
onChange?: (
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => void;
onCancel?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [searchFilter, setSearchFilter] = useState<string>('');
const {
filteredSelectedObjectRecords,
loading,
objectRecordsToSelect,
selectedObjectRecords,
} = useMultiObjectSearch({
searchFilterValue: searchFilter,
selectedObjectRecordIds,
excludedObjectRecordIds: [],
limit: 10,
});
const selectedObjectRecordsForSelect = useMemo(
() =>
selectedObjectRecords.filter((selectedObjectRecord) =>
selectedObjectRecordIds.some(
(selectedObjectRecordId) =>
selectedObjectRecordId.id ===
selectedObjectRecord.recordIdentifier.id,
),
),
[selectedObjectRecords, selectedObjectRecordIds],
);
const [internalSelectedRecords, setInternalSelectedRecords] = useState<
ObjectRecordForSelect[]
>([]);
useEffect(() => {
if (!loading) {
setInternalSelectedRecords(selectedObjectRecordsForSelect);
}
}, [selectedObjectRecordsForSelect, loading]);
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearchFilter(event.currentTarget.value);
};
const handleSelectChange = (
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => {
const newSelectedRecords = newSelectedValue
? [...internalSelectedRecords, changedRecordForSelect]
: internalSelectedRecords.filter(
(selectedRecord) =>
selectedRecord.record.id !== changedRecordForSelect.record.id,
);
setInternalSelectedRecords(newSelectedRecords);
onChange?.(changedRecordForSelect, newSelectedValue);
};
const entitiesInDropdown = useMemo(
() =>
[
...(filteredSelectedObjectRecords ?? []),
...(objectRecordsToSelect ?? []),
].filter((entity) => isNonEmptyString(entity.recordIdentifier.id)),
[filteredSelectedObjectRecords, objectRecordsToSelect],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
onSubmit?.(internalSelectedRecords);
},
});
const selectableItemIds = entitiesInDropdown.map(
(entity) => entity.record.id,
);
return (
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<MenuItem text="Loading..." />
) : (
<>
<SelectableList
selectableListId="multiple-entity-select-list"
selectableItemIdArray={selectableItemIds}
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
onEnter={(recordId) => {
const recordIsSelected = internalSelectedRecords?.some(
(selectedRecord) => selectedRecord.record.id === recordId,
);
const correspondingRecordForSelect = entitiesInDropdown?.find(
(entity) => entity.record.id === recordId,
);
if (correspondingRecordForSelect) {
handleSelectChange(
correspondingRecordForSelect,
!recordIsSelected,
);
}
}}
>
{entitiesInDropdown?.map((objectRecordForSelect) => (
<StyledSelectableItem
itemId={objectRecordForSelect.record.id}
key={objectRecordForSelect.record.id + v4()}
>
<MenuItemMultiSelectAvatar
selected={internalSelectedRecords?.some(
(selectedRecord) => {
return (
selectedRecord.record.id ===
objectRecordForSelect.record.id
);
},
)}
onSelectChange={(newCheckedValue) =>
handleSelectChange(objectRecordForSelect, newCheckedValue)
}
avatar={
<Avatar
avatarUrl={
objectRecordForSelect.recordIdentifier.avatarUrl
}
colorId={objectRecordForSelect.record.id}
placeholder={
objectRecordForSelect.recordIdentifier.name
}
size="md"
type={
objectRecordForSelect.recordIdentifier.avatarType ??
'rounded'
}
/>
}
text={objectRecordForSelect.recordIdentifier.name}
/>
</StyledSelectableItem>
))}
</SelectableList>
{entitiesInDropdown?.length === 0 && <MenuItem text="No result" />}
</>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
);
};

View File

@ -0,0 +1,24 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/search/hooks/useFilteredSearchEntityQuery';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useLimitPerMetadataItem = ({
objectMetadataItems,
limit = DEFAULT_SEARCH_REQUEST_LIMIT,
}: {
objectMetadataItems: ObjectMetadataItem[];
limit?: number;
}) => {
const limitPerMetadataItem = Object.fromEntries(
objectMetadataItems
.map(({ nameSingular }) => {
return [`limit${capitalize(nameSingular)}`, limit];
})
.filter(isDefined),
);
return {
limitPerMetadataItem,
};
};

View File

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/states/objectMetadataItemsByNamePluralMapSelector';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { isDefined } from '~/utils/isDefined';
export type MultiObjectRecordQueryResult = {
[namePlural: string]: ObjectRecordConnection;
};
export const useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray =
({
multiObjectRecordsQueryResult,
}: {
multiObjectRecordsQueryResult:
| MultiObjectRecordQueryResult
| null
| undefined;
}) => {
const objectMetadataItemsByNamePluralMap = useRecoilValue(
objectMetadataItemsByNamePluralMapSelector,
);
const objectRecordForSelectArray = useMemo(() => {
return Object.entries(multiObjectRecordsQueryResult ?? {}).flatMap(
([namePlural, objectRecordConnection]) => {
const objectMetadataItem =
objectMetadataItemsByNamePluralMap.get(namePlural);
if (!isDefined(objectMetadataItem)) return [];
return objectRecordConnection.edges.map(({ node }) => ({
objectMetadataItem,
record: node,
recordIdentifier: getObjectRecordIdentifier({
objectMetadataItem,
record: node,
}),
})) as ObjectRecordForSelect[];
},
);
}, [multiObjectRecordsQueryResult, objectMetadataItemsByNamePluralMap]);
return {
objectRecordForSelectArray,
};
};

View File

@ -0,0 +1,72 @@
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';
import { useMultiObjectSearchSelectedItemsQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
export const DEFAULT_SEARCH_REQUEST_LIMIT = 5;
export type ObjectRecordForSelect = {
objectMetadataItem: ObjectMetadataItem;
record: ObjectRecord;
recordIdentifier: ObjectRecordIdentifier;
};
export type SelectedObjectRecordId = {
objectNameSingular: string;
id: string;
};
export type MultiObjectSearch = {
selectedObjectRecords: ObjectRecordForSelect[];
filteredSelectedObjectRecords: ObjectRecordForSelect[];
objectRecordsToSelect: ObjectRecordForSelect[];
loading: boolean;
};
export const useMultiObjectSearch = ({
searchFilterValue,
selectedObjectRecordIds,
limit,
excludedObjectRecordIds = [],
}: {
searchFilterValue: string;
selectedObjectRecordIds: SelectedObjectRecordId[];
limit?: number;
excludedObjectRecordIds?: SelectedObjectRecordId[];
}): MultiObjectSearch => {
const { selectedObjectRecords, selectedObjectRecordsLoading } =
useMultiObjectSearchSelectedItemsQuery({
selectedObjectRecordIds,
});
const {
selectedAndMatchesSearchFilterObjectRecords,
selectedAndMatchesSearchFilterObjectRecordsLoading,
} = useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery({
searchFilterValue,
selectedObjectRecordIds,
limit,
});
const {
toSelectAndMatchesSearchFilterObjectRecords,
toSelectAndMatchesSearchFilterObjectRecordsLoading,
} = useMultiObjectSearchMatchesSearchFilterAndToSelectQuery({
excludedObjectRecordIds,
searchFilterValue,
selectedObjectRecordIds,
limit,
});
return {
selectedObjectRecords,
filteredSelectedObjectRecords: selectedAndMatchesSearchFilterObjectRecords,
objectRecordsToSelect: toSelectAndMatchesSearchFilterObjectRecords,
loading:
selectedAndMatchesSearchFilterObjectRecordsLoading ||
toSelectAndMatchesSearchFilterObjectRecordsLoading ||
selectedObjectRecordsLoading,
};
};

View File

@ -0,0 +1,113 @@
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray,
} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem';
import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({
selectedObjectRecordIds,
searchFilterValue,
limit,
}: {
selectedObjectRecordIds: SelectedObjectRecordId[];
searchFilterValue: string;
limit?: number;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { searchFilterPerMetadataItemNameSingular } =
useSearchFilterPerMetadataItem({
objectMetadataItems,
searchFilterValue,
});
const objectMetadataItemsUsedInSelectedIdsQuery = objectMetadataItems.filter(
({ nameSingular }) => {
return selectedObjectRecordIds.some(({ objectNameSingular }) => {
return objectNameSingular === nameSingular;
});
},
);
const selectedAndMatchesSearchFilterTextFilterPerMetadataItem =
Object.fromEntries(
objectMetadataItems
.map(({ nameSingular }) => {
const selectedIds = selectedObjectRecordIds
.filter(
({ objectNameSingular }) => objectNameSingular === nameSingular,
)
.map(({ id }) => id);
if (!isNonEmptyArray(selectedIds)) return null;
const searchFilter =
searchFilterPerMetadataItemNameSingular[nameSingular] ?? {};
return [
`filter${capitalize(nameSingular)}`,
{
and: [
{
...searchFilter,
},
{
id: {
in: selectedIds,
},
},
],
},
];
})
.filter(isDefined),
);
const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
limit,
});
const multiSelectQueryForSelectedIds =
useGenerateFindManyRecordsForMultipleMetadataItemsQuery({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const {
loading: selectedAndMatchesSearchFilterObjectRecordsLoading,
data: selectedAndMatchesSearchFilterObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(multiSelectQueryForSelectedIds, {
variables: {
...selectedAndMatchesSearchFilterTextFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem,
},
});
const {
objectRecordForSelectArray: selectedAndMatchesSearchFilterObjectRecords,
} = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult:
selectedAndMatchesSearchFilterObjectRecordsQueryResult,
});
return {
selectedAndMatchesSearchFilterObjectRecordsLoading,
selectedAndMatchesSearchFilterObjectRecords,
};
};

View File

@ -0,0 +1,130 @@
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray,
} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem';
import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
selectedObjectRecordIds,
excludedObjectRecordIds,
searchFilterValue,
limit,
}: {
selectedObjectRecordIds: SelectedObjectRecordId[];
excludedObjectRecordIds: SelectedObjectRecordId[];
searchFilterValue: string;
limit?: number;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const nonSystemObjectMetadataItems = objectMetadataItems.filter(
({ nameSingular, isSystem }) =>
!isSystem && nameSingular !== CoreObjectNameSingular.Opportunity,
);
const { searchFilterPerMetadataItemNameSingular } =
useSearchFilterPerMetadataItem({
objectMetadataItems: nonSystemObjectMetadataItems,
searchFilterValue,
});
const objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem =
Object.fromEntries(
nonSystemObjectMetadataItems
.map(({ nameSingular }) => {
const selectedIds = selectedObjectRecordIds
.filter(
({ objectNameSingular }) => objectNameSingular === nameSingular,
)
.map(({ id }) => id);
const excludedIds = excludedObjectRecordIds
.filter(
({ objectNameSingular }) => objectNameSingular === nameSingular,
)
.map(({ id }) => id);
const searchFilter =
searchFilterPerMetadataItemNameSingular[nameSingular] ?? {};
const excludedIdsUnion = [...selectedIds, ...excludedIds];
const noFilter =
!isNonEmptyArray(excludedIdsUnion) &&
isDeeplyEqual(searchFilter, {});
return [
`filter${capitalize(nameSingular)}`,
!noFilter
? {
and: [
{
...searchFilter,
},
isNonEmptyArray(excludedIdsUnion)
? {
not: {
id: {
in: [...selectedIds, ...excludedIds],
},
},
}
: {},
],
}
: {},
];
})
.filter(isDefined),
);
const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({
objectMetadataItems: nonSystemObjectMetadataItems,
});
const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems: nonSystemObjectMetadataItems,
limit,
});
const multiSelectQuery =
useGenerateFindManyRecordsForMultipleMetadataItemsQuery({
objectMetadataItems: nonSystemObjectMetadataItems,
});
const {
loading: toSelectAndMatchesSearchFilterObjectRecordsLoading,
data: toSelectAndMatchesSearchFilterObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(multiSelectQuery, {
variables: {
...objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem,
},
});
const {
objectRecordForSelectArray: toSelectAndMatchesSearchFilterObjectRecords,
} = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult:
toSelectAndMatchesSearchFilterObjectRecordsQueryResult,
});
return {
toSelectAndMatchesSearchFilterObjectRecordsLoading,
toSelectAndMatchesSearchFilterObjectRecords,
};
};

View File

@ -0,0 +1,88 @@
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray,
} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useMultiObjectSearchSelectedItemsQuery = ({
selectedObjectRecordIds,
}: {
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const objectMetadataItemsUsedInSelectedIdsQuery = objectMetadataItems.filter(
({ nameSingular }) => {
return selectedObjectRecordIds.some(({ objectNameSingular }) => {
return objectNameSingular === nameSingular;
});
},
);
const selectedIdFilterPerMetadataItem = Object.fromEntries(
objectMetadataItemsUsedInSelectedIdsQuery
.map(({ nameSingular }) => {
const selectedIds = selectedObjectRecordIds
.filter(
({ objectNameSingular }) => objectNameSingular === nameSingular,
)
.map(({ id }) => id);
if (!isNonEmptyArray(selectedIds)) return null;
return [
`filter${capitalize(nameSingular)}`,
{
id: {
in: selectedIds,
},
},
];
})
.filter(isDefined),
);
const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const multiSelectQueryForSelectedIds =
useGenerateFindManyRecordsForMultipleMetadataItemsQuery({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const {
loading: selectedObjectRecordsLoading,
data: selectedObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(multiSelectQueryForSelectedIds, {
variables: {
...selectedIdFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem,
},
});
const { objectRecordForSelectArray: selectedObjectRecords } =
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult: selectedObjectRecordsQueryResult,
});
return {
selectedObjectRecordsLoading,
selectedObjectRecords,
};
};

View File

@ -0,0 +1,29 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectOrderByField } from '@/object-metadata/utils/getObjectOrderByField';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useOrderByFieldPerMetadataItem = ({
objectMetadataItems,
}: {
objectMetadataItems: ObjectMetadataItem[];
}) => {
const orderByFieldPerMetadataItem = Object.fromEntries(
objectMetadataItems
.map((objectMetadataItem) => {
const orderByField = getObjectOrderByField(objectMetadataItem);
return [
`orderBy${capitalize(objectMetadataItem.nameSingular)}`,
{
...orderByField,
},
];
})
.filter(isDefined),
);
return {
orderByFieldPerMetadataItem,
};
};

View File

@ -0,0 +1,67 @@
import { isNonEmptyString } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
export const useSearchFilterPerMetadataItem = ({
objectMetadataItems,
searchFilterValue,
}: {
objectMetadataItems: ObjectMetadataItem[];
searchFilterValue: string;
}) => {
const searchFilterPerMetadataItemNameSingular =
Object.fromEntries<ObjectRecordQueryFilter>(
objectMetadataItems
.map((objectMetadataItem) => {
if (!isNonEmptyString(searchFilterValue)) return null;
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
let searchFilter: ObjectRecordQueryFilter = {};
if (labelIdentifierFieldMetadataItem) {
switch (labelIdentifierFieldMetadataItem.type) {
case FieldMetadataType.FullName: {
searchFilter = {
or: [
{
[labelIdentifierFieldMetadataItem.name]: {
firstName: {
ilike: `%${searchFilterValue}%`,
},
},
},
{
[labelIdentifierFieldMetadataItem.name]: {
lastName: {
ilike: `%${searchFilterValue}%`,
},
},
},
],
};
break;
}
default:
searchFilter = {
[labelIdentifierFieldMetadataItem.name]: {
ilike: `%${searchFilterValue}%`,
},
};
}
}
return [objectMetadataItem.nameSingular, searchFilter] as const;
})
.filter(isDefined),
);
return {
searchFilterPerMetadataItemNameSingular,
};
};

View File

@ -0,0 +1 @@
export type ObjectRecord = Record<string, any> & { id: string };

View File

@ -0,0 +1,11 @@
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export type ObjectRecordConnection = {
edges: ObjectRecordEdge[];
pageInfo: {
hasNextPage?: boolean;
hasPreviousPage?: boolean;
startCursor?: string;
endCursor?: string;
};
};

View File

@ -0,0 +1,6 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type ObjectRecordEdge = {
node: ObjectRecord;
cursor: string;
};

View File

@ -5,5 +5,6 @@ export type ObjectRecordIdentifier = {
name: string; name: string;
avatarUrl?: string | null; avatarUrl?: string | null;
avatarType?: AvatarType | null; avatarType?: AvatarType | null;
linkToEntity?: string;
linkToShowPage?: string; linkToShowPage?: string;
}; };

View File

@ -29,33 +29,37 @@ export const useSpreadsheetPersonImport = () => {
...options, ...options,
onSubmit: async (data) => { onSubmit: async (data) => {
// TODO: Add better type checking in spreadsheet import later // TODO: Add better type checking in spreadsheet import later
const createInputs = data.validData.map((person) => ({ const createInputs = data.validData.map(
id: v4(), (person) =>
name: { ({
firstName: person.firstName as string | undefined, id: v4(),
lastName: person.lastName as string | undefined, name: {
}, firstName: person.firstName as string | undefined,
email: person.email as string | undefined, lastName: person.lastName as string | undefined,
...(person.linkedinUrl },
? { email: person.email as string | undefined,
linkedinLink: { ...(person.linkedinUrl
label: 'linkedinUrl', ? {
url: person.linkedinUrl as string | undefined, linkedinLink: {
}, label: 'linkedinUrl',
} url: person.linkedinUrl as string | undefined,
: {}), },
...(person.xUrl }
? { : {}),
xLink: { ...(person.xUrl
label: 'xUrl', ? {
url: person.xUrl as string | undefined, xLink: {
}, label: 'xUrl',
} url: person.xUrl as string | undefined,
: {}), },
jobTitle: person.jobTitle as string | undefined, }
phone: person.phone as string | undefined, : {}),
city: person.city as string | undefined, jobTitle: person.jobTitle as string | undefined,
})); phone: person.phone as string | undefined,
city: person.city as string | undefined,
}) as Person,
);
// TODO: abstract this part for any object // TODO: abstract this part for any object
try { try {
await createManyPeople(createInputs); await createManyPeople(createInputs);

View File

@ -5,6 +5,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { IconComponent } from '@/ui/display/icon/types/IconComponent'; import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { Avatar, AvatarType } from '@/users/components/Avatar'; import { Avatar, AvatarType } from '@/users/components/Avatar';
import { Nullable } from '~/types/Nullable';
import { Chip, ChipVariant } from './Chip'; import { Chip, ChipVariant } from './Chip';
@ -13,7 +14,7 @@ export type EntityChipProps = {
entityId: string; entityId: string;
name: string; name: string;
avatarUrl?: string; avatarUrl?: string;
avatarType?: AvatarType; avatarType?: Nullable<AvatarType>;
variant?: EntityChipVariant; variant?: EntityChipVariant;
LeftIcon?: IconComponent; LeftIcon?: IconComponent;
className?: string; className?: string;

View File

@ -124,13 +124,13 @@ export const Checkbox = ({
React.useState<boolean>(false); React.useState<boolean>(false);
React.useEffect(() => { React.useEffect(() => {
setIsInternalChecked(checked); setIsInternalChecked(checked ?? false);
}, [checked]); }, [checked]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(event); onChange?.(event);
onCheckedChange?.(event.target.checked); onCheckedChange?.(event.target.checked);
setIsInternalChecked(event.target.checked); setIsInternalChecked(event.target.checked ?? false);
}; };
const checkboxId = 'checkbox' + v4(); const checkboxId = 'checkbox' + v4();

View File

@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityType } from '@/activities/types/Activity'; import { ActivityType } from '@/activities/types/Activity';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { IconCheckbox, IconNotes, IconPlus } from '@/ui/display/icon/index'; import { IconCheckbox, IconNotes, IconPlus } from '@/ui/display/icon/index';
import { IconButton } from '@/ui/input/button/components/IconButton'; import { IconButton } from '@/ui/input/button/components/IconButton';
@ -21,13 +21,13 @@ const StyledContainer = styled.div`
export const ShowPageAddButton = ({ export const ShowPageAddButton = ({
entity, entity,
}: { }: {
entity: ActivityTargetableEntity; entity: ActivityTargetableObject;
}) => { }) => {
const { closeDropdown, toggleDropdown } = useDropdown('add-show-page'); const { closeDropdown, toggleDropdown } = useDropdown('add-show-page');
const openCreateActivity = useOpenCreateActivityDrawer(); const openCreateActivity = useOpenCreateActivityDrawer();
const handleSelect = (type: ActivityType) => { const handleSelect = (type: ActivityType) => {
openCreateActivity({ type, targetableEntities: [entity] }); openCreateActivity({ type, targetableObjects: [entity] });
closeDropdown(); closeDropdown();
}; };

View File

@ -3,9 +3,10 @@ import styled from '@emotion/styled';
import { Threads } from '@/activities/emails/components/Threads'; import { Threads } from '@/activities/emails/components/Threads';
import { Attachments } from '@/activities/files/components/Attachments'; import { Attachments } from '@/activities/files/components/Attachments';
import { Notes } from '@/activities/notes/components/Notes'; import { Notes } from '@/activities/notes/components/Notes';
import { EntityTasks } from '@/activities/tasks/components/EntityTasks'; import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks';
import { Timeline } from '@/activities/timeline/components/Timeline'; import { Timeline } from '@/activities/timeline/components/Timeline';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { isStandardObject } from '@/object-metadata/utils/isStandardObject';
import { import {
IconCheckbox, IconCheckbox,
IconMail, IconMail,
@ -40,7 +41,7 @@ const StyledTabListContainer = styled.div`
`; `;
type ShowPageRightContainerProps = { type ShowPageRightContainerProps = {
entity?: ActivityTargetableEntity; targetableObject?: ActivityTargetableObject;
timeline?: boolean; timeline?: boolean;
tasks?: boolean; tasks?: boolean;
notes?: boolean; notes?: boolean;
@ -48,7 +49,7 @@ type ShowPageRightContainerProps = {
}; };
export const ShowPageRightContainer = ({ export const ShowPageRightContainer = ({
entity, targetableObject,
timeline, timeline,
tasks, tasks,
notes, notes,
@ -60,42 +61,44 @@ export const ShowPageRightContainer = ({
ShowPageRecoilScopeContext, ShowPageRecoilScopeContext,
); );
if (!entity) return <></>; if (!targetableObject) return <></>;
const targetableObjectIsStandardObject = isStandardObject(
targetableObject.targetObjectNameSingular,
);
const TASK_TABS = [ const TASK_TABS = [
{ {
id: 'timeline', id: 'timeline',
title: 'Timeline', title: 'Timeline',
Icon: IconTimelineEvent, Icon: IconTimelineEvent,
hide: !timeline, hide: !timeline,
disabled: entity.type === 'Custom',
}, },
{ {
id: 'tasks', id: 'tasks',
title: 'Tasks', title: 'Tasks',
Icon: IconCheckbox, Icon: IconCheckbox,
hide: !tasks, hide: !tasks,
disabled: entity.type === 'Custom',
}, },
{ {
id: 'notes', id: 'notes',
title: 'Notes', title: 'Notes',
Icon: IconNotes, Icon: IconNotes,
hide: !notes, hide: !notes,
disabled: entity.type === 'Custom',
}, },
{ {
id: 'files', id: 'files',
title: 'Files', title: 'Files',
Icon: IconPaperclip, Icon: IconPaperclip,
hide: !notes, hide: !notes,
disabled: entity.type === 'Custom', disabled: !targetableObjectIsStandardObject,
}, },
{ {
id: 'emails', id: 'emails',
title: 'Emails', title: 'Emails',
Icon: IconMail, Icon: IconMail,
hide: !emails, hide: !emails,
disabled: !isMessagingEnabled || entity.type === 'Custom', disabled: !isMessagingEnabled || !targetableObjectIsStandardObject,
}, },
]; ];
@ -104,11 +107,17 @@ export const ShowPageRightContainer = ({
<StyledTabListContainer> <StyledTabListContainer>
<TabList context={ShowPageRecoilScopeContext} tabs={TASK_TABS} /> <TabList context={ShowPageRecoilScopeContext} tabs={TASK_TABS} />
</StyledTabListContainer> </StyledTabListContainer>
{activeTabId === 'timeline' && <Timeline entity={entity} />} {activeTabId === 'timeline' && (
{activeTabId === 'tasks' && <EntityTasks entity={entity} />} <Timeline targetableObject={targetableObject} />
{activeTabId === 'notes' && <Notes entity={entity} />} )}
{activeTabId === 'files' && <Attachments targetableEntity={entity} />} {activeTabId === 'tasks' && (
{activeTabId === 'emails' && <Threads entity={entity} />} <ObjectTasks targetableObject={targetableObject} />
)}
{activeTabId === 'notes' && <Notes targetableObject={targetableObject} />}
{activeTabId === 'files' && (
<Attachments targetableObject={targetableObject} />
)}
{activeTabId === 'emails' && <Threads entity={targetableObject} />}
</StyledShowPageRightContainer> </StyledShowPageRightContainer>
); );
}; };

View File

@ -1,6 +1,7 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip';
import { Checkbox } from '@/ui/input/components/Checkbox'; import { Checkbox } from '@/ui/input/components/Checkbox';
import { import {
@ -14,6 +15,7 @@ const StyledLeftContentWithCheckboxContainer = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`; `;
type MenuItemMultiSelectAvatarProps = { type MenuItemMultiSelectAvatarProps = {
@ -48,7 +50,7 @@ export const MenuItemMultiSelectAvatar = ({
<StyledMenuItemLeftContent> <StyledMenuItemLeftContent>
{avatar} {avatar}
<StyledMenuItemLabel hasLeftIcon={!!avatar}> <StyledMenuItemLabel hasLeftIcon={!!avatar}>
{text} <OverflowingTextWithTooltip text={text} />
</StyledMenuItemLabel> </StyledMenuItemLabel>
</StyledMenuItemLeftContent> </StyledMenuItemLeftContent>
</StyledLeftContentWithCheckboxContainer> </StyledLeftContentWithCheckboxContainer>

View File

@ -71,10 +71,11 @@ export const StyledMenuItemBase = styled.li<MenuItemBaseProps>`
export const StyledMenuItemLabel = styled.div<{ hasLeftIcon: boolean }>` export const StyledMenuItemLabel = styled.div<{ hasLeftIcon: boolean }>`
font-size: ${({ theme }) => theme.font.size.sm}; font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular}; font-weight: ${({ theme }) => theme.font.weight.regular};
overflow: hidden; overflow: hidden;
padding-left: ${({ theme, hasLeftIcon }) => padding-left: ${({ theme, hasLeftIcon }) =>
hasLeftIcon ? '' : theme.spacing(1)}; hasLeftIcon ? '' : theme.spacing(1)};
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `;

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { Nullable } from '~/types/Nullable';
import { stringToHslColor } from '~/utils/string-to-hsl'; import { stringToHslColor } from '~/utils/string-to-hsl';
import { getImageAbsoluteURIOrBase64 } from '../utils/getProfilePictureAbsoluteURI'; import { getImageAbsoluteURIOrBase64 } from '../utils/getProfilePictureAbsoluteURI';
@ -16,7 +17,7 @@ export type AvatarProps = {
size?: AvatarSize; size?: AvatarSize;
placeholder: string | undefined; placeholder: string | undefined;
colorId?: string; colorId?: string;
type?: AvatarType; type?: Nullable<AvatarType>;
onClick?: () => void; onClick?: () => void;
}; };

View File

@ -34,6 +34,7 @@ type MockedActivity = Pick<
| 'activityId' | 'activityId'
| 'personId' | 'personId'
| 'companyId' | 'companyId'
| 'targetObjectNameSingular'
> & { > & {
activity: Pick<Activity, 'id' | 'createdAt' | 'updatedAt'>; activity: Pick<Activity, 'id' | 'createdAt' | 'updatedAt'>;
person?: Pick<Person, 'id' | 'name' | 'avatarUrl'> | null; person?: Pick<Person, 'id' | 'name' | 'avatarUrl'> | null;
@ -98,6 +99,7 @@ export const mockedActivities: Array<MockedActivity> = [
id: '89bb825c-171e-4bcc-9cf7-43448d6fb300', id: '89bb825c-171e-4bcc-9cf7-43448d6fb300',
createdAt: '2023-04-26T10:12:42.33625+00:00', createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00', updatedAt: '2023-04-26T10:23:42.33625+00:00',
targetObjectNameSingular: 'company',
personId: null, personId: null,
companyId: '89bb825c-171e-4bcc-9cf7-43448d6fb280', companyId: '89bb825c-171e-4bcc-9cf7-43448d6fb280',
company: { company: {
@ -118,6 +120,7 @@ export const mockedActivities: Array<MockedActivity> = [
id: '89bb825c-171e-4bcc-9cf7-43448d6fb301', id: '89bb825c-171e-4bcc-9cf7-43448d6fb301',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
targetObjectNameSingular: 'company',
personId: null, personId: null,
companyId: 'b396e6b9-dc5c-4643-bcff-61b6cf7523ae', companyId: 'b396e6b9-dc5c-4643-bcff-61b6cf7523ae',
company: { company: {
@ -160,6 +163,7 @@ export const mockedActivities: Array<MockedActivity> = [
id: '89bb825c-171e-4bcc-9cf7-43448d6fb278t', id: '89bb825c-171e-4bcc-9cf7-43448d6fb278t',
createdAt: '2023-04-26T10:12:42.33625+00:00', createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00', updatedAt: '2023-04-26T10:23:42.33625+00:00',
targetObjectNameSingular: 'person',
personId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', // Alexandre personId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', // Alexandre
person: { person: {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
@ -185,6 +189,7 @@ export const mockedActivities: Array<MockedActivity> = [
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
personId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d', // Jean d'Eau personId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d', // Jean d'Eau
companyId: null, companyId: null,
targetObjectNameSingular: 'person',
company: null, company: null,
person: { person: {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d',

View File

@ -111,8 +111,9 @@ export class RelationFieldAliasFactory {
} }
`; `;
} }
let relationAlias = fieldMetadata.isCustom let relationAlias = fieldMetadata.isCustom
? `${fieldKey}: ${fieldMetadata.targetColumnMap.value}` ? `${fieldKey}: ${referencedObjectMetadata.targetTableName}`
: fieldKey; : fieldKey;
// For one to one relations, pg_graphql use the targetTableName on the side that is not storing the foreign key // For one to one relations, pg_graphql use the targetTableName on the side that is not storing the foreign key

View File

@ -11,7 +11,6 @@ export default async () => {
await prisma.$transaction( await prisma.$transaction(
entities.map((entity) => { entities.map((entity) => {
console.log('entity: ', entity);
return prisma[entity].deleteMany(); return prisma[entity].deleteMany();
}), }),
); );