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:
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const INFORMATION_BANNER_HEIGHT = '40px';
|
||||
@ -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 =
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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<{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -4,6 +4,7 @@ import { FilterDefinition } from './FilterDefinition';
|
||||
|
||||
export type Filter = {
|
||||
id: string;
|
||||
variant?: 'default' | 'danger';
|
||||
fieldMetadataId: string;
|
||||
value: string;
|
||||
displayValue: string;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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' && (
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -29,7 +29,7 @@ export const buildFindOneRecordForShowPageOperationSignature: RecordGqlOperation
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(objectMetadataItem.nameSingular === 'Note'
|
||||
...(objectMetadataItem.nameSingular === CoreObjectNameSingular.Note
|
||||
? {
|
||||
noteTargets: {
|
||||
id: true,
|
||||
|
||||
@ -50,6 +50,7 @@ export const useRecordShowPage = (
|
||||
objectRecordId,
|
||||
objectNameSingular,
|
||||
recordGqlFields: FIND_ONE_RECORD_FOR_SHOW_PAGE_OPERATION_SIGNATURE.fields,
|
||||
withSoftDeleted: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const getDestroyManyRecordsMutationResponseField = (
|
||||
objectNamePlural: string,
|
||||
) => `destroy${capitalize(objectNamePlural)}`;
|
||||
@ -0,0 +1,5 @@
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const getRestoreManyRecordsMutationResponseField = (
|
||||
objectNamePlural: string,
|
||||
) => `restore${capitalize(objectNamePlural)}`;
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -15,7 +15,7 @@ export enum AppPath {
|
||||
|
||||
// Onboarded
|
||||
Index = '/',
|
||||
TasksPage = '/tasks',
|
||||
TasksPage = '/objects/tasks',
|
||||
OpportunitiesPage = '/objects/opportunities',
|
||||
|
||||
RecordIndexPage = '/objects/:objectNamePlural',
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -3,6 +3,7 @@ import { ViewFilterOperand } from './ViewFilterOperand';
|
||||
export type ViewFilter = {
|
||||
__typename: 'ViewFilter';
|
||||
id: string;
|
||||
variant?: 'default' | 'danger';
|
||||
fieldMetadataId: string;
|
||||
operand: ViewFilterOperand;
|
||||
value: string;
|
||||
|
||||
Reference in New Issue
Block a user