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

@ -45,5 +45,16 @@ const config: StorybookConfig = {
name: '@storybook/react-vite', name: '@storybook/react-vite',
options: {}, options: {},
}, },
viteFinal: async (config) => {
// Merge custom configuration into the default config
const { mergeConfig } = await import('vite');
return mergeConfig(config, {
// Add dependencies to pre-optimization
optimizeDeps: {
exclude: ['@tabler/icons-react'],
},
});
},
}; };
export default config; export default config;

View File

@ -344,9 +344,9 @@ export type FieldConnection = {
/** Type of the field */ /** Type of the field */
export enum FieldMetadataType { export enum FieldMetadataType {
Actor = 'ACTOR',
Address = 'ADDRESS', Address = 'ADDRESS',
Boolean = 'BOOLEAN', Boolean = 'BOOLEAN',
Actor = 'ACTOR',
Currency = 'CURRENCY', Currency = 'CURRENCY',
Date = 'DATE', Date = 'DATE',
DateTime = 'DATE_TIME', DateTime = 'DATE_TIME',
@ -452,13 +452,13 @@ export type Mutation = {
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
impersonate: Verify; impersonate: Verify;
renewToken: AuthTokens; renewToken: AuthTokens;
runWorkflowVersion: WorkflowTriggerResult;
sendInviteLink: SendInviteLink; sendInviteLink: SendInviteLink;
signUp: LoginToken; signUp: LoginToken;
skipSyncEmailOnboardingStep: OnboardingStepSuccess; skipSyncEmailOnboardingStep: OnboardingStepSuccess;
syncRemoteTable: RemoteTable; syncRemoteTable: RemoteTable;
syncRemoteTableSchemaChanges: RemoteTable; syncRemoteTableSchemaChanges: RemoteTable;
track: Analytics; track: Analytics;
triggerWorkflow: WorkflowTriggerResult;
unsyncRemoteTable: RemoteTable; unsyncRemoteTable: RemoteTable;
updateBillingSubscription: UpdateBillingEntity; updateBillingSubscription: UpdateBillingEntity;
updateOneField: Field; updateOneField: Field;
@ -610,6 +610,11 @@ export type MutationRenewTokenArgs = {
}; };
export type MutationRunWorkflowVersionArgs = {
input: RunWorkflowVersionInput;
};
export type MutationSendInviteLinkArgs = { export type MutationSendInviteLinkArgs = {
emails: Array<Scalars['String']['input']>; emails: Array<Scalars['String']['input']>;
}; };
@ -639,11 +644,6 @@ export type MutationTrackArgs = {
}; };
export type MutationTriggerWorkflowArgs = {
workflowVersionId: Scalars['String']['input'];
};
export type MutationUnsyncRemoteTableArgs = { export type MutationUnsyncRemoteTableArgs = {
input: RemoteTableInput; input: RemoteTableInput;
}; };
@ -1001,6 +1001,13 @@ export enum RemoteTableStatus {
Synced = 'SYNCED' Synced = 'SYNCED'
} }
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']['input']>;
/** Workflow version ID */
workflowVersionId: Scalars['String']['input'];
};
export type SendInviteLink = { export type SendInviteLink = {
__typename?: 'SendInviteLink'; __typename?: 'SendInviteLink';
/** Boolean that confirms query was dispatched */ /** Boolean that confirms query was dispatched */
@ -1400,6 +1407,7 @@ export type WorkspaceFeatureFlagsArgs = {
export enum WorkspaceActivationStatus { export enum WorkspaceActivationStatus {
Active = 'ACTIVE', Active = 'ACTIVE',
Inactive = 'INACTIVE', Inactive = 'INACTIVE',
OngoingCreation = 'ONGOING_CREATION',
PendingCreation = 'PENDING_CREATION' PendingCreation = 'PENDING_CREATION'
} }

View File

@ -1,5 +1,5 @@
import * as Apollo from '@apollo/client';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null; export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>; export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
@ -249,9 +249,9 @@ export type FieldConnection = {
/** Type of the field */ /** Type of the field */
export enum FieldMetadataType { export enum FieldMetadataType {
Actor = 'ACTOR',
Address = 'ADDRESS', Address = 'ADDRESS',
Boolean = 'BOOLEAN', Boolean = 'BOOLEAN',
Actor = 'ACTOR',
Currency = 'CURRENCY', Currency = 'CURRENCY',
Date = 'DATE', Date = 'DATE',
DateTime = 'DATE_TIME', DateTime = 'DATE_TIME',
@ -344,11 +344,11 @@ export type Mutation = {
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
impersonate: Verify; impersonate: Verify;
renewToken: AuthTokens; renewToken: AuthTokens;
runWorkflowVersion: WorkflowTriggerResult;
sendInviteLink: SendInviteLink; sendInviteLink: SendInviteLink;
signUp: LoginToken; signUp: LoginToken;
skipSyncEmailOnboardingStep: OnboardingStepSuccess; skipSyncEmailOnboardingStep: OnboardingStepSuccess;
track: Analytics; track: Analytics;
triggerWorkflow: WorkflowTriggerResult;
updateBillingSubscription: UpdateBillingEntity; updateBillingSubscription: UpdateBillingEntity;
updateOneObject: Object; updateOneObject: Object;
updateOneServerlessFunction: ServerlessFunction; updateOneServerlessFunction: ServerlessFunction;
@ -457,6 +457,11 @@ export type MutationRenewTokenArgs = {
}; };
export type MutationRunWorkflowVersionArgs = {
input: RunWorkflowVersionInput;
};
export type MutationSendInviteLinkArgs = { export type MutationSendInviteLinkArgs = {
emails: Array<Scalars['String']>; emails: Array<Scalars['String']>;
}; };
@ -476,11 +481,6 @@ export type MutationTrackArgs = {
}; };
export type MutationTriggerWorkflowArgs = {
workflowVersionId: Scalars['String'];
};
export type MutationUpdateOneObjectArgs = { export type MutationUpdateOneObjectArgs = {
input: UpdateOneObjectInput; input: UpdateOneObjectInput;
}; };
@ -743,6 +743,13 @@ export enum RemoteTableStatus {
Synced = 'SYNCED' Synced = 'SYNCED'
} }
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']>;
/** Workflow version ID */
workflowVersionId: Scalars['String'];
};
export type SendInviteLink = { export type SendInviteLink = {
__typename?: 'SendInviteLink'; __typename?: 'SendInviteLink';
/** Boolean that confirms query was dispatched */ /** Boolean that confirms query was dispatched */
@ -1087,6 +1094,7 @@ export type WorkspaceFeatureFlagsArgs = {
export enum WorkspaceActivationStatus { export enum WorkspaceActivationStatus {
Active = 'ACTIVE', Active = 'ACTIVE',
Inactive = 'INACTIVE', Inactive = 'INACTIVE',
OngoingCreation = 'ONGOING_CREATION',
PendingCreation = 'PENDING_CREATION' PendingCreation = 'PENDING_CREATION'
} }

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 { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@ -19,6 +19,9 @@ export const EventIconDynamicComponent = ({
if (eventAction === 'updated') { if (eventAction === 'updated') {
return <IconEditCircle />; return <IconEditCircle />;
} }
if (eventAction === 'deleted') {
return <IconTrash />;
}
const IconComponent = getIcon(linkedObjectMetadataItem?.icon); 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: default:
return null; return null;
} }

View File

@ -1,6 +1,6 @@
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Banner, IconComponent } from 'twenty-ui'; import { Banner, BannerVariant, IconComponent } from 'twenty-ui';
const StyledBanner = styled(Banner)` const StyledBanner = styled(Banner)`
position: absolute; position: absolute;
@ -14,26 +14,30 @@ const StyledText = styled.div`
export const InformationBanner = ({ export const InformationBanner = ({
message, message,
variant = 'default',
buttonTitle, buttonTitle,
buttonIcon, buttonIcon,
buttonOnClick, buttonOnClick,
}: { }: {
message: string; message: string;
buttonTitle: string; variant?: BannerVariant;
buttonTitle?: string;
buttonIcon?: IconComponent; buttonIcon?: IconComponent;
buttonOnClick: () => void; buttonOnClick?: () => void;
}) => { }) => {
return ( return (
<StyledBanner> <StyledBanner variant={variant}>
<StyledText>{message}</StyledText> <StyledText>{message}</StyledText>
<Button {buttonTitle && buttonOnClick && (
variant="secondary" <Button
title={buttonTitle} variant="secondary"
Icon={buttonIcon} title={buttonTitle}
size="small" Icon={buttonIcon}
inverted size="small"
onClick={buttonOnClick} inverted
/> onClick={buttonOnClick}
/>
)}
</StyledBanner> </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 { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
type useDeleteOneRecordProps = { type useDeleteManyRecordProps = {
objectNameSingular: string; objectNameSingular: string;
refetchFindManyQuery?: boolean; refetchFindManyQuery?: boolean;
}; };
@ -25,7 +25,7 @@ type DeleteManyRecordsOptions = {
export const useDeleteManyRecords = ({ export const useDeleteManyRecords = ({
objectNameSingular, objectNameSingular,
}: useDeleteOneRecordProps) => { }: useDeleteManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState); const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize = 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, recordGqlFields,
onCompleted, onCompleted,
skip, skip,
withSoftDeleted = false,
}: ObjectMetadataItemIdentifier & { }: ObjectMetadataItemIdentifier & {
objectRecordId: string | undefined; objectRecordId: string | undefined;
recordGqlFields?: RecordGqlOperationGqlRecordFields; recordGqlFields?: RecordGqlOperationGqlRecordFields;
onCompleted?: (data: T) => void; onCompleted?: (data: T) => void;
skip?: boolean; skip?: boolean;
withSoftDeleted?: boolean;
}) => { }) => {
const { objectMetadataItem } = useObjectMetadataItem({ const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular, objectNameSingular,
@ -33,6 +35,7 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
const { findOneRecordQuery } = useFindOneRecordQuery({ const { findOneRecordQuery } = useFindOneRecordQuery({
objectNameSingular, objectNameSingular,
recordGqlFields: computedRecordGqlFields, recordGqlFields: computedRecordGqlFields,
withSoftDeleted,
}); });
const { data, loading, error } = useQuery<{ const { data, loading, error } = useQuery<{

View File

@ -10,9 +10,11 @@ import { capitalize } from '~/utils/string/capitalize';
export const useFindOneRecordQuery = ({ export const useFindOneRecordQuery = ({
objectNameSingular, objectNameSingular,
recordGqlFields, recordGqlFields,
withSoftDeleted = false,
}: { }: {
objectNameSingular: string; objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields; recordGqlFields?: RecordGqlOperationGqlRecordFields;
withSoftDeleted?: boolean;
}) => { }) => {
const { objectMetadataItem } = useObjectMetadataItem({ const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular, objectNameSingular,
@ -25,6 +27,16 @@ export const useFindOneRecordQuery = ({
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
)}($objectRecordId: ID!) { )}($objectRecordId: ID!) {
${objectMetadataItem.nameSingular}(filter: { ${objectMetadataItem.nameSingular}(filter: {
${
withSoftDeleted
? `
or: [
{ deletedAt: { is: NULL } },
{ deletedAt: { is: NOT_NULL } }
],
`
: ''
}
id: { id: {
eq: $objectRecordId 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 = { export type Filter = {
id: string; id: string;
variant?: 'default' | 'danger';
fieldMetadataId: string; fieldMetadataId: string;
value: string; value: string;
displayValue: 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, IconFileImport,
IconSettings, IconSettings,
IconTag, IconTag,
IconTrash,
} from 'twenty-ui'; } from 'twenty-ui';
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
@ -37,6 +38,7 @@ import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
type RecordIndexOptionsMenu = 'fields' | 'hiddenFields'; type RecordIndexOptionsMenu = 'fields' | 'hiddenFields';
@ -88,6 +90,11 @@ export const RecordIndexOptionsDropdownContent = ({
hiddenTableColumns, hiddenTableColumns,
} = useRecordIndexOptionsForTable(recordIndexId); } = useRecordIndexOptionsForTable(recordIndexId);
const handleToggleTrashColumnFilter = useHandleToggleTrashColumnFilter({
objectNameSingular,
viewBarId: recordIndexId,
});
const { const {
visibleBoardFields, visibleBoardFields,
hiddenBoardFields, hiddenBoardFields,
@ -153,6 +160,14 @@ export const RecordIndexOptionsDropdownContent = ({
LeftIcon={IconFileExport} LeftIcon={IconFileExport}
text={displayedExportProgress(progress)} text={displayedExportProgress(progress)}
/> />
<MenuItem
onClick={() => {
handleToggleTrashColumnFilter();
closeDropdown();
}}
LeftIcon={IconTrash}
text="Trash"
/>
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
)} )}
{currentMenu === 'fields' && ( {currentMenu === 'fields' && (

View File

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

View File

@ -50,6 +50,7 @@ export const useRecordShowPage = (
objectRecordId, objectRecordId,
objectNameSingular, objectNameSingular,
recordGqlFields: FIND_ONE_RECORD_FOR_SHOW_PAGE_OPERATION_SIGNATURE.fields, recordGqlFields: FIND_ONE_RECORD_FOR_SHOW_PAGE_OPERATION_SIGNATURE.fields,
withSoftDeleted: true,
}); });
useEffect(() => { 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, IconColorSwatch,
IconCurrencyDollar, IconCurrencyDollar,
IconDoorEnter, IconDoorEnter,
IconFunction,
IconHierarchy2, IconHierarchy2,
IconMail, IconMail,
IconRocket, IconRocket,
IconSettings, IconSettings,
IconUserCircle, IconUserCircle,
IconUsers, IconUsers,
IconFunction,
} from 'twenty-ui'; } from 'twenty-ui';
import { useAuth } from '@/auth/hooks/useAuth'; import { useAuth } from '@/auth/hooks/useAuth';
@ -49,7 +49,6 @@ export const SettingsNavigationDrawerItems = () => {
path={SettingsPath.Appearance} path={SettingsPath.Appearance}
Icon={IconColorSwatch} Icon={IconColorSwatch}
/> />
<NavigationDrawerItemGroup> <NavigationDrawerItemGroup>
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem
label="Accounts" label="Accounts"

View File

@ -1,9 +1,12 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings'; import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledSettingsPageContainer = styled.div<{ width?: number }>` const StyledSettingsPageContainer = styled.div<{ width?: number }>`
display: flex; display: flex;
flex-direction: column; 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 { FloatingButton } from '@/ui/input/button/components/FloatingButton';
import { Card } from '@/ui/layout/card/components/Card'; import { Card } from '@/ui/layout/card/components/Card';
import { SettingsPath } from '@/types/SettingsPath';
import DarkCoverImage from '../assets/cover-dark.png'; import DarkCoverImage from '../assets/cover-dark.png';
import LightCoverImage from '../assets/cover-light.png'; import LightCoverImage from '../assets/cover-light.png';
@ -34,7 +35,7 @@ export const SettingsObjectCoverImage = () => {
Icon={IconEye} Icon={IconEye}
title="Visualize" title="Visualize"
size="small" size="small"
to="/settings/objects/overview" to={'/settings/' + SettingsPath.ObjectOverview}
/> />
</StyledButtonContainer> </StyledButtonContainer>
</StyledCoverImageContainer> </StyledCoverImageContainer>

View File

@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Section } from '@react-email/components'; import { Section } from '@react-email/components';
import { useNavigate } from 'react-router-dom';
import { H2Title } from 'twenty-ui'; import { H2Title } from 'twenty-ui';
import { useDeleteOneDatabaseConnection } from '@/databases/hooks/useDeleteOneDatabaseConnection'; import { useDeleteOneDatabaseConnection } from '@/databases/hooks/useDeleteOneDatabaseConnection';
@ -31,6 +31,7 @@ export const SettingsIntegrationDatabaseConnectionShowContainer = () => {
SettingsPath.Integrations, SettingsPath.Integrations,
); );
// TODO: move breadcrumb to header?
return ( return (
<> <>
<Breadcrumb <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 { zodResolver } from '@hookform/resolvers/zod';
import { Section } from '@react-email/components'; import { Section } from '@react-email/components';
import pick from 'lodash.pick'; import pick from 'lodash.pick';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { H2Title } from 'twenty-ui'; import { H2Title } from 'twenty-ui';
import { z } from 'zod'; import { z } from 'zod';
@ -94,6 +94,7 @@ export const SettingsIntegrationEditDatabaseConnectionContent = ({
} }
}; };
// TODO: move breadcrumb to header?
return ( return (
<> <>
<FormProvider <FormProvider

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,12 +2,37 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconComponent, IconX } from 'twenty-ui'; import { IconComponent, IconX } from 'twenty-ui';
const StyledChip = styled.div` const StyledChip = styled.div<{ variant: SortOrFitlerChipVariant }>`
align-items: center; align-items: center;
background-color: ${({ theme }) => theme.accent.quaternary}; background-color: ${({ theme, variant }) => {
border: 1px solid ${({ theme }) => theme.accent.tertiary}; 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; 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; cursor: pointer;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -24,7 +49,7 @@ const StyledIcon = styled.div`
margin-right: ${({ theme }) => theme.spacing(1)}; margin-right: ${({ theme }) => theme.spacing(1)};
`; `;
const StyledDelete = styled.div` const StyledDelete = styled.div<{ variant: SortOrFitlerChipVariant }>`
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@ -33,7 +58,15 @@ const StyledDelete = styled.div`
margin-top: 1px; margin-top: 1px;
user-select: none; user-select: none;
&:hover { &: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}; border-radius: ${({ theme }) => theme.border.radius.sm};
} }
`; `;
@ -42,9 +75,12 @@ const StyledLabelKey = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium}; font-weight: ${({ theme }) => theme.font.weight.medium};
`; `;
type SortOrFitlerChipVariant = 'default' | 'danger';
type SortOrFilterChipProps = { type SortOrFilterChipProps = {
labelKey?: string; labelKey?: string;
labelValue: string; labelValue: string;
variant?: SortOrFitlerChipVariant;
Icon?: IconComponent; Icon?: IconComponent;
onRemove: () => void; onRemove: () => void;
onClick?: () => void; onClick?: () => void;
@ -54,6 +90,7 @@ type SortOrFilterChipProps = {
export const SortOrFilterChip = ({ export const SortOrFilterChip = ({
labelKey, labelKey,
labelValue, labelValue,
variant = 'default',
Icon, Icon,
onRemove, onRemove,
testId, testId,
@ -67,7 +104,7 @@ export const SortOrFilterChip = ({
}; };
return ( return (
<StyledChip onClick={onClick}> <StyledChip onClick={onClick} variant={variant}>
{Icon && ( {Icon && (
<StyledIcon> <StyledIcon>
<Icon size={theme.icon.size.sm} /> <Icon size={theme.icon.size.sm} />
@ -76,6 +113,7 @@ export const SortOrFilterChip = ({
{labelKey && <StyledLabelKey>{labelKey}</StyledLabelKey>} {labelKey && <StyledLabelKey>{labelKey}</StyledLabelKey>}
{labelValue} {labelValue}
<StyledDelete <StyledDelete
variant={variant}
onClick={handleDeleteClick} onClick={handleDeleteClick}
data-testid={'remove-icon-' + testId} 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 styled from '@emotion/styled';
import { ReactNode, useMemo } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { AddObjectFilterFromDetailsButton } from '@/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton'; import { AddObjectFilterFromDetailsButton } from '@/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; 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 { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { EditableFilterDropdownButton } from '@/views/components/EditableFilterDropdownButton'; import { EditableFilterDropdownButton } from '@/views/components/EditableFilterDropdownButton';
import { EditableSortChip } from '@/views/components/EditableSortChip'; import { EditableSortChip } from '@/views/components/EditableSortChip';
@ -14,6 +15,7 @@ import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useResetCurrentView } from '@/views/hooks/useResetCurrentView'; import { useResetCurrentView } from '@/views/hooks/useResetCurrentView';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { VariantFilterChip } from './VariantFilterChip';
export type ViewBarDetailsProps = { export type ViewBarDetailsProps = {
hasFilterButton?: boolean; hasFilterButton?: boolean;
@ -118,6 +120,29 @@ export const ViewBarDetails = ({
const { resetCurrentView } = useResetCurrentView(); const { resetCurrentView } = useResetCurrentView();
const canResetView = canPersistView && !hasFiltersQueryParams; 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 = () => { const handleCancelClick = () => {
resetCurrentView(); resetCurrentView();
}; };
@ -136,6 +161,22 @@ export const ViewBarDetails = ({
<StyledBar> <StyledBar>
<StyledFilterContainer> <StyledFilterContainer>
<StyledChipcontainer> <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( {mapViewSortsToSorts(
currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [], currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [],
availableSortDefinitions, availableSortDefinitions,
@ -143,13 +184,13 @@ export const ViewBarDetails = ({
<EditableSortChip key={sort.fieldMetadataId} viewSort={sort} /> <EditableSortChip key={sort.fieldMetadataId} viewSort={sort} />
))} ))}
{!!currentViewWithCombinedFiltersAndSorts?.viewSorts?.length && {!!currentViewWithCombinedFiltersAndSorts?.viewSorts?.length &&
!!currentViewWithCombinedFiltersAndSorts?.viewFilters?.length && ( !!defaultViewFilters.length && (
<StyledSeperatorContainer> <StyledSeperatorContainer>
<StyledSeperator /> <StyledSeperator />
</StyledSeperatorContainer> </StyledSeperatorContainer>
)} )}
{mapViewFiltersToFilters( {mapViewFiltersToFilters(
currentViewWithCombinedFiltersAndSorts?.viewFilters ?? [], defaultViewFilters,
availableFilterDefinitions, availableFilterDefinitions,
).map((viewFilter) => ( ).map((viewFilter) => (
<ObjectFilterDropdownScope <ObjectFilterDropdownScope

View File

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

View File

@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
import rehypeStringify from 'rehype-stringify'; import rehypeStringify from 'rehype-stringify';
import remarkParse from 'remark-parse'; import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype'; import remarkRehype from 'remark-rehype';
import { H1Title, IconSettings } from 'twenty-ui'; import { H1Title, IconRocket } from 'twenty-ui';
import { unified } from 'unified'; import { unified } from 'unified';
import { visit } from 'unist-util-visit'; import { visit } from 'unist-util-visit';
@ -108,7 +108,7 @@ export const Releases = () => {
}, []); }, []);
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Releases"> <SubMenuTopBarContainer Icon={IconRocket} title="Releases">
<SettingsPageContainer> <SettingsPageContainer>
<StyledH1Title title="Releases" /> <StyledH1Title title="Releases" />
<ScrollWrapper> <ScrollWrapper>

View File

@ -1,5 +1,4 @@
import styled from '@emotion/styled'; import { H2Title, IconUserCircle } from 'twenty-ui';
import { H1Title, H2Title, IconSettings } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ChangePassword } from '@/settings/profile/components/ChangePassword'; import { ChangePassword } from '@/settings/profile/components/ChangePassword';
@ -10,14 +9,9 @@ import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePic
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsProfile = () => ( export const SettingsProfile = () => (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconUserCircle} title="Profile">
<SettingsPageContainer> <SettingsPageContainer>
<StyledH1Title title="Profile" />
<Section> <Section>
<H2Title title="Picture" /> <H2Title title="Picture" />
<ProfilePictureUploader /> <ProfilePictureUploader />

View File

@ -1,5 +1,4 @@
import styled from '@emotion/styled'; import { H2Title, IconSettings } from 'twenty-ui';
import { H1Title, H2Title, IconSettings } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace'; import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace';
@ -9,14 +8,9 @@ import { WorkspaceLogoUploader } from '@/settings/workspace/components/Workspace
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsWorkspace = () => ( export const SettingsWorkspace = () => (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconSettings} title="General">
<SettingsPageContainer> <SettingsPageContainer>
<StyledH1Title title="General" />
<Section> <Section>
<H2Title title="Picture" /> <H2Title title="Picture" />
<WorkspaceLogoUploader /> <WorkspaceLogoUploader />

View File

@ -1,7 +1,7 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useState } from 'react'; import { useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { H1Title, H2Title, IconSettings, IconTrash } from 'twenty-ui'; import { H2Title, IconTrash, IconUsers } from 'twenty-ui';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@ -18,10 +18,6 @@ import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink'
import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam'; import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard'; import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
const StyledButtonContainer = styled.div` const StyledButtonContainer = styled.div`
align-items: center; align-items: center;
display: flex; display: flex;
@ -50,9 +46,8 @@ export const SettingsWorkspaceMembers = () => {
}; };
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconUsers} title="Members">
<SettingsPageContainer> <SettingsPageContainer>
<StyledH1Title title="Members" />
<Section> <Section>
<H2Title <H2Title
title="Invite by email" title="Invite by email"

View File

@ -1,5 +1,5 @@
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { H2Title, IconSettings } from 'twenty-ui'; import { H2Title, IconAt } from 'twenty-ui';
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
@ -14,7 +14,6 @@ import { SettingsAccountsSettingsSection } from '@/settings/accounts/components/
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const SettingsAccounts = () => { export const SettingsAccounts = () => {
@ -37,10 +36,8 @@ export const SettingsAccounts = () => {
const isBlocklistEnabled = useIsFeatureEnabled('IS_BLOCKLIST_ENABLED'); const isBlocklistEnabled = useIsFeatureEnabled('IS_BLOCKLIST_ENABLED');
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconAt} title="Account">
<SettingsPageContainer> <SettingsPageContainer>
<Breadcrumb links={[{ children: 'Accounts' }]} />
{loading ? ( {loading ? (
<SettingsAccountLoader /> <SettingsAccountLoader />
) : ( ) : (

View File

@ -1,5 +1,3 @@
import { IconSettings } from 'twenty-ui';
import { SettingsAccountsCalendarChannelsContainer } from '@/settings/accounts/components/SettingsAccountsCalendarChannelsContainer'; import { SettingsAccountsCalendarChannelsContainer } from '@/settings/accounts/components/SettingsAccountsCalendarChannelsContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
@ -7,11 +5,13 @@ import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { IconCalendarEvent } from 'twenty-ui';
export const SettingsAccountsCalendars = () => { export const SettingsAccountsCalendars = () => {
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
<SettingsPageContainer> Icon={IconCalendarEvent}
title={
<Breadcrumb <Breadcrumb
links={[ links={[
{ {
@ -21,6 +21,9 @@ export const SettingsAccountsCalendars = () => {
{ children: 'Calendars' }, { children: 'Calendars' },
]} ]}
/> />
}
>
<SettingsPageContainer>
<Section> <Section>
<SettingsAccountsCalendarChannelsContainer /> <SettingsAccountsCalendarChannelsContainer />
</Section> </Section>

View File

@ -1,20 +1,23 @@
import { IconSettings } from 'twenty-ui';
import { SettingsAccountsMessageChannelsContainer } from '@/settings/accounts/components/SettingsAccountsMessageChannelsContainer'; import { SettingsAccountsMessageChannelsContainer } from '@/settings/accounts/components/SettingsAccountsMessageChannelsContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { IconMail } from 'twenty-ui';
export const SettingsAccountsEmails = () => ( export const SettingsAccountsEmails = () => (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
<SettingsPageContainer> Icon={IconMail}
title={
<Breadcrumb <Breadcrumb
links={[ links={[
{ children: 'Accounts', href: '/settings/accounts' }, { children: 'Accounts', href: '/settings/accounts' },
{ children: 'Emails' }, { children: 'Emails' },
]} ]}
/> />
}
>
<SettingsPageContainer>
<Section> <Section>
<SettingsAccountsMessageChannelsContainer /> <SettingsAccountsMessageChannelsContainer />
</Section> </Section>

View File

@ -1,20 +1,23 @@
import { IconSettings } from 'twenty-ui';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection'; import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { IconAt } from 'twenty-ui';
export const SettingsNewAccount = () => { export const SettingsNewAccount = () => {
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
<SettingsPageContainer> Icon={IconAt}
title={
<Breadcrumb <Breadcrumb
links={[ links={[
{ children: 'Accounts', href: '/settings/accounts' }, { children: 'Accounts', href: '/settings/accounts' },
{ children: `New` }, { children: `New` },
]} ]}
/> />
}
>
<SettingsPageContainer>
<SettingsNewAccountSection /> <SettingsNewAccountSection />
</SettingsPageContainer> </SettingsPageContainer>
</SubMenuTopBarContainer> </SubMenuTopBarContainer>

View File

@ -16,12 +16,13 @@ const REVERT_PUBLIC_KEY = 'pk_live_a87fee8c-28c7-494f-99a3-996ff89f9918';
export const SettingsCRMMigration = () => { export const SettingsCRMMigration = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentWorkspace = useRecoilValue(currentWorkspaceState);
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconSettings}
title={<Breadcrumb links={[{ children: 'Migrate' }]} />}
actionButton={<SettingsReadDocumentationButton />}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer> <SettingsHeaderContainer></SettingsHeaderContainer>
<Breadcrumb links={[{ children: 'Migrate' }]} />
<SettingsReadDocumentationButton />
</SettingsHeaderContainer>
<Section> <Section>
<RevertConnect <RevertConnect
config={{ config={{

View File

@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { H2Title, IconSettings } from 'twenty-ui'; import { H2Title, IconHierarchy2 } from 'twenty-ui';
import { z } from 'zod'; import { z } from 'zod';
import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreateOneObjectMetadataItem'; import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreateOneObjectMetadataItem';
@ -70,25 +70,30 @@ export const SettingsNewObject = () => {
return ( return (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formConfig}> <FormProvider {...formConfig}>
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconHierarchy2}
title={
<Breadcrumb
links={[
{
children: 'Objects',
href: settingsObjectsPagePath,
},
{ children: 'New' },
]}
/>
}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
isCancelDisabled={isSubmitting}
onCancel={() => navigate(settingsObjectsPagePath)}
onSave={formConfig.handleSubmit(handleSave)}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer> <SettingsHeaderContainer></SettingsHeaderContainer>
<Breadcrumb
links={[
{
children: 'Objects',
href: settingsObjectsPagePath,
},
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
isCancelDisabled={isSubmitting}
onCancel={() => navigate(settingsObjectsPagePath)}
onSave={formConfig.handleSubmit(handleSave)}
/>
</SettingsHeaderContainer>
<Section> <Section>
<H2Title <H2Title
title="About" title="About"

View File

@ -1,12 +1,7 @@
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import { H2Title, IconPlus, IconSettings } from 'twenty-ui';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { getDisabledFieldMetadataItems } from '@/object-metadata/utils/getDisabledFieldMetadataItems'; import { getDisabledFieldMetadataItems } from '@/object-metadata/utils/getDisabledFieldMetadataItems';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard'; import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
@ -15,7 +10,10 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink'; import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import styled from '@emotion/styled';
import { isNonEmptyArray } from '@sniptt/guards'; import { isNonEmptyArray } from '@sniptt/guards';
import { useNavigate } from 'react-router-dom';
import { H2Title, IconHierarchy2, IconPlus } from 'twenty-ui';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable'; import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
const StyledDiv = styled.div` const StyledDiv = styled.div`
@ -49,14 +47,18 @@ export const SettingsObjectDetailPageContent = ({
const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote; const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
<SettingsPageContainer> Icon={IconHierarchy2}
title={
<Breadcrumb <Breadcrumb
links={[ links={[
{ children: 'Objects', href: '/settings/objects' }, { children: 'Objects', href: '/settings/objects' },
{ children: objectMetadataItem.labelPlural }, { children: objectMetadataItem.labelPlural },
]} ]}
/> />
}
>
<SettingsPageContainer>
<Section> <Section>
<H2Title title="About" description="Manage your object" /> <H2Title title="About" description="Manage your object" />
<SettingsObjectSummaryCard <SettingsObjectSummaryCard

View File

@ -5,12 +5,13 @@ import pick from 'lodash.pick';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconArchive, IconSettings } from 'twenty-ui'; import { H2Title, IconArchive, IconHierarchy2 } from 'twenty-ui';
import { z } from 'zod'; import { z } from 'zod';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
@ -30,7 +31,6 @@ import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
const objectEditFormSchema = z const objectEditFormSchema = z
.object({}) .object({})
@ -108,22 +108,26 @@ export const SettingsObjectEdit = () => {
return ( return (
<RecordFieldValueSelectorContextProvider> <RecordFieldValueSelectorContextProvider>
<FormProvider {...formConfig}> <FormProvider {...formConfig}>
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconHierarchy2}
title={
<Breadcrumb
links={[
{
children: 'Objects',
href: settingsObjectsPagePath,
},
{
children: activeObjectMetadataItem.labelPlural,
href: `${settingsObjectsPagePath}/${objectSlug}`,
},
{ children: 'Edit' },
]}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer> <SettingsHeaderContainer>
<Breadcrumb
links={[
{
children: 'Objects',
href: settingsObjectsPagePath,
},
{
children: activeObjectMetadataItem.labelPlural,
href: `${settingsObjectsPagePath}/${objectSlug}`,
},
{ children: 'Edit' },
]}
/>
{activeObjectMetadataItem.isCustom && ( {activeObjectMetadataItem.isCustom && (
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={!canSave} isSaveDisabled={!canSave}

View File

@ -6,7 +6,7 @@ import pick from 'lodash.pick';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconArchive, IconSettings } from 'twenty-ui'; import { H2Title, IconArchive, IconHierarchy2 } from 'twenty-ui';
import { z } from 'zod'; import { z } from 'zod';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
@ -172,19 +172,23 @@ export const SettingsObjectFieldEdit = () => {
<RecordFieldValueSelectorContextProvider> <RecordFieldValueSelectorContextProvider>
{/* eslint-disable-next-line react/jsx-props-no-spreading */} {/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...formConfig}> <FormProvider {...formConfig}>
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconHierarchy2}
title={
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: activeMetadataField.label },
]}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer> <SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: activeMetadataField.label },
]}
/>
{shouldDisplaySaveAndCancel && ( {shouldDisplaySaveAndCancel && (
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={!canSave} isSaveDisabled={!canSave}

View File

@ -5,7 +5,6 @@ import { H2Title, IconPlus, IconSettings } from 'twenty-ui';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
@ -85,27 +84,31 @@ export const SettingsObjectNewFieldStep1 = () => {
if (!activeObjectMetadataItem) return null; if (!activeObjectMetadataItem) return null;
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
<SettingsPageContainer> Icon={IconSettings}
<SettingsHeaderContainer> title={
<Breadcrumb <Breadcrumb
links={[ links={[
{ children: 'Objects', href: '/settings/objects' }, { children: 'Objects', href: '/settings/objects' },
{ {
children: activeObjectMetadataItem.labelPlural, children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`, href: `/settings/objects/${objectSlug}`,
}, },
{ children: 'New Field' }, { children: 'New Field' },
]} ]}
/>
}
actionButton={
!activeObjectMetadataItem.isRemote && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/> />
{!activeObjectMetadataItem.isRemote && ( )
<SaveAndCancelButtons }
isSaveDisabled={!canSave} >
onCancel={() => navigate(`/settings/objects/${objectSlug}`)} <SettingsPageContainer>
onSave={handleSave}
/>
)}
</SettingsHeaderContainer>
<StyledSection> <StyledSection>
<H2Title <H2Title
title="Check deactivated fields" title="Check deactivated fields"

