feat: soft delete (#6576)

Implement soft delete on standards and custom objects.
This is a temporary solution, when we drop `pg_graphql` we should rely
on the `softDelete` functions of TypeORM.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Jérémy M
2024-08-16 21:20:02 +02:00
committed by GitHub
parent 20d84755bb
commit db54469c8a
118 changed files with 1675 additions and 492 deletions

View File

@ -1,4 +1,4 @@
import { IconCirclePlus, IconEditCircle, useIcons } from 'twenty-ui';
import { IconCirclePlus, IconEditCircle, IconTrash, useIcons } from 'twenty-ui';
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@ -19,6 +19,9 @@ export const EventIconDynamicComponent = ({
if (eventAction === 'updated') {
return <IconEditCircle />;
}
if (eventAction === 'deleted') {
return <IconTrash />;
}
const IconComponent = getIcon(linkedObjectMetadataItem?.icon);

View File

@ -45,6 +45,17 @@ export const EventRowMainObject = ({
/>
);
}
case 'deleted': {
return (
<StyledMainContainer>
<StyledEventRowItemColumn>
{labelIdentifierValue}
</StyledEventRowItemColumn>
<StyledEventRowItemAction>was deleted by</StyledEventRowItemAction>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
</StyledMainContainer>
);
}
default:
return null;
}

View File

@ -1,6 +1,6 @@
import { Button } from '@/ui/input/button/components/Button';
import styled from '@emotion/styled';
import { Banner, IconComponent } from 'twenty-ui';
import { Banner, BannerVariant, IconComponent } from 'twenty-ui';
const StyledBanner = styled(Banner)`
position: absolute;
@ -14,26 +14,30 @@ const StyledText = styled.div`
export const InformationBanner = ({
message,
variant = 'default',
buttonTitle,
buttonIcon,
buttonOnClick,
}: {
message: string;
buttonTitle: string;
variant?: BannerVariant;
buttonTitle?: string;
buttonIcon?: IconComponent;
buttonOnClick: () => void;
buttonOnClick?: () => void;
}) => {
return (
<StyledBanner>
<StyledBanner variant={variant}>
<StyledText>{message}</StyledText>
<Button
variant="secondary"
title={buttonTitle}
Icon={buttonIcon}
size="small"
inverted
onClick={buttonOnClick}
/>
{buttonTitle && buttonOnClick && (
<Button
variant="secondary"
title={buttonTitle}
Icon={buttonIcon}
size="small"
inverted
onClick={buttonOnClick}
/>
)}
</StyledBanner>
);
};

View File

@ -0,0 +1,37 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import styled from '@emotion/styled';
import { IconRefresh } from 'twenty-ui';
const StyledInformationBannerDeletedRecord = styled.div`
height: 40px;
position: relative;
&:empty {
height: 0;
}
`;
export const InformationBannerDeletedRecord = ({
recordId,
objectNameSingular,
}: {
recordId: string;
objectNameSingular: string;
}) => {
const { restoreManyRecords } = useRestoreManyRecords({
objectNameSingular,
});
return (
<StyledInformationBannerDeletedRecord>
<InformationBanner
variant="danger"
message={`This record has been deleted`}
buttonTitle="Restore"
buttonIcon={IconRefresh}
buttonOnClick={() => restoreManyRecords([recordId])}
/>
</StyledInformationBannerDeletedRecord>
);
};

View File

@ -0,0 +1 @@
export const INFORMATION_BANNER_HEIGHT = '40px';

View File

@ -13,7 +13,7 @@ import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useDeleteOneRecordProps = {
type useDeleteManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
@ -25,7 +25,7 @@ type DeleteManyRecordsOptions = {
export const useDeleteManyRecords = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
}: useDeleteManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =

View File

@ -0,0 +1,41 @@
import gql from 'graphql-tag';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useDestroyManyRecordsMutation = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
if (isUndefinedOrNull(objectMetadataItem)) {
return { destroyManyRecordsMutation: EMPTY_MUTATION };
}
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const destroyManyRecordsMutation = gql`
mutation DestroyMany${capitalizedObjectName}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput!) {
${mutationResponseField}(filter: $filter) {
id
}
}
`;
return {
destroyManyRecordsMutation,
};
};

View File

@ -0,0 +1,115 @@
import { useApolloClient } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useDestroyManyRecordsMutation } from '@/object-record/hooks/useDestroyManyRecordMutation';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useDestroyManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
type DestroyManyRecordsOptions = {
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
export const useDestroyManyRecords = ({
objectNameSingular,
}: useDestroyManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular,
});
const { destroyManyRecordsMutation } = useDestroyManyRecordsMutation({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const destroyManyRecords = async (
idsToDestroy: string[],
options?: DestroyManyRecordsOptions,
) => {
const numberOfBatches = Math.ceil(idsToDestroy.length / mutationPageSize);
const destroyedRecords = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchIds = idsToDestroy.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
const destroyedRecordsResponse = await apolloClient.mutate({
mutation: destroyManyRecordsMutation,
variables: {
filter: { id: { in: batchIds } },
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: batchIds.map((idToDestroy) => ({
__typename: capitalize(objectNameSingular),
id: idToDestroy,
})),
},
update: options?.skipOptimisticEffect
? undefined
: (cache, { data }) => {
const records = data?.[mutationResponseField];
if (!records?.length) return;
const cachedRecords = records
.map((record) => getRecordFromCache(record.id, cache))
.filter(isDefined);
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToDelete: cachedRecords,
objectMetadataItems,
});
},
});
const destroyedRecordsForThisBatch =
destroyedRecordsResponse.data?.[mutationResponseField] ?? [];
destroyedRecords.push(...destroyedRecordsForThisBatch);
if (isDefined(options?.delayInMsBetweenRequests)) {
await sleep(options.delayInMsBetweenRequests);
}
}
return destroyedRecords;
};
return { destroyManyRecords };
};