View File

@ -1,12 +1,25 @@
import { ReactFlowProvider } from 'reactflow'; import { ReactFlowProvider } from 'reactflow';
import { IconSettings } from 'twenty-ui';
import { SettingsDataModelOverview } from '@/settings/data-model/graph-overview/components/SettingsDataModelOverview'; import { SettingsDataModelOverview } from '@/settings/data-model/graph-overview/components/SettingsDataModelOverview';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { IconHierarchy2 } from 'twenty-ui';
export const SettingsObjectOverview = () => { export const SettingsObjectOverview = () => {
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconHierarchy2}
title={
<Breadcrumb
links={[
{ children: 'Data model', href: '/settings/objects' },
{
children: 'Overview',
},
]}
/>
}
>
<ReactFlowProvider> <ReactFlowProvider>
<SettingsDataModelOverview /> <SettingsDataModelOverview />
</ReactFlowProvider> </ReactFlowProvider>

View File

@ -1,19 +1,12 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { import { H2Title, IconChevronRight, IconHierarchy2, IconPlus } from 'twenty-ui';
H1Title,
H2Title,
IconChevronRight,
IconPlus,
IconSettings,
} from 'twenty-ui';
import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem'; import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { useCombinedGetTotalCount } from '@/object-record/multiple-objects/hooks/useCombinedGetTotalCount'; import { useCombinedGetTotalCount } from '@/object-record/multiple-objects/hooks/useCombinedGetTotalCount';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { import {
SettingsObjectMetadataItemTableRow, SettingsObjectMetadataItemTableRow,
@ -42,10 +35,6 @@ const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary}; color: ${({ theme }) => theme.font.color.tertiary};
`; `;
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsObjects = () => { export const SettingsObjects = () => {
const theme = useTheme(); const theme = useTheme();
@ -115,19 +104,21 @@ export const SettingsObjects = () => {
); );
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconHierarchy2}
title="Data model"
actionButton={
<UndecoratedLink to={getSettingsPagePath(SettingsPath.NewObject)}>
<Button
Icon={IconPlus}
title="Add object"
accent="blue"
size="small"
/>
</UndecoratedLink>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer>
<StyledH1Title title="Objects" />
<UndecoratedLink to={getSettingsPagePath(SettingsPath.NewObject)}>
<Button
Icon={IconPlus}
title="Add object"
accent="blue"
size="small"
/>
</UndecoratedLink>
</SettingsHeaderContainer>
<> <>
<SettingsObjectCoverImage /> <SettingsObjectCoverImage />
<Section> <Section>

View File

@ -1,7 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { H2Title, IconPlus, IconSettings } from 'twenty-ui'; import { H2Title, IconCode, IconPlus } from 'twenty-ui';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsApiKeysTable } from '@/settings/developers/components/SettingsApiKeysTable'; import { SettingsApiKeysTable } from '@/settings/developers/components/SettingsApiKeysTable';
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton'; import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
@ -9,7 +8,6 @@ import { SettingsWebhooksTable } from '@/settings/developers/components/Settings
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
const StyledButtonContainer = styled.div` const StyledButtonContainer = styled.div`
display: flex; display: flex;
@ -19,12 +17,12 @@ const StyledButtonContainer = styled.div`
export const SettingsDevelopers = () => { export const SettingsDevelopers = () => {
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconCode}
title="Developers"
actionButton={<SettingsReadDocumentationButton />}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb links={[{ children: 'Developers' }]} />
<SettingsReadDocumentationButton />
</SettingsHeaderContainer>
<Section> <Section>
<H2Title <H2Title
title="API keys" title="API keys"

View File

@ -4,16 +4,16 @@ import { DateTime } from 'luxon';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { H2Title, IconRepeat, IconSettings, IconTrash } from 'twenty-ui'; import { H2Title, IconCode, IconRepeat, IconTrash } from 'twenty-ui';
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';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput'; import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { ApiKeyNameInput } from '@/settings/developers/components/ApiKeyNameInput'; import { ApiKeyNameInput } from '@/settings/developers/components/ApiKeyNameInput';
import { apiKeyTokenState } from '@/settings/developers/states/generatedApiKeyTokenState';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { computeNewExpirationDate } from '@/settings/developers/utils/compute-new-expiration-date'; import { computeNewExpirationDate } from '@/settings/developers/utils/compute-new-expiration-date';
import { formatExpiration } from '@/settings/developers/utils/format-expiration'; import { formatExpiration } from '@/settings/developers/utils/format-expiration';
@ -24,7 +24,6 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql'; import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
import { apiKeyTokenState } from '@/settings/developers/states/generatedApiKeyTokenState';
const StyledInfo = styled.span` const StyledInfo = styled.span`
color: ${({ theme }) => theme.font.color.light}; color: ${({ theme }) => theme.font.color.light};
@ -121,16 +120,18 @@ export const SettingsDevelopersApiKeyDetail = () => {
return ( return (
<> <>
{apiKeyData?.name && ( {apiKeyData?.name && (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconCode}
title={
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: `${apiKeyName} API Key` },
]}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: `${apiKeyName} API Key` },
]}
/>
</SettingsHeaderContainer>
<Section> <Section>
{apiKeyToken ? ( {apiKeyToken ? (
<> <>

View File

@ -1,25 +1,24 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { H2Title, IconSettings } from 'twenty-ui'; import { H2Title, IconCode } from 'twenty-ui';
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';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { EXPIRATION_DATES } from '@/settings/developers/constants/ExpirationDates'; import { EXPIRATION_DATES } from '@/settings/developers/constants/ExpirationDates';
import { apiKeyTokenState } from '@/settings/developers/states/generatedApiKeyTokenState';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { Select } from '@/ui/input/components/Select'; import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql'; import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { Key } from 'ts-key-enum';
import { apiKeyTokenState } from '@/settings/developers/states/generatedApiKeyTokenState';
import { useSetRecoilState } from 'recoil';
export const SettingsDevelopersApiKeysNew = () => { export const SettingsDevelopersApiKeysNew = () => {
const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation(); const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation();
@ -64,23 +63,27 @@ export const SettingsDevelopersApiKeysNew = () => {
}; };
const canSave = !!formValues.name && createOneApiKey; const canSave = !!formValues.name && createOneApiKey;
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconCode}
title={
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: 'New API Key' },
]}
/>
}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: 'New API Key' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<Section> <Section>
<H2Title title="Name" description="Name of your API key" /> <H2Title title="Name" description="Name of your API key" />
<TextInput <TextInput

View File

@ -1,7 +1,7 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconSettings, IconTrash } from 'twenty-ui'; import { H2Title, IconCode, IconTrash } from 'twenty-ui';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -9,7 +9,6 @@ import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Webhook } from '@/settings/developers/types/webhook/Webhook'; import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
@ -88,23 +87,27 @@ export const SettingsDevelopersWebhooksDetail = () => {
return ( return (
<> <>
{webhookData?.targetUrl && ( {webhookData?.targetUrl && (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconCode}
title={
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: 'Webhook' },
]}
/>
}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!isDirty}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: 'Webhook' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!isDirty}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<Section> <Section>
<H2Title <H2Title
title="Endpoint URL" title="Endpoint URL"

View File

@ -1,11 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { H2Title, IconSettings } from 'twenty-ui'; import { H2Title, IconCode } from 'twenty-ui';
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';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Webhook } from '@/settings/developers/types/webhook/Webhook'; import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
@ -35,23 +34,27 @@ export const SettingsDevelopersWebhooksNew = () => {
}; };
const canSave = !!formValues.targetUrl && createOneWebhook; const canSave = !!formValues.targetUrl && createOneWebhook;
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconCode}
title={
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: 'New webhook' },
]}
/>
}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: 'New webhook' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<Section> <Section>
<H2Title <H2Title
title="Endpoint URL" title="Endpoint URL"

View File

@ -42,8 +42,9 @@ export const SettingsIntegrationDatabase = () => {
if (!isIntegrationAvailable) return null; if (!isIntegrationAvailable) return null;
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
<SettingsPageContainer> Icon={IconSettings}
title={
<Breadcrumb <Breadcrumb
links={[ links={[
{ {
@ -53,6 +54,9 @@ export const SettingsIntegrationDatabase = () => {
{ children: integration.text }, { children: integration.text },
]} ]}
/> />
}
>
<SettingsPageContainer>
<SettingsIntegrationPreview <SettingsIntegrationPreview
integrationLogoUrl={integration.from.image} integrationLogoUrl={integration.from.image}
/> />

View File

@ -3,10 +3,21 @@ import { IconSettings } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsIntegrationEditDatabaseConnectionContainer } from '@/settings/integrations/database-connection/components/SettingsIntegrationEditDatabaseConnectionContainer'; import { SettingsIntegrationEditDatabaseConnectionContainer } from '@/settings/integrations/database-connection/components/SettingsIntegrationEditDatabaseConnectionContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
export const SettingsIntegrationEditDatabaseConnection = () => { export const SettingsIntegrationEditDatabaseConnection = () => {
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconSettings}
title={
<Breadcrumb
links={[
// TODO
{ children: 'Edit connection' },
]}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsIntegrationEditDatabaseConnectionContainer /> <SettingsIntegrationEditDatabaseConnectionContainer />
</SettingsPageContainer> </SettingsPageContainer>

View File

@ -8,7 +8,6 @@ import { z } from 'zod';
import { useCreateOneDatabaseConnection } from '@/databases/hooks/useCreateOneDatabaseConnection'; import { useCreateOneDatabaseConnection } from '@/databases/hooks/useCreateOneDatabaseConnection';
import { getForeignDataWrapperType } from '@/databases/utils/getForeignDataWrapperType'; import { getForeignDataWrapperType } from '@/databases/utils/getForeignDataWrapperType';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { import {
SettingsIntegrationDatabaseConnectionForm, SettingsIntegrationDatabaseConnectionForm,
@ -132,34 +131,38 @@ export const SettingsIntegrationNewDatabaseConnection = () => {
}; };
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconSettings}
title={
<Breadcrumb
links={[
{
children: 'Integrations',
href: settingsIntegrationsPagePath,
},
{
children: integration.text,
href: `${settingsIntegrationsPagePath}/${databaseKey}`,
},
{ children: 'New' },
]}
/>
}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() =>
navigate(`${settingsIntegrationsPagePath}/${databaseKey}`)
}
onSave={handleSave}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<FormProvider <FormProvider
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...formConfig} {...formConfig}
> >
<SettingsHeaderContainer>
<Breadcrumb
links={[
{
children: 'Integrations',
href: settingsIntegrationsPagePath,
},
{
children: integration.text,
href: `${settingsIntegrationsPagePath}/${databaseKey}`,
},
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() =>
navigate(`${settingsIntegrationsPagePath}/${databaseKey}`)
}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<Section> <Section>
<H2Title <H2Title
title="Connect a new database" title="Connect a new database"

View File

@ -1,18 +1,15 @@
import { IconSettings } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsIntegrationGroup } from '@/settings/integrations/components/SettingsIntegrationGroup'; import { SettingsIntegrationGroup } from '@/settings/integrations/components/SettingsIntegrationGroup';
import { useSettingsIntegrationCategories } from '@/settings/integrations/hooks/useSettingsIntegrationCategories'; import { useSettingsIntegrationCategories } from '@/settings/integrations/hooks/useSettingsIntegrationCategories';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { IconApps } from 'twenty-ui';
export const SettingsIntegrations = () => { export const SettingsIntegrations = () => {
const integrationCategories = useSettingsIntegrationCategories(); const integrationCategories = useSettingsIntegrationCategories();
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconApps} title="Integrations">
<SettingsPageContainer> <SettingsPageContainer>
<Breadcrumb links={[{ children: 'Integrations' }]} />
{integrationCategories.map((group) => ( {integrationCategories.map((group) => (
<SettingsIntegrationGroup key={group.key} integrationGroup={group} /> <SettingsIntegrationGroup key={group.key} integrationGroup={group} />
))} ))}

View File

@ -1,5 +1,4 @@
import styled from '@emotion/styled'; import { H2Title, IconColorSwatch } from 'twenty-ui';
import { H1Title, H2Title, IconSettings } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ColorSchemePicker } from '@/ui/input/color-scheme/components/ColorSchemePicker'; import { ColorSchemePicker } from '@/ui/input/color-scheme/components/ColorSchemePicker';
@ -8,17 +7,12 @@ import { Section } from '@/ui/layout/section/components/Section';
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme'; import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { DateTimeSettings } from '~/pages/settings/profile/appearance/components/DateTimeSettings'; import { DateTimeSettings } from '~/pages/settings/profile/appearance/components/DateTimeSettings';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsAppearance = () => { export const SettingsAppearance = () => {
const { colorScheme, setColorScheme } = useColorScheme(); const { colorScheme, setColorScheme } = useColorScheme();
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconColorSwatch} title="Appearance">
<SettingsPageContainer> <SettingsPageContainer>
<StyledH1Title title="Appearance" />
<Section> <Section>
<H2Title title="Theme" /> <H2Title title="Theme" />
<ColorSchemePicker value={colorScheme} onChange={setColorScheme} /> <ColorSchemePicker value={colorScheme} onChange={setColorScheme} />

View File

@ -1,7 +1,7 @@
import { useResetRecoilState } from 'recoil'; import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState'; import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState'; import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState'; import { useResetRecoilState } from 'recoil';
export const ResetServerlessFunctionStatesEffect = () => { export const ResetServerlessFunctionStatesEffect = () => {
const resetSettingsServerlessFunctionInput = useResetRecoilState( const resetSettingsServerlessFunctionInput = useResetRecoilState(

View File

@ -1,25 +1,24 @@
import { useParams } from 'react-router-dom';
import { IconCode, IconSettings, IconTestPipe } from 'twenty-ui';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab';
import { SettingsServerlessFunctionTestTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab';
import { SettingsServerlessFunctionTestTabEffect } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTabEffect';
import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { TabList } from '@/ui/layout/tab/components/TabList'; import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useParams } from 'react-router-dom';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab'; import { IconCode, IconFunction, IconSettings, IconTestPipe } from 'twenty-ui';
import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionTestTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab';
import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { SettingsServerlessFunctionTestTabEffect } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTabEffect';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
const TAB_LIST_COMPONENT_ID = 'serverless-function-detail'; const TAB_LIST_COMPONENT_ID = 'serverless-function-detail';
@ -145,16 +144,18 @@ export const SettingsServerlessFunctionDetail = () => {
return ( return (
formValues.name && ( formValues.name && (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconFunction}
title={
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: `${formValues.name}` },
]}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: `${formValues.name}` },
]}
/>
</SettingsHeaderContainer>
<Section> <Section>
<TabList tabListId={TAB_LIST_COMPONENT_ID} tabs={tabs} /> <TabList tabListId={TAB_LIST_COMPONENT_ID} tabs={tabs} />
</Section> </Section>

View File

@ -1,31 +1,36 @@
import { IconPlus, IconSettings } from 'twenty-ui';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable'; import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink'; import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { IconFunction, IconPlus } from 'twenty-ui';
export const SettingsServerlessFunctions = () => { export const SettingsServerlessFunctions = () => {
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconFunction}
title="Functions"
actionButton={
<UndecoratedLink
to={getSettingsPagePath(SettingsPath.NewServerlessFunction)}
>
<Button
Icon={IconPlus}
title="New Function"
accent="blue"
size="small"
/>
</UndecoratedLink>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer> <SettingsHeaderContainer>
<Breadcrumb links={[{ children: 'Functions' }]} /> <Breadcrumb links={[{ children: 'Functions' }]} />
<UndecoratedLink
to={getSettingsPagePath(SettingsPath.NewServerlessFunction)}
>
<Button
Icon={IconPlus}
title="New Function"
accent="blue"
size="small"
/>
</UndecoratedLink>
</SettingsHeaderContainer> </SettingsHeaderContainer>
<Section> <Section>
<SettingsServerlessFunctionsTable /> <SettingsServerlessFunctionsTable />

View File

@ -1,19 +1,18 @@
import { IconSettings } from 'twenty-ui';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction';
import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor';
import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm'; import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { isDefined } from '~/utils/isDefined'; import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction';
import { useState } from 'react'; import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor';
import { useState } from 'react';
import { IconFunction } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
export const SettingsServerlessFunctionsNew = () => { export const SettingsServerlessFunctionsNew = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -54,23 +53,27 @@ export const SettingsServerlessFunctionsNew = () => {
const canSave = !!formValues.name && createOneServerlessFunction; const canSave = !!formValues.name && createOneServerlessFunction;
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer
Icon={IconFunction}
title={
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: 'New' },
]}
/>
}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/functions');
}}
onSave={handleSave}
/>
}
>
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/functions');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<SettingsServerlessFunctionNewForm <SettingsServerlessFunctionNewForm
formValues={formValues} formValues={formValues}
onChange={onChange} onChange={onChange}

View File

@ -49,10 +49,6 @@ export const getPageTitleFromPath = (pathname: string): string => {
return 'Create Workspace'; return 'Create Workspace';
case AppPath.CreateProfile: case AppPath.CreateProfile:
return 'Create Profile'; return 'Create Profile';
case AppPath.TasksPage:
return 'Tasks';
case AppPath.OpportunitiesPage:
return 'Opportunities';
case SettingsPathPrefixes.Appearance: case SettingsPathPrefixes.Appearance:
return SettingsPageTitles.Appearance; return SettingsPageTitles.Appearance;
case SettingsPathPrefixes.Accounts: case SettingsPathPrefixes.Accounts:

View File

@ -104,8 +104,5 @@ export default defineConfig(({ command, mode }) => {
localsConvention: 'camelCaseOnly', localsConvention: 'camelCaseOnly',
}, },
}, },
optimizeDeps: {
exclude: ['@tabler/icons-react'],
},
}; };
}); });

View File

@ -11,7 +11,8 @@
"command:prod": "node dist/src/command/command", "command:prod": "node dist/src/command/command",
"worker:prod": "node dist/src/queue-worker/queue-worker", "worker:prod": "node dist/src/queue-worker/queue-worker",
"database:init:prod": "npx ts-node ./scripts/setup-db.ts && yarn database:migrate:prod", "database:init:prod": "npx ts-node ./scripts/setup-db.ts && yarn database:migrate:prod",
"database:migrate:prod": "npx -y typeorm migration:run -d dist/src/database/typeorm/metadata/metadata.datasource && npx -y typeorm migration:run -d dist/src/database/typeorm/core/core.datasource" "database:migrate:prod": "npx -y typeorm migration:run -d dist/src/database/typeorm/metadata/metadata.datasource && npx -y typeorm migration:run -d dist/src/database/typeorm/core/core.datasource",
"typeorm": "../../node_modules/typeorm/.bin/typeorm"
}, },
"dependencies": { "dependencies": {
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga-nestjs-npm-2.1.0-cb509e6047.patch", "@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga-nestjs-npm-2.1.0-cb509e6047.patch",

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSoftDelete1723038077987 implements MigrationInterface {
name = 'AddSoftDelete1723038077987';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" ADD "isSoftDeletable" boolean`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "isSoftDeletable"`,
);
}
}

View File

@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { stringifyWithoutKeyQuote } from 'src/engine/api/graphql/workspace-query-builder/utils/stringify-without-key-quote.util'; import { stringifyWithoutKeyQuote } from 'src/engine/api/graphql/workspace-query-builder/utils/stringify-without-key-quote.util';
import { isDefined } from 'src/utils/is-defined';
import { ArgsAliasFactory } from './args-alias.factory'; import { ArgsAliasFactory } from './args-alias.factory';
@ -13,10 +14,18 @@ export class ArgsStringFactory {
create( create(
initialArgs: Record<string, any> | undefined, initialArgs: Record<string, any> | undefined,
fieldMetadataCollection: FieldMetadataInterface[], fieldMetadataCollection: FieldMetadataInterface[],
softDeletable?: boolean,
): string | null { ): string | null {
if (!initialArgs) { if (!initialArgs) {
return null; return null;
} }
if (softDeletable) {
initialArgs.filter = {
and: [initialArgs.filter, { deletedAt: { is: 'NULL' } }].filter(
isDefined,
),
};
}
let argsString = ''; let argsString = '';
const computedArgs = this.argsAliasFactory.create( const computedArgs = this.argsAliasFactory.create(
initialArgs, initialArgs,

View File

@ -22,19 +22,23 @@ export class FieldsStringFactory {
private readonly relationFieldAliasFactory: RelationFieldAliasFactory, private readonly relationFieldAliasFactory: RelationFieldAliasFactory,
) {} ) {}
create( async create(
info: GraphQLResolveInfo, info: GraphQLResolveInfo,
fieldMetadataCollection: FieldMetadataInterface[], fieldMetadataCollection: FieldMetadataInterface[],
objectMetadataCollection: ObjectMetadataInterface[], objectMetadataCollection: ObjectMetadataInterface[],
withSoftDeleted?: boolean,
): Promise<string> { ): Promise<string> {
const selectedFields: Partial<Record> = graphqlFields(info); const selectedFields: Partial<Record> = graphqlFields(info);
return this.createFieldsStringRecursive( const res = await this.createFieldsStringRecursive(
info, info,
selectedFields, selectedFields,
fieldMetadataCollection, fieldMetadataCollection,
objectMetadataCollection, objectMetadataCollection,
withSoftDeleted ?? false,
); );
return res;
} }
async createFieldsStringRecursive( async createFieldsStringRecursive(
@ -42,6 +46,7 @@ export class FieldsStringFactory {
selectedFields: Partial<Record>, selectedFields: Partial<Record>,
fieldMetadataCollection: FieldMetadataInterface[], fieldMetadataCollection: FieldMetadataInterface[],
objectMetadataCollection: ObjectMetadataInterface[], objectMetadataCollection: ObjectMetadataInterface[],
withSoftDeleted: boolean,
accumulator = '', accumulator = '',
): Promise<string> { ): Promise<string> {
const fieldMetadataMap = new Map( const fieldMetadataMap = new Map(
@ -65,6 +70,7 @@ export class FieldsStringFactory {
fieldMetadata, fieldMetadata,
objectMetadataCollection, objectMetadataCollection,
info, info,
withSoftDeleted,
); );
fieldAlias = alias; fieldAlias = alias;
@ -91,6 +97,7 @@ export class FieldsStringFactory {
fieldValue, fieldValue,
fieldMetadataCollection, fieldMetadataCollection,
objectMetadataCollection, objectMetadataCollection,
withSoftDeleted,
accumulator, accumulator,
); );
accumulator += `}\n`; accumulator += `}\n`;

View File

@ -36,6 +36,7 @@ export class FindManyQueryFactory {
const argsString = this.argsStringFactory.create( const argsString = this.argsStringFactory.create(
args, args,
options.fieldMetadataCollection, options.fieldMetadataCollection,
!options.withSoftDeleted && !!options.objectMetadataItem.isSoftDeletable,
); );
return ` return `

View File

@ -26,10 +26,12 @@ export class FindOneQueryFactory {
options.info, options.info,
options.fieldMetadataCollection, options.fieldMetadataCollection,
options.objectMetadataCollection, options.objectMetadataCollection,
options.withSoftDeleted,
); );
const argsString = this.argsStringFactory.create( const argsString = this.argsStringFactory.create(
args, args,
options.fieldMetadataCollection, options.fieldMetadataCollection,
!options.withSoftDeleted && !!options.objectMetadataItem.isSoftDeletable,
); );
return ` return `

View File

@ -12,7 +12,6 @@ import {
RelationDirection, RelationDirection,
} from 'src/engine/utils/deduce-relation-direction.util'; } from 'src/engine/utils/deduce-relation-direction.util';
import { getFieldArgumentsByKey } from 'src/engine/api/graphql/workspace-query-builder/utils/get-field-arguments-by-key.util'; import { getFieldArgumentsByKey } from 'src/engine/api/graphql/workspace-query-builder/utils/get-field-arguments-by-key.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
@ -27,7 +26,6 @@ export class RelationFieldAliasFactory {
@Inject(forwardRef(() => FieldsStringFactory)) @Inject(forwardRef(() => FieldsStringFactory))
private readonly fieldsStringFactory: CircularDep<FieldsStringFactory>, private readonly fieldsStringFactory: CircularDep<FieldsStringFactory>,
private readonly argsStringFactory: ArgsStringFactory, private readonly argsStringFactory: ArgsStringFactory,
private readonly objectMetadataService: ObjectMetadataService,
) {} ) {}
create( create(
@ -36,6 +34,7 @@ export class RelationFieldAliasFactory {
fieldMetadata: FieldMetadataInterface, fieldMetadata: FieldMetadataInterface,
objectMetadataCollection: ObjectMetadataInterface[], objectMetadataCollection: ObjectMetadataInterface[],
info: GraphQLResolveInfo, info: GraphQLResolveInfo,
withSoftDeleted?: boolean,
): Promise<string> { ): Promise<string> {
if (!isRelationFieldMetadataType(fieldMetadata.type)) { if (!isRelationFieldMetadataType(fieldMetadata.type)) {
throw new Error(`Field ${fieldMetadata.name} is not a relation field`); throw new Error(`Field ${fieldMetadata.name} is not a relation field`);
@ -47,6 +46,7 @@ export class RelationFieldAliasFactory {
fieldMetadata, fieldMetadata,
objectMetadataCollection, objectMetadataCollection,
info, info,
withSoftDeleted,
); );
} }
@ -56,6 +56,7 @@ export class RelationFieldAliasFactory {
fieldMetadata: FieldMetadataInterface, fieldMetadata: FieldMetadataInterface,
objectMetadataCollection: ObjectMetadataInterface[], objectMetadataCollection: ObjectMetadataInterface[],
info: GraphQLResolveInfo, info: GraphQLResolveInfo,
withSoftDeleted?: boolean,
): Promise<string> { ): Promise<string> {
const relationMetadata = const relationMetadata =
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata; fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
@ -98,9 +99,11 @@ export class RelationFieldAliasFactory {
relationDirection === RelationDirection.FROM relationDirection === RelationDirection.FROM
) { ) {
const args = getFieldArgumentsByKey(info, fieldKey); const args = getFieldArgumentsByKey(info, fieldKey);
const argsString = this.argsStringFactory.create( const argsString = this.argsStringFactory.create(
args, args,
referencedObjectMetadata.fields ?? [], referencedObjectMetadata.fields ?? [],
!withSoftDeleted && !!referencedObjectMetadata.isSoftDeletable,
); );
const fieldsString = const fieldsString =
await this.fieldsStringFactory.createFieldsStringRecursive( await this.fieldsStringFactory.createFieldsStringRecursive(
@ -108,6 +111,7 @@ export class RelationFieldAliasFactory {
fieldValue, fieldValue,
referencedObjectMetadata.fields ?? [], referencedObjectMetadata.fields ?? [],
objectMetadataCollection, objectMetadataCollection,
withSoftDeleted ?? false,
); );
return ` return `
@ -137,6 +141,7 @@ export class RelationFieldAliasFactory {
fieldValue, fieldValue,
referencedObjectMetadata.fields ?? [], referencedObjectMetadata.fields ?? [],
objectMetadataCollection, objectMetadataCollection,
withSoftDeleted ?? false,
); );
// Otherwise it means it's a relation destination is of kind ONE // Otherwise it means it's a relation destination is of kind ONE

View File

@ -3,6 +3,7 @@ export interface Record {
[key: string]: any; [key: string]: any;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null;
} }
export type RecordFilter = { export type RecordFilter = {

View File

@ -8,4 +8,5 @@ export interface WorkspaceQueryBuilderOptions {
info: GraphQLResolveInfo; info: GraphQLResolveInfo;
fieldMetadataCollection: FieldMetadataInterface[]; fieldMetadataCollection: FieldMetadataInterface[];
objectMetadataCollection: ObjectMetadataInterface[]; objectMetadataCollection: ObjectMetadataInterface[];
withSoftDeleted?: boolean;
} }

View File

@ -35,11 +35,10 @@ export class EntityEventsToDbListener {
return this.handle(payload); return this.handle(payload);
} }
// @OnEvent('*.deleted') - TODO: implement when we soft delete has been implemented @OnEvent('*.deleted')
// .... async handleDelete(payload: ObjectRecordUpdateEvent<any>) {
return this.handle(payload);
// @OnEvent('*.restored') - TODO: implement when we soft delete has been implemented }
// ....
private async handle(payload: ObjectRecordBaseEvent) { private async handle(payload: ObjectRecordBaseEvent) {
if (!payload.objectMetadata?.isAuditLogged) { if (!payload.objectMetadata?.isAuditLogged) {

View File

@ -0,0 +1,29 @@
import { RecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { isDefined } from 'src/utils/is-defined';
export const withSoftDeleted = <T extends RecordFilter>(
filter: T | undefined | null,
): boolean => {
if (!isDefined(filter)) {
return false;
}
if (Array.isArray(filter)) {
return filter.some((item) => withSoftDeleted(item));
}
for (const [key, value] of Object.entries(filter)) {
if (key === 'deletedAt') {
return true;
}
if (typeof value === 'object' && value !== null) {
if (withSoftDeleted(value)) {
return true;
}
}
}
return false;
};

View File

@ -3,9 +3,11 @@ import {
CreateOneResolverArgs, CreateOneResolverArgs,
DeleteManyResolverArgs, DeleteManyResolverArgs,
DeleteOneResolverArgs, DeleteOneResolverArgs,
DestroyManyResolverArgs,
FindDuplicatesResolverArgs, FindDuplicatesResolverArgs,
FindManyResolverArgs, FindManyResolverArgs,
FindOneResolverArgs, FindOneResolverArgs,
RestoreManyResolverArgs,
UpdateManyResolverArgs, UpdateManyResolverArgs,
UpdateOneResolverArgs, UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
@ -33,4 +35,8 @@ export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
? UpdateOneResolverArgs ? UpdateOneResolverArgs
: T extends 'findDuplicates' : T extends 'findDuplicates'
? FindDuplicatesResolverArgs ? FindDuplicatesResolverArgs
: never; : T extends 'restoreMany'
? RestoreManyResolverArgs
: T extends 'destroyMany'
? DestroyManyResolverArgs
: never;

View File

@ -15,10 +15,12 @@ import {
CreateOneResolverArgs, CreateOneResolverArgs,
DeleteManyResolverArgs, DeleteManyResolverArgs,
DeleteOneResolverArgs, DeleteOneResolverArgs,
DestroyManyResolverArgs,
FindDuplicatesResolverArgs, FindDuplicatesResolverArgs,
FindManyResolverArgs, FindManyResolverArgs,
FindOneResolverArgs, FindOneResolverArgs,
ResolverArgsType, ResolverArgsType,
RestoreManyResolverArgs,
UpdateManyResolverArgs, UpdateManyResolverArgs,
UpdateOneResolverArgs, UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
@ -34,6 +36,7 @@ import {
} from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook-jobs.job'; } from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook-jobs.job';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { parseResult } from 'src/engine/api/graphql/workspace-query-runner/utils/parse-result.util'; import { parseResult } from 'src/engine/api/graphql/workspace-query-runner/utils/parse-result.util';
import { withSoftDeleted } from 'src/engine/api/graphql/workspace-query-runner/utils/with-soft-deleted.util';
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
import { import {
WorkspaceQueryRunnerException, WorkspaceQueryRunnerException,
@ -108,7 +111,10 @@ export class WorkspaceQueryRunnerService {
const query = await this.workspaceQueryBuilderFactory.findMany( const query = await this.workspaceQueryBuilderFactory.findMany(
computedArgs, computedArgs,
options, {
...options,
withSoftDeleted: withSoftDeleted(args.filter),
},
); );
const result = await this.execute(query, authContext.workspace.id); const result = await this.execute(query, authContext.workspace.id);
@ -159,7 +165,10 @@ export class WorkspaceQueryRunnerService {
const query = await this.workspaceQueryBuilderFactory.findOne( const query = await this.workspaceQueryBuilderFactory.findOne(
computedArgs, computedArgs,
options, {
...options,
withSoftDeleted: withSoftDeleted(args.filter),
},
); );
const result = await this.execute(query, authContext.workspace.id); const result = await this.execute(query, authContext.workspace.id);
@ -540,6 +549,7 @@ export class WorkspaceQueryRunnerService {
options: WorkspaceQueryRunnerOptions, options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> { ): Promise<Record[] | undefined> {
const { authContext, objectMetadataItem } = options; const { authContext, objectMetadataItem } = options;
let query: string;
assertMutationNotOnRemoteObject(objectMetadataItem); assertMutationNotOnRemoteObject(objectMetadataItem);
@ -555,13 +565,25 @@ export class WorkspaceQueryRunnerService {
args, args,
); );
const query = await this.workspaceQueryBuilderFactory.deleteMany( if (objectMetadataItem.isSoftDeletable) {
hookedArgs, query = await this.workspaceQueryBuilderFactory.updateMany(
{ {
filter: hookedArgs.filter,
data: {
deletedAt: new Date().toISOString(),
},
},
{
...options,
atMost: maximumRecordAffected,
},
);
} else {
query = await this.workspaceQueryBuilderFactory.deleteMany(hookedArgs, {
...options, ...options,
atMost: maximumRecordAffected, atMost: maximumRecordAffected,
}, });
); }
const result = await this.execute(query, authContext.workspace.id); const result = await this.execute(query, authContext.workspace.id);
@ -569,7 +591,7 @@ export class WorkspaceQueryRunnerService {
await this.parseResult<PGGraphQLMutation<Record>>( await this.parseResult<PGGraphQLMutation<Record>>(
result, result,
objectMetadataItem, objectMetadataItem,
'deleteFrom', objectMetadataItem.isSoftDeletable ? 'update' : 'deleteFrom',
authContext.workspace.id, authContext.workspace.id,
) )
)?.records; )?.records;
@ -596,6 +618,148 @@ export class WorkspaceQueryRunnerService {
return parsedResults; return parsedResults;
} }
async destroyMany<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: DestroyManyResolverArgs<Filter>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { authContext, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
if (!objectMetadataItem.isSoftDeletable) {
throw new WorkspaceQueryRunnerException(
'This method is reserved to objects that can be soft-deleted, use delete instead',
WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND,
);
}
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_AFFECTED_RECORDS',
);
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'destroyMany',
args,
);
const query = await this.workspaceQueryBuilderFactory.deleteMany(
{
filter: {
...hookedArgs.filter,
deletedAt: { is: 'NOT_NULL' },
},
},
{
...options,
atMost: maximumRecordAffected,
},
);
const result = await this.execute(query, authContext.workspace.id);
const parsedResults = (
await this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'deleteFrom',
authContext.workspace.id,
)
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.delete,
options,
);
return parsedResults;
}
async restoreMany<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: RestoreManyResolverArgs<Filter>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { authContext, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
if (!objectMetadataItem.isSoftDeletable) {
throw new WorkspaceQueryRunnerException(
'This method is reserved to objects that can be soft-deleted',
WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND,
);
}
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_AFFECTED_RECORDS',
);
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'restoreMany',
args,
);
const query = await this.workspaceQueryBuilderFactory.updateMany(
{
filter: {
...hookedArgs.filter,
deletedAt: { is: 'NOT_NULL' },
},
data: {
deletedAt: null,
},
},
{
...options,
atMost: maximumRecordAffected,
},
);
const result = await this.execute(query, authContext.workspace.id);
const parsedResults = (
await this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'update',
authContext.workspace.id,
)
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.delete,
options,
);
parsedResults.forEach((record) => {
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.created`, {
name: `${objectMetadataItem.nameSingular}.created`,
workspaceId: authContext.workspace.id,
userId: authContext.user?.id,
recordId: record.id,
objectMetadata: objectMetadataItem,
properties: {
after: this.removeNestedProperties(record),
},
} satisfies ObjectRecordCreateEvent<any>);
});
return parsedResults;
}
async deleteOne<Record extends IRecord = IRecord>( async deleteOne<Record extends IRecord = IRecord>(
args: DeleteOneResolverArgs, args: DeleteOneResolverArgs,
options: WorkspaceQueryRunnerOptions, options: WorkspaceQueryRunnerOptions,
@ -606,6 +770,7 @@ export class WorkspaceQueryRunnerService {
authContext.workspace.id, authContext.workspace.id,
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
); );
let query: string;
assertMutationNotOnRemoteObject(objectMetadataItem); assertMutationNotOnRemoteObject(objectMetadataItem);
assertIsValidUuid(args.id); assertIsValidUuid(args.id);
@ -618,10 +783,22 @@ export class WorkspaceQueryRunnerService {
args, args,
); );
const query = await this.workspaceQueryBuilderFactory.deleteOne( if (objectMetadataItem.isSoftDeletable) {
hookedArgs, query = await this.workspaceQueryBuilderFactory.updateOne(
options, {
); id: hookedArgs.id,
data: {
deletedAt: new Date().toISOString(),
},
},
options,
);
} else {
query = await this.workspaceQueryBuilderFactory.deleteOne(
hookedArgs,
options,
);
}
const existingRecord = await repository.findOne({ const existingRecord = await repository.findOne({
where: { id: args.id }, where: { id: args.id },
@ -633,7 +810,7 @@ export class WorkspaceQueryRunnerService {
await this.parseResult<PGGraphQLMutation<Record>>( await this.parseResult<PGGraphQLMutation<Record>>(
result, result,
objectMetadataItem, objectMetadataItem,
'deleteFrom', objectMetadataItem.isSoftDeletable ? 'update' : 'deleteFrom',
authContext.workspace.id, authContext.workspace.id,
) )
)?.records; )?.records;

View File

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
DestroyManyResolverArgs,
Resolver,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class DestroyManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'destroyMany' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<DestroyManyResolverArgs> {
const internalContext = context;
return async (_source, args, context, info) => {
try {
return await this.workspaceQueryRunnerService.destroyMany(args, {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
};
}
}

View File

@ -1,3 +1,5 @@
import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory';
import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { CreateManyResolverFactory } from './create-many-resolver.factory'; import { CreateManyResolverFactory } from './create-many-resolver.factory';
@ -19,6 +21,8 @@ export const workspaceResolverBuilderFactories = [
DeleteOneResolverFactory, DeleteOneResolverFactory,
UpdateManyResolverFactory, UpdateManyResolverFactory,
DeleteManyResolverFactory, DeleteManyResolverFactory,
DestroyManyResolverFactory,
RestoreManyResolverFactory,
]; ];
export const workspaceResolverBuilderMethodNames = { export const workspaceResolverBuilderMethodNames = {
@ -34,5 +38,7 @@ export const workspaceResolverBuilderMethodNames = {
DeleteOneResolverFactory.methodName, DeleteOneResolverFactory.methodName,
UpdateManyResolverFactory.methodName, UpdateManyResolverFactory.methodName,
DeleteManyResolverFactory.methodName, DeleteManyResolverFactory.methodName,
DestroyManyResolverFactory.methodName,
RestoreManyResolverFactory.methodName,
], ],
} as const; } as const;

View File

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
Resolver,
RestoreManyResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class RestoreManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'restoreMany' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<RestoreManyResolverArgs> {
const internalContext = context;
return async (_source, args, context, info) => {
try {
return await this.workspaceQueryRunnerService.restoreMany(args, {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
};
}
}

View File

@ -20,6 +20,8 @@ export enum ResolverArgsType {
UpdateMany = 'UpdateMany', UpdateMany = 'UpdateMany',
DeleteOne = 'DeleteOne', DeleteOne = 'DeleteOne',
DeleteMany = 'DeleteMany', DeleteMany = 'DeleteMany',
RestoreMany = 'RestoreMany',
DestroyMany = 'DestroyMany',
} }
export interface FindManyResolverArgs< export interface FindManyResolverArgs<
@ -82,6 +84,14 @@ export interface DeleteManyResolverArgs<Filter = any> {
filter: Filter; filter: Filter;
} }
export interface RestoreManyResolverArgs<Filter = any> {
filter: Filter;
}
export interface DestroyManyResolverArgs<Filter = any> {
filter: Filter;
}
export type WorkspaceResolverBuilderQueryMethodNames = export type WorkspaceResolverBuilderQueryMethodNames =
(typeof workspaceResolverBuilderMethodNames.queries)[number]; (typeof workspaceResolverBuilderMethodNames.queries)[number];
@ -106,4 +116,6 @@ export type ResolverArgs =
| FindOneResolverArgs | FindOneResolverArgs
| FindDuplicatesResolverArgs | FindDuplicatesResolverArgs
| UpdateManyResolverArgs | UpdateManyResolverArgs
| UpdateOneResolverArgs; | UpdateOneResolverArgs
| DestroyManyResolverArgs
| RestoreManyResolverArgs;

View File

@ -5,6 +5,8 @@ import { IResolvers } from '@graphql-tools/utils';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory'; import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory';
import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory';
import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
@ -36,6 +38,8 @@ export class WorkspaceResolverFactory {
private readonly deleteOneResolverFactory: DeleteOneResolverFactory, private readonly deleteOneResolverFactory: DeleteOneResolverFactory,
private readonly updateManyResolverFactory: UpdateManyResolverFactory, private readonly updateManyResolverFactory: UpdateManyResolverFactory,
private readonly deleteManyResolverFactory: DeleteManyResolverFactory, private readonly deleteManyResolverFactory: DeleteManyResolverFactory,
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
) {} ) {}
async create( async create(
@ -56,6 +60,8 @@ export class WorkspaceResolverFactory {
['deleteOne', this.deleteOneResolverFactory], ['deleteOne', this.deleteOneResolverFactory],
['updateMany', this.updateManyResolverFactory], ['updateMany', this.updateManyResolverFactory],
['deleteMany', this.deleteManyResolverFactory], ['deleteMany', this.deleteManyResolverFactory],
['restoreMany', this.restoreManyResolverFactory],
['destroyMany', this.destroyManyResolverFactory],
]); ]);
const resolvers: IResolvers = { const resolvers: IResolvers = {
Query: {}, Query: {},

View File

@ -2,14 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
import { GraphQLFieldConfigMap, GraphQLObjectType } from 'graphql'; import { GraphQLFieldConfigMap, GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { TypeDefinitionsStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
import { getResolverArgs } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { getResolverArgs } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
import { ArgsFactory } from './args.factory'; import { ArgsFactory } from './args.factory';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory'; import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
@ -101,13 +101,17 @@ export class RootTypeFactory {
); );
} }
const allowedMethodNames = [
'updateMany',
'deleteMany',
'createMany',
'findDuplicates',
'restoreMany',
'destroyMany',
];
const outputType = this.typeMapperService.mapToGqlType(objectType, { const outputType = this.typeMapperService.mapToGqlType(objectType, {
isArray: [ isArray: allowedMethodNames.includes(methodName),
'updateMany',
'deleteMany',
'createMany',
'findDuplicates',
].includes(methodName),
}); });
fieldConfigMap[name] = { fieldConfigMap[name] = {

View File

@ -50,6 +50,12 @@ describe('getResolverArgs', () => {
deleteOne: { deleteOne: {
id: { type: GraphQLID, isNullable: false }, id: { type: GraphQLID, isNullable: false },
}, },
restoreMany: {
filter: { kind: InputTypeDefinitionKind.Filter, isNullable: false },
},
destroyMany: {
filter: { kind: InputTypeDefinitionKind.Filter, isNullable: false },
},
}; };
// Test each resolver type // Test each resolver type

View File

@ -116,6 +116,20 @@ export const getResolverArgs = (
isNullable: false, isNullable: false,
}, },
}; };
case 'restoreMany':
return {
filter: {
kind: InputTypeDefinitionKind.Filter,
isNullable: false,
},
};
case 'destroyMany':
return {
filter: {
kind: InputTypeDefinitionKind.Filter,
isNullable: false,
},
};
default: default:
throw new Error(`Unknown resolver type: ${type}`); throw new Error(`Unknown resolver type: ${type}`);
} }