View File

@ -17,11 +17,13 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
recordGqlFields,
onCompleted,
skip,
withSoftDeleted = false,
}: ObjectMetadataItemIdentifier & {
objectRecordId: string | undefined;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
onCompleted?: (data: T) => void;
skip?: boolean;
withSoftDeleted?: boolean;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -33,6 +35,7 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
const { findOneRecordQuery } = useFindOneRecordQuery({
objectNameSingular,
recordGqlFields: computedRecordGqlFields,
withSoftDeleted,
});
const { data, loading, error } = useQuery<{

View File

@ -10,9 +10,11 @@ import { capitalize } from '~/utils/string/capitalize';
export const useFindOneRecordQuery = ({
objectNameSingular,
recordGqlFields,
withSoftDeleted = false,
}: {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
withSoftDeleted?: boolean;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -25,6 +27,16 @@ export const useFindOneRecordQuery = ({
objectMetadataItem.nameSingular,
)}($objectRecordId: ID!) {
${objectMetadataItem.nameSingular}(filter: {
${
withSoftDeleted
? `
or: [
{ deletedAt: { is: NULL } },
{ deletedAt: { is: NOT_NULL } }
],
`
: ''
}
id: {
eq: $objectRecordId
}

View File

@ -0,0 +1,94 @@
import { useApolloClient } from '@apollo/client';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useRestoreManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
type RestoreManyRecordsOptions = {
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
export const useRestoreManyRecords = ({
objectNameSingular,
}: useRestoreManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { restoreManyRecordsMutation } = useRestoreManyRecordsMutation({
objectNameSingular,
});
const mutationResponseField = getRestoreManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const restoreManyRecords = async (
idsToRestore: string[],
options?: RestoreManyRecordsOptions,
) => {
const numberOfBatches = Math.ceil(idsToRestore.length / mutationPageSize);
const restoredRecords = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchIds = idsToRestore.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
// TODO: fix optimistic effect
const findOneQueryName = `FindOne${capitalize(objectNameSingular)}`;
const findManyQueryName = `FindMany${capitalize(objectMetadataItem.namePlural)}`;
const restoredRecordsResponse = await apolloClient.mutate({
mutation: restoreManyRecordsMutation,
refetchQueries: [findOneQueryName, findManyQueryName],
variables: {
filter: { id: { in: batchIds } },
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: batchIds.map((idToRestore) => ({
__typename: capitalize(objectNameSingular),
id: idToRestore,
deletedAt: null,
})),
},
});
const restoredRecordsForThisBatch =
restoredRecordsResponse.data?.[mutationResponseField] ?? [];
restoredRecords.push(...restoredRecordsForThisBatch);
if (isDefined(options?.delayInMsBetweenRequests)) {
await sleep(options.delayInMsBetweenRequests);
}
}
return restoredRecords;
};
return { restoreManyRecords };
};

View File

@ -0,0 +1,41 @@
import gql from 'graphql-tag';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useRestoreManyRecordsMutation = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
if (isUndefinedOrNull(objectMetadataItem)) {
return { restoreManyRecordsMutation: EMPTY_MUTATION };
}
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
const mutationResponseField = getRestoreManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const restoreManyRecordsMutation = gql`
mutation RestoreMany${capitalizedObjectName}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput!) {
${mutationResponseField}(filter: $filter) {
id
}
}
`;
return {
restoreManyRecordsMutation,
};
};

View File

@ -4,6 +4,7 @@ import { FilterDefinition } from './FilterDefinition';
export type Filter = {
id: string;
variant?: 'default' | 'danger';
fieldMetadataId: string;
value: string;
displayValue: string;

View File

@ -0,0 +1,67 @@
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { useCombinedViewFilters } from '@/views/hooks/useCombinedViewFilters';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { isDefined } from '~/utils/isDefined';
type UseHandleToggleTrashColumnFilterProps = {
objectNameSingular: string;
viewBarId: string;
};
export const useHandleToggleTrashColumnFilter = ({
viewBarId,
objectNameSingular,
}: UseHandleToggleTrashColumnFilterProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { columnDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
const { upsertCombinedViewFilter } = useCombinedViewFilters(viewBarId);
const handleToggleTrashColumnFilter = useCallback(() => {
const trashFieldMetadata = objectMetadataItem.fields.find(
(field) => field.name === 'deletedAt',
);
if (!isDefined(trashFieldMetadata)) return;
const correspondingColumnDefinition = columnDefinitions.find(
(columnDefinition) =>
columnDefinition.fieldMetadataId === trashFieldMetadata.id,
);
if (!isDefined(correspondingColumnDefinition)) return;
const filterType = getFilterTypeFromFieldType(
correspondingColumnDefinition?.type,
);
const newFilter: Filter = {
id: v4(),
variant: 'danger',
fieldMetadataId: trashFieldMetadata.id,
operand: ViewFilterOperand.IsNotEmpty,
displayValue: '',
definition: {
label: 'Trash',
iconName: 'IconTrash',
fieldMetadataId: trashFieldMetadata.id,
type: filterType,
},
value: '',
};
upsertCombinedViewFilter(newFilter);
}, [columnDefinitions, objectMetadataItem.fields, upsertCombinedViewFilter]);
return handleToggleTrashColumnFilter;
};

View File

@ -8,6 +8,7 @@ import {
IconFileImport,
IconSettings,
IconTag,
IconTrash,
} from 'twenty-ui';
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
@ -37,6 +38,7 @@ import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewType } from '@/views/types/ViewType';
import { useLocation } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
type RecordIndexOptionsMenu = 'fields' | 'hiddenFields';
@ -88,6 +90,11 @@ export const RecordIndexOptionsDropdownContent = ({
hiddenTableColumns,
} = useRecordIndexOptionsForTable(recordIndexId);
const handleToggleTrashColumnFilter = useHandleToggleTrashColumnFilter({
objectNameSingular,
viewBarId: recordIndexId,
});
const {
visibleBoardFields,
hiddenBoardFields,
@ -153,6 +160,14 @@ export const RecordIndexOptionsDropdownContent = ({
LeftIcon={IconFileExport}
text={displayedExportProgress(progress)}
/>
<MenuItem
onClick={() => {
handleToggleTrashColumnFilter();
closeDropdown();
}}
LeftIcon={IconTrash}
text="Trash"
/>
</DropdownMenuItemsContainer>
)}
{currentMenu === 'fields' && (

View File

@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { InformationBannerDeletedRecord } from '@/information-banner/components/deleted-record/InformationBannerDeletedRecord';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -126,7 +127,11 @@ export const RecordShowContainer = ({
);
const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy(
availableFieldMetadataItems,
availableFieldMetadataItems.filter(
(fieldMetadataItem) =>
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'deletedAt',
),
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation
? 'relationFieldMetadataItems'
@ -301,25 +306,33 @@ export const RecordShowContainer = ({
);
return (
<ShowPageContainer>
<ShowPageLeftContainer forceMobile={isMobile}>
{!isMobile && summaryCard}
{!isMobile && fieldsBox}
</ShowPageLeftContainer>
<ShowPageRightContainer
targetableObject={{
id: objectRecordId,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
timeline
tasks
notes
emails
isInRightDrawer={isInRightDrawer}
summaryCard={isMobile ? summaryCard : <></>}
fieldsBox={fieldsBox}
loading={isPrefetchLoading || loading || recordLoading}
/>
</ShowPageContainer>
<>
{recordFromStore && recordFromStore.deletedAt && (
<InformationBannerDeletedRecord
recordId={objectRecordId}
objectNameSingular={objectNameSingular}
/>
)}
<ShowPageContainer>
<ShowPageLeftContainer forceMobile={isMobile}>
{!isMobile && summaryCard}
{!isMobile && fieldsBox}
</ShowPageLeftContainer>
<ShowPageRightContainer
targetableObject={{
id: objectRecordId,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
timeline
tasks
notes
emails
isInRightDrawer={isInRightDrawer}
summaryCard={isMobile ? summaryCard : <></>}
fieldsBox={fieldsBox}
loading={isPrefetchLoading || loading || recordLoading}
/>
</ShowPageContainer>
</>
);
};

View File

@ -29,7 +29,7 @@ export const buildFindOneRecordForShowPageOperationSignature: RecordGqlOperation
},
}
: {}),
...(objectMetadataItem.nameSingular === 'Note'
...(objectMetadataItem.nameSingular === CoreObjectNameSingular.Note
? {
noteTargets: {
id: true,

View File

@ -50,6 +50,7 @@ export const useRecordShowPage = (
objectRecordId,
objectNameSingular,
recordGqlFields: FIND_ONE_RECORD_FOR_SHOW_PAGE_OPERATION_SIGNATURE.fields,
withSoftDeleted: true,
});
useEffect(() => {

View File

@ -0,0 +1,5 @@
import { capitalize } from '~/utils/string/capitalize';
export const getDestroyManyRecordsMutationResponseField = (
objectNamePlural: string,
) => `destroy${capitalize(objectNamePlural)}`;

View File

@ -0,0 +1,5 @@
import { capitalize } from '~/utils/string/capitalize';
export const getRestoreManyRecordsMutationResponseField = (
objectNamePlural: string,
) => `restore${capitalize(objectNamePlural)}`;

View File

@ -7,13 +7,13 @@ import {
IconColorSwatch,
IconCurrencyDollar,
IconDoorEnter,
IconFunction,
IconHierarchy2,
IconMail,
IconRocket,
IconSettings,
IconUserCircle,
IconUsers,
IconFunction,
} from 'twenty-ui';
import { useAuth } from '@/auth/hooks/useAuth';
@ -49,7 +49,6 @@ export const SettingsNavigationDrawerItems = () => {
path={SettingsPath.Appearance}
Icon={IconColorSwatch}
/>
<NavigationDrawerItemGroup>
<SettingsNavigationDrawerItem
label="Accounts"

View File

@ -1,9 +1,12 @@
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isDefined } from '~/utils/isDefined';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledSettingsPageContainer = styled.div<{ width?: number }>`
display: flex;
flex-direction: column;
@ -21,4 +24,17 @@ const StyledSettingsPageContainer = styled.div<{ width?: number }>`
}};
`;
export { StyledSettingsPageContainer as SettingsPageContainer };
const StyledScrollWrapper = styled(ScrollWrapper)`
background-color: ${({ theme }) => theme.background.secondary};
border-radius: ${({ theme }) => theme.border.radius.md};
`;
export const SettingsPageContainer = ({
children,
}: {
children: ReactNode;
}) => (
<StyledScrollWrapper>
<StyledSettingsPageContainer>{children}</StyledSettingsPageContainer>
</StyledScrollWrapper>
);

View File

@ -4,6 +4,7 @@ import { IconEye } from 'twenty-ui';
import { FloatingButton } from '@/ui/input/button/components/FloatingButton';
import { Card } from '@/ui/layout/card/components/Card';
import { SettingsPath } from '@/types/SettingsPath';
import DarkCoverImage from '../assets/cover-dark.png';
import LightCoverImage from '../assets/cover-light.png';
@ -34,7 +35,7 @@ export const SettingsObjectCoverImage = () => {
Icon={IconEye}
title="Visualize"
size="small"
to="/settings/objects/overview"
to={'/settings/' + SettingsPath.ObjectOverview}
/>
</StyledButtonContainer>
</StyledCoverImageContainer>

View File

@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Section } from '@react-email/components';
import { useNavigate } from 'react-router-dom';
import { H2Title } from 'twenty-ui';
import { useDeleteOneDatabaseConnection } from '@/databases/hooks/useDeleteOneDatabaseConnection';
@ -31,6 +31,7 @@ export const SettingsIntegrationDatabaseConnectionShowContainer = () => {
SettingsPath.Integrations,
);
// TODO: move breadcrumb to header?
return (
<>
<Breadcrumb

View File

@ -1,8 +1,8 @@
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { Section } from '@react-email/components';
import pick from 'lodash.pick';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { H2Title } from 'twenty-ui';
import { z } from 'zod';
@ -94,6 +94,7 @@ export const SettingsIntegrationEditDatabaseConnectionContent = ({
}
};
// TODO: move breadcrumb to header?
return (
<>
<FormProvider

View File

@ -15,7 +15,7 @@ export enum AppPath {
// Onboarded
Index = '/',
TasksPage = '/tasks',
TasksPage = '/objects/tasks',
OpportunitiesPage = '/objects/opportunities',
RecordIndexPage = '/objects/:objectNamePlural',

View File

@ -336,6 +336,7 @@ const StyledButton = styled('button', {
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: 500;
font-size: ${({ theme }) => theme.font.size.md};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
justify-content: ${({ justify }) => justify};

View File

@ -1,3 +1,4 @@
import isPropValid from '@emotion/is-prop-valid';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Link } from 'react-router-dom';
@ -19,12 +20,11 @@ export type FloatingButtonProps = {
to?: string;
};
const shouldForwardProp = (prop: string) =>
!['applyBlur', 'applyShadow', 'focus', 'position', 'size', 'to'].includes(
prop,
);
const StyledButton = styled('button', { shouldForwardProp })<
const StyledButton = styled('button', {
shouldForwardProp: (prop) =>
!['applyBlur', 'applyShadow', 'focus', 'position', 'size'].includes(prop) &&
isPropValid(prop),
})<
Pick<
FloatingButtonProps,
| 'size'

View File

@ -1,6 +1,6 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ComponentProps, ReactNode } from 'react';
import { ReactNode } from 'react';
import { useRecoilValue } from 'recoil';
import {
IconChevronDown,
@ -18,7 +18,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
export const PAGE_BAR_MIN_HEIGHT = 40;
const StyledTopBarContainer = styled.div`
const StyledTopBarContainer = styled.div<{ width?: number }>`
align-items: center;
background: ${({ theme }) => theme.background.noisy};
color: ${({ theme }) => theme.font.color.primary};
@ -31,6 +31,7 @@ const StyledTopBarContainer = styled.div`
padding-left: 0;
padding-right: ${({ theme }) => theme.spacing(3)};
z-index: 20;
width: ${({ width }) => width + 'px' || '100%'};
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${({ theme }) => theme.spacing(3)};
@ -76,8 +77,8 @@ const StyledTopBarButtonContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(1)};
`;
type PageHeaderProps = ComponentProps<'div'> & {
title: string;
type PageHeaderProps = {
title: ReactNode;
hasClosePageButton?: boolean;
onClosePage?: () => void;
hasPaginationButtons?: boolean;
@ -87,6 +88,7 @@ type PageHeaderProps = ComponentProps<'div'> & {
navigateToNextRecord?: () => void;
Icon: IconComponent;
children?: ReactNode;
width?: number;
};
export const PageHeader = ({
@ -100,13 +102,14 @@ export const PageHeader = ({
navigateToNextRecord,
Icon,
children,
width,
}: PageHeaderProps) => {
const isMobile = useIsMobile();
const theme = useTheme();
const isNavigationDrawerOpen = useRecoilValue(isNavigationDrawerOpenState);
return (
<StyledTopBarContainer>
<StyledTopBarContainer width={width}>
<StyledLeftContainer>
{!isMobile && !isNavigationDrawerOpen && (
<StyledTopBarButtonContainer>
@ -143,7 +146,11 @@ export const PageHeader = ({
)}
{Icon && <Icon size={theme.icon.size.md} />}
<StyledTitleContainer data-testid="top-bar-title">
<OverflowingTextWithTooltip text={title} />
{typeof title === 'string' ? (
<OverflowingTextWithTooltip text={title} />
) : (
title
)}
</StyledTitleContainer>
</StyledTopBarIconStyledTitleContainer>
</StyledLeftContainer>

View File

@ -1,15 +1,21 @@
import React from 'react';
import styled from '@emotion/styled';
import React from 'react';
const StyledPanel = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
height: 100%;
overflow: auto;
overflow-x: auto;
overflow-y: hidden;
width: 100%;
`;
export const PagePanel = ({ children }: { children: React.ReactNode }) => (
type PagePanelProps = {
children: React.ReactNode;
hasInformationBar?: boolean;
};
export const PagePanel = ({ children }: PagePanelProps) => (
<StyledPanel>{children}</StyledPanel>
);

View File

@ -1,16 +1,18 @@
import styled from '@emotion/styled';
import { JSX } from 'react';
import { JSX, ReactNode } from 'react';
import { IconComponent } from 'twenty-ui';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { PageBody } from './PageBody';
import { PageHeader } from './PageHeader';
type SubMenuTopBarContainerProps = {
children: JSX.Element | JSX.Element[];
title: string;
title: string | ReactNode;
actionButton?: ReactNode;
Icon: IconComponent;
className?: string;
};
@ -25,6 +27,7 @@ const StyledContainer = styled.div<{ isMobile: boolean }>`
export const SubMenuTopBarContainer = ({
children,
title,
actionButton,
Icon,
className,
}: SubMenuTopBarContainerProps) => {
@ -32,7 +35,13 @@ export const SubMenuTopBarContainer = ({
return (
<StyledContainer isMobile={isMobile} className={className}>
{isMobile && <PageHeader title={title} Icon={Icon} />}
<PageHeader
title={title}
Icon={Icon}
width={OBJECT_SETTINGS_WIDTH + 4 * 8}
>
{actionButton}
</PageHeader>
<PageBody>
<InformationBannerWrapper />
{children}

View File

@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconDotsVertical, IconTrash } from 'twenty-ui';
import { useNavigate } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconDotsVertical, IconRestore, IconTrash } from 'twenty-ui';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
@ -11,6 +11,9 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useDestroyManyRecords } from '@/object-record/hooks/useDestroyManyRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { Dropdown } from '../../dropdown/components/Dropdown';
import { DropdownMenu } from '../../dropdown/components/DropdownMenu';
@ -32,6 +35,12 @@ export const ShowPageMoreButton = ({
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular,
});
const { destroyManyRecords } = useDestroyManyRecords({
objectNameSingular,
});
const { restoreManyRecords } = useRestoreManyRecords({
objectNameSingular,
});
const handleDelete = () => {
deleteOneRecord(recordId);
@ -39,6 +48,21 @@ export const ShowPageMoreButton = ({
navigate(navigationMemorizedUrl, { replace: true });
};
const handleDestroy = () => {
destroyManyRecords([recordId]);
closeDropdown();
navigate(navigationMemorizedUrl, { replace: true });
};
const handleRestore = () => {
restoreManyRecords([recordId]);
closeDropdown();
};
const [recordFromStore] = useRecoilState<any>(
recordStoreFamilyState(recordId),
);
return (
<StyledContainer>
<Dropdown
@ -56,12 +80,29 @@ export const ShowPageMoreButton = ({
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleDelete}
accent="danger"
LeftIcon={IconTrash}
text="Delete"
/>
{recordFromStore && !recordFromStore.deletedAt && (
<MenuItem
onClick={handleDelete}
accent="danger"
LeftIcon={IconTrash}
text="Delete"
/>
)}
{recordFromStore && recordFromStore.deletedAt && (
<>
<MenuItem
onClick={handleDestroy}
accent="danger"
LeftIcon={IconTrash}
text="Destroy"
/>
<MenuItem
onClick={handleRestore}
LeftIcon={IconRestore}
text="Restore"
/>
</>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
}

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { Fragment } from 'react';
import { Link } from 'react-router-dom';
import styled from '@emotion/styled';
type BreadcrumbProps = {
className?: string;
@ -9,10 +9,10 @@ type BreadcrumbProps = {
const StyledWrapper = styled.nav`
align-items: center;
color: ${({ theme }) => theme.font.color.extraLight};
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.md};
// font-weight: ${({ theme }) => theme.font.weight.semiBold};
gap: ${({ theme }) => theme.spacing(2)};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
`;
@ -23,7 +23,7 @@ const StyledLink = styled(Link)`
`;
const StyledText = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
color: ${({ theme }) => theme.font.color.primary};
`;
export const Breadcrumb = ({ className, links }: BreadcrumbProps) => (

View File

@ -2,12 +2,37 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent, IconX } from 'twenty-ui';
const StyledChip = styled.div`
const StyledChip = styled.div<{ variant: SortOrFitlerChipVariant }>`
align-items: center;
background-color: ${({ theme }) => theme.accent.quaternary};
border: 1px solid ${({ theme }) => theme.accent.tertiary};
background-color: ${({ theme, variant }) => {
switch (variant) {
case 'danger':
return theme.background.danger;
case 'default':
default:
return theme.accent.quaternary;
}
}};
border: 1px solid
${({ theme, variant }) => {
switch (variant) {
case 'danger':
return theme.border.color.danger;
case 'default':
default:
return theme.accent.tertiary;
}
}};
border-radius: 4px;
color: ${({ theme }) => theme.color.blue};
color: ${({ theme, variant }) => {
switch (variant) {
case 'danger':
return theme.color.red;
case 'default':
default:
return theme.color.blue;
}
}};
cursor: pointer;
display: flex;
flex-direction: row;
@ -24,7 +49,7 @@ const StyledIcon = styled.div`
margin-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledDelete = styled.div`
const StyledDelete = styled.div<{ variant: SortOrFitlerChipVariant }>`
align-items: center;
cursor: pointer;
display: flex;
@ -33,7 +58,15 @@ const StyledDelete = styled.div`
margin-top: 1px;
user-select: none;
&:hover {
background-color: ${({ theme }) => theme.accent.secondary};
background-color: ${({ theme, variant }) => {
switch (variant) {
case 'danger':
return theme.color.red20;
case 'default':
default:
return theme.accent.secondary;
}
}};
border-radius: ${({ theme }) => theme.border.radius.sm};
}
`;
@ -42,9 +75,12 @@ const StyledLabelKey = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
type SortOrFitlerChipVariant = 'default' | 'danger';
type SortOrFilterChipProps = {
labelKey?: string;
labelValue: string;
variant?: SortOrFitlerChipVariant;
Icon?: IconComponent;
onRemove: () => void;
onClick?: () => void;
@ -54,6 +90,7 @@ type SortOrFilterChipProps = {
export const SortOrFilterChip = ({
labelKey,
labelValue,
variant = 'default',
Icon,
onRemove,
testId,
@ -67,7 +104,7 @@ export const SortOrFilterChip = ({
};
return (
<StyledChip onClick={onClick}>
<StyledChip onClick={onClick} variant={variant}>
{Icon && (
<StyledIcon>
<Icon size={theme.icon.size.sm} />
@ -76,6 +113,7 @@ export const SortOrFilterChip = ({
{labelKey && <StyledLabelKey>{labelKey}</StyledLabelKey>}
{labelValue}
<StyledDelete
variant={variant}
onClick={handleDeleteClick}
data-testid={'remove-icon-' + testId}
>

View File

@ -0,0 +1,30 @@
import { useIcons } from 'twenty-ui';
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { useCombinedViewFilters } from '@/views/hooks/useCombinedViewFilters';
type VariantFilterChipProps = {
viewFilter: Filter;
};
export const VariantFilterChip = ({ viewFilter }: VariantFilterChipProps) => {
const { removeCombinedViewFilter } = useCombinedViewFilters();
const { getIcon } = useIcons();
const handleRemoveClick = () => {
removeCombinedViewFilter(viewFilter.id);
};
return (
<SortOrFilterChip
key={viewFilter.fieldMetadataId}
testId={viewFilter.fieldMetadataId}
variant={viewFilter.variant}
labelValue={viewFilter.definition.label}
Icon={getIcon(viewFilter.definition.iconName)}
onRemove={handleRemoveClick}
/>
);
};

View File

@ -1,9 +1,10 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { ReactNode, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { AddObjectFilterFromDetailsButton } from '@/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { EditableFilterDropdownButton } from '@/views/components/EditableFilterDropdownButton';
import { EditableSortChip } from '@/views/components/EditableSortChip';
@ -14,6 +15,7 @@ import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useResetCurrentView } from '@/views/hooks/useResetCurrentView';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { VariantFilterChip } from './VariantFilterChip';
export type ViewBarDetailsProps = {
hasFilterButton?: boolean;
@ -118,6 +120,29 @@ export const ViewBarDetails = ({
const { resetCurrentView } = useResetCurrentView();
const canResetView = canPersistView && !hasFiltersQueryParams;
const { otherViewFilters, defaultViewFilters } = useMemo(() => {
if (!currentViewWithCombinedFiltersAndSorts) {
return {
otherViewFilters: [],
defaultViewFilters: [],
};
}
const otherViewFilters =
currentViewWithCombinedFiltersAndSorts.viewFilters.filter(
(viewFilter) => viewFilter.variant && viewFilter.variant !== 'default',
);
const defaultViewFilters =
currentViewWithCombinedFiltersAndSorts.viewFilters.filter(
(viewFilter) => !viewFilter.variant || viewFilter.variant === 'default',
);
return {
otherViewFilters,
defaultViewFilters,
};
}, [currentViewWithCombinedFiltersAndSorts]);
const handleCancelClick = () => {
resetCurrentView();
};
@ -136,6 +161,22 @@ export const ViewBarDetails = ({
<StyledBar>
<StyledFilterContainer>
<StyledChipcontainer>
{otherViewFilters.map((viewFilter) => (
<VariantFilterChip
key={viewFilter.fieldMetadataId}
// Why do we have two types, Filter and ViewFilter?
// Why key defition is already present in the Filter type and added on the fly here with mapViewFiltersToFilters ?
// Also as filter is spread into viewFilter, definition is present
// FixMe: Ugly hack to make it work
viewFilter={viewFilter as unknown as Filter}
/>
))}
{!!otherViewFilters.length &&
!!currentViewWithCombinedFiltersAndSorts?.viewSorts?.length && (
<StyledSeperatorContainer>
<StyledSeperator />
</StyledSeperatorContainer>
)}
{mapViewSortsToSorts(
currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [],
availableSortDefinitions,
@ -143,13 +184,13 @@ export const ViewBarDetails = ({
<EditableSortChip key={sort.fieldMetadataId} viewSort={sort} />
))}
{!!currentViewWithCombinedFiltersAndSorts?.viewSorts?.length &&
!!currentViewWithCombinedFiltersAndSorts?.viewFilters?.length && (
!!defaultViewFilters.length && (
<StyledSeperatorContainer>
<StyledSeperator />
</StyledSeperatorContainer>
)}
{mapViewFiltersToFilters(
currentViewWithCombinedFiltersAndSorts?.viewFilters ?? [],
defaultViewFilters,
availableFilterDefinitions,
).map((viewFilter) => (
<ObjectFilterDropdownScope

View File

@ -3,6 +3,7 @@ import { ViewFilterOperand } from './ViewFilterOperand';
export type ViewFilter = {
__typename: 'ViewFilter';
id: string;
variant?: 'default' | 'danger';
fieldMetadataId: string;
operand: ViewFilterOperand;
value: string;