View File

@ -20,4 +20,5 @@ export interface ObjectMetadataInterface {
isAuditLogged: boolean; isAuditLogged: boolean;
labelIdentifierFieldMetadataId?: string | null; labelIdentifierFieldMetadataId?: string | null;
imageIdentifierFieldMetadataId?: string | null; imageIdentifierFieldMetadataId?: string | null;
isSoftDeletable?: boolean | null;
} }

View File

@ -69,6 +69,9 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface {
@Column({ default: true }) @Column({ default: true })
isAuditLogged: boolean; isAuditLogged: boolean;
@Column({ nullable: true, type: 'boolean' })
isSoftDeletable?: boolean | null;
@Column({ nullable: true, type: 'uuid' }) @Column({ nullable: true, type: 'uuid' })
labelIdentifierFieldMetadataId?: string | null; labelIdentifierFieldMetadataId?: string | null;

View File

@ -1,6 +1,7 @@
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIsPimaryField } from 'src/engine/twenty-orm/decorators/workspace-is-primary-field.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsPrimaryField } from 'src/engine/twenty-orm/decorators/workspace-is-primary-field.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { BASE_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { BASE_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
@ -13,7 +14,7 @@ export abstract class BaseWorkspaceEntity {
defaultValue: 'uuid', defaultValue: 'uuid',
icon: 'Icon123', icon: 'Icon123',
}) })
@WorkspaceIsPimaryField() @WorkspaceIsPrimaryField()
@WorkspaceIsSystem() @WorkspaceIsSystem()
id: string; id: string;
@ -25,7 +26,7 @@ export abstract class BaseWorkspaceEntity {
icon: 'IconCalendar', icon: 'IconCalendar',
defaultValue: 'now', defaultValue: 'now',
}) })
createdAt: Date; createdAt: string;
@WorkspaceField({ @WorkspaceField({
standardId: BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt, standardId: BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt,
@ -35,5 +36,15 @@ export abstract class BaseWorkspaceEntity {
icon: 'IconCalendarClock', icon: 'IconCalendarClock',
defaultValue: 'now', defaultValue: 'now',
}) })
updatedAt: Date; updatedAt: string;
@WorkspaceField({
standardId: BASE_OBJECT_STANDARD_FIELD_IDS.deletedAt,
type: FieldMetadataType.DATE_TIME,
label: 'Deleted at',
description: 'Date when the record was deleted',
icon: 'IconCalendarMinus',
})
@WorkspaceIsNullable()
deletedAt?: string | null;
} }

View File

@ -8,7 +8,7 @@ import {
RelationOnDeleteAction, RelationOnDeleteAction,
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceCustomObject } from 'src/engine/twenty-orm/decorators/workspace-custom-object.decorator'; import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
@ -21,7 +21,9 @@ import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/not
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity'; import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
@WorkspaceCustomObject() @WorkspaceCustomEntity({
softDelete: true,
})
export class CustomWorkspaceEntity extends BaseWorkspaceEntity { export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceField({ @WorkspaceField({
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name, standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,

View File

@ -1,7 +1,13 @@
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { TypedReflect } from 'src/utils/typed-reflect'; import { TypedReflect } from 'src/utils/typed-reflect';
export function WorkspaceCustomObject(): ClassDecorator { interface WorkspaceCustomEntityOptions {
softDelete?: boolean;
}
export function WorkspaceCustomEntity(
options: WorkspaceCustomEntityOptions = {},
): ClassDecorator {
return (target) => { return (target) => {
const gate = TypedReflect.getMetadata( const gate = TypedReflect.getMetadata(
'workspace:gate-metadata-args', 'workspace:gate-metadata-args',
@ -11,6 +17,7 @@ export function WorkspaceCustomObject(): ClassDecorator {
metadataArgsStorage.addExtendedEntities({ metadataArgsStorage.addExtendedEntities({
target, target,
gate, gate,
softDelete: options.softDelete,
}); });
}; };
} }

View File

@ -12,6 +12,7 @@ interface WorkspaceEntityOptions {
icon?: string; icon?: string;
labelIdentifierStandardId?: string; labelIdentifierStandardId?: string;
imageIdentifierStandardId?: string; imageIdentifierStandardId?: string;
softDelete?: boolean;
} }
export function WorkspaceEntity( export function WorkspaceEntity(
@ -47,6 +48,7 @@ export function WorkspaceEntity(
isAuditLogged, isAuditLogged,
isSystem, isSystem,
gate, gate,
softDelete: options.softDelete,
}); });
}; };
} }

View File

@ -1,6 +1,6 @@
import { TypedReflect } from 'src/utils/typed-reflect'; import { TypedReflect } from 'src/utils/typed-reflect';
export function WorkspaceIsPimaryField(): PropertyDecorator { export function WorkspaceIsPrimaryField(): PropertyDecorator {
return (object, propertyKey) => { return (object, propertyKey) => {
TypedReflect.defineMetadata( TypedReflect.defineMetadata(
'workspace:is-primary-field-metadata-args', 'workspace:is-primary-field-metadata-args',

Some files were not shown because too many files have changed in this diff Show More