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',
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;

View File

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

View File

@@ -1,5 +1,5 @@
import * as Apollo from '@apollo/client';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
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 */
export enum FieldMetadataType {
Actor = 'ACTOR',
Address = 'ADDRESS',
Boolean = 'BOOLEAN',
Actor = 'ACTOR',
Currency = 'CURRENCY',
Date = 'DATE',
DateTime = 'DATE_TIME',
@@ -344,11 +344,11 @@ export type Mutation = {
generateTransientToken: TransientToken;
impersonate: Verify;
renewToken: AuthTokens;
runWorkflowVersion: WorkflowTriggerResult;
sendInviteLink: SendInviteLink;
signUp: LoginToken;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
track: Analytics;
triggerWorkflow: WorkflowTriggerResult;
updateBillingSubscription: UpdateBillingEntity;
updateOneObject: Object;
updateOneServerlessFunction: ServerlessFunction;
@@ -457,6 +457,11 @@ export type MutationRenewTokenArgs = {
};
export type MutationRunWorkflowVersionArgs = {
input: RunWorkflowVersionInput;
};
export type MutationSendInviteLinkArgs = {
emails: Array<Scalars['String']>;
};
@@ -476,11 +481,6 @@ export type MutationTrackArgs = {
};
export type MutationTriggerWorkflowArgs = {
workflowVersionId: Scalars['String'];
};
export type MutationUpdateOneObjectArgs = {
input: UpdateOneObjectInput;
};
@@ -743,6 +743,13 @@ export enum RemoteTableStatus {
Synced = 'SYNCED'
}
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']>;
/** Workflow version ID */
workflowVersionId: Scalars['String'];
};
export type SendInviteLink = {
__typename?: 'SendInviteLink';
/** Boolean that confirms query was dispatched */
@@ -1087,6 +1094,7 @@ export type WorkspaceFeatureFlagsArgs = {
export enum WorkspaceActivationStatus {
Active = 'ACTIVE',
Inactive = 'INACTIVE',
OngoingCreation = 'ONGOING_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 { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -19,6 +19,9 @@ export const EventIconDynamicComponent = ({
if (eventAction === 'updated') {
return <IconEditCircle />;
}
if (eventAction === 'deleted') {
return <IconTrash />;
}
const IconComponent = getIcon(linkedObjectMetadataItem?.icon);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import styled from '@emotion/styled';
import { H1Title, H2Title, IconSettings } from 'twenty-ui';
import { H2Title, IconSettings } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
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 { Section } from '@/ui/layout/section/components/Section';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsWorkspace = () => (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SubMenuTopBarContainer Icon={IconSettings} title="General">
<SettingsPageContainer>
<StyledH1Title title="General" />
<Section>
<H2Title title="Picture" />
<WorkspaceLogoUploader />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { H2Title, IconSettings } from 'twenty-ui';
import { H2Title, IconHierarchy2 } from 'twenty-ui';
import { z } from 'zod';
import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreateOneObjectMetadataItem';
@@ -70,25 +70,30 @@ export const SettingsNewObject = () => {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<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>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{
children: 'Objects',
href: settingsObjectsPagePath,
},
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
isCancelDisabled={isSubmitting}
onCancel={() => navigate(settingsObjectsPagePath)}
onSave={formConfig.handleSubmit(handleSave)}
/>
</SettingsHeaderContainer>
<SettingsHeaderContainer></SettingsHeaderContainer>
<Section>
<H2Title
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 { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { getDisabledFieldMetadataItems } from '@/object-metadata/utils/getDisabledFieldMetadataItems';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
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 { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import styled from '@emotion/styled';
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';
const StyledDiv = styled.div`
@@ -49,14 +47,18 @@ export const SettingsObjectDetailPageContent = ({
const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SubMenuTopBarContainer
Icon={IconHierarchy2}
title={
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{ children: objectMetadataItem.labelPlural },
]}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title title="About" description="Manage your object" />
<SettingsObjectSummaryCard

View File

@@ -5,12 +5,13 @@ import pick from 'lodash.pick';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
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 { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
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 { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
const objectEditFormSchema = z
.object({})
@@ -108,22 +108,26 @@ export const SettingsObjectEdit = () => {
return (
<RecordFieldValueSelectorContextProvider>
<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>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{
children: 'Objects',
href: settingsObjectsPagePath,
},
{
children: activeObjectMetadataItem.labelPlural,
href: `${settingsObjectsPagePath}/${objectSlug}`,
},
{ children: 'Edit' },
]}
/>
{activeObjectMetadataItem.isCustom && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}

View File

@@ -6,7 +6,7 @@ import pick from 'lodash.pick';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
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 { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
@@ -172,19 +172,23 @@ export const SettingsObjectFieldEdit = () => {
<RecordFieldValueSelectorContextProvider>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<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>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: activeMetadataField.label },
]}
/>
{shouldDisplaySaveAndCancel && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,24 @@
import { DateTime } from 'luxon';
import { useState } from 'react';
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 { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
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 { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
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 { useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
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 = () => {
const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation();
@@ -64,23 +63,27 @@ export const SettingsDevelopersApiKeysNew = () => {
};
const canSave = !!formValues.name && createOneApiKey;
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>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: 'New API Key' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<Section>
<H2Title title="Name" description="Name of your API key" />
<TextInput

View File

@@ -1,7 +1,7 @@
import styled from '@emotion/styled';
import { useState } from 'react';
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 { 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 { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { Button } from '@/ui/input/button/components/Button';
@@ -88,23 +87,27 @@ export const SettingsDevelopersWebhooksDetail = () => {
return (
<>
{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>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Developers', href: '/settings/developers' },
{ children: 'Webhook' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!isDirty}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<Section>
<H2Title
title="Endpoint URL"

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import { z } from 'zod';
import { useCreateOneDatabaseConnection } from '@/databases/hooks/useCreateOneDatabaseConnection';
import { getForeignDataWrapperType } from '@/databases/utils/getForeignDataWrapperType';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
SettingsIntegrationDatabaseConnectionForm,
@@ -132,34 +131,38 @@ export const SettingsIntegrationNewDatabaseConnection = () => {
};
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>
<FormProvider
// eslint-disable-next-line react/jsx-props-no-spreading
{...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>
<H2Title
title="Connect a new database"

View File

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

View File

@@ -1,5 +1,4 @@
import styled from '@emotion/styled';
import { H1Title, H2Title, IconSettings } from 'twenty-ui';
import { H2Title, IconColorSwatch } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
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 { DateTimeSettings } from '~/pages/settings/profile/appearance/components/DateTimeSettings';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsAppearance = () => {
const { colorScheme, setColorScheme } = useColorScheme();
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SubMenuTopBarContainer Icon={IconColorSwatch} title="Appearance">
<SettingsPageContainer>
<StyledH1Title title="Appearance" />
<Section>
<H2Title title="Theme" />
<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 { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
import { useResetRecoilState } from 'recoil';
export const ResetServerlessFunctionStatesEffect = () => {
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 { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab';
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 { TabList } from '@/ui/layout/tab/components/TabList';
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 { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab';
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 { IconCode, IconFunction, IconSettings, IconTestPipe } from 'twenty-ui';
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';
@@ -145,16 +144,18 @@ export const SettingsServerlessFunctionDetail = () => {
return (
formValues.name && (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SubMenuTopBarContainer
Icon={IconFunction}
title={
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: `${formValues.name}` },
]}
/>
}
>
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: `${formValues.name}` },
]}
/>
</SettingsHeaderContainer>
<Section>
<TabList tabListId={TAB_LIST_COMPONENT_ID} tabs={tabs} />
</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 { Section } from '@/ui/layout/section/components/Section';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
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 { IconFunction, IconPlus } from 'twenty-ui';
export const SettingsServerlessFunctions = () => {
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>
<SettingsHeaderContainer>
<Breadcrumb links={[{ children: 'Functions' }]} />
<UndecoratedLink
to={getSettingsPagePath(SettingsPath.NewServerlessFunction)}
>
<Button
Icon={IconPlus}
title="New Function"
accent="blue"
size="small"
/>
</UndecoratedLink>
</SettingsHeaderContainer>
<Section>
<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 { 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 { 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 { isDefined } from '~/utils/isDefined';
import { useState } from 'react';
import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction';
import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
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 = () => {
const navigate = useNavigate();
@@ -54,23 +53,27 @@ export const SettingsServerlessFunctionsNew = () => {
const canSave = !!formValues.name && createOneServerlessFunction;
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>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/functions');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<SettingsServerlessFunctionNewForm
formValues={formValues}
onChange={onChange}

View File

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

View File

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

View File

@@ -11,7 +11,8 @@
"command:prod": "node dist/src/command/command",
"worker:prod": "node dist/src/queue-worker/queue-worker",
"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": {
"@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 { 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';
@@ -13,10 +14,18 @@ export class ArgsStringFactory {
create(
initialArgs: Record<string, any> | undefined,
fieldMetadataCollection: FieldMetadataInterface[],
softDeletable?: boolean,
): string | null {
if (!initialArgs) {
return null;
}
if (softDeletable) {
initialArgs.filter = {
and: [initialArgs.filter, { deletedAt: { is: 'NULL' } }].filter(
isDefined,
),
};
}
let argsString = '';
const computedArgs = this.argsAliasFactory.create(
initialArgs,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,11 +35,10 @@ export class EntityEventsToDbListener {
return this.handle(payload);
}
// @OnEvent('*.deleted') - TODO: implement when we soft delete has been implemented
// ....
// @OnEvent('*.restored') - TODO: implement when we soft delete has been implemented
// ....
@OnEvent('*.deleted')
async handleDelete(payload: ObjectRecordUpdateEvent<any>) {
return this.handle(payload);
}
private async handle(payload: ObjectRecordBaseEvent) {
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,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
DestroyManyResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
RestoreManyResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} 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
: T extends 'findDuplicates'
? FindDuplicatesResolverArgs
: never;
: T extends 'restoreMany'
? RestoreManyResolverArgs
: T extends 'destroyMany'
? DestroyManyResolverArgs
: never;

View File

@@ -15,10 +15,12 @@ import {
CreateOneResolverArgs,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
DestroyManyResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
ResolverArgsType,
RestoreManyResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} 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';
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 { 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 {
WorkspaceQueryRunnerException,
@@ -108,7 +111,10 @@ export class WorkspaceQueryRunnerService {
const query = await this.workspaceQueryBuilderFactory.findMany(
computedArgs,
options,
{
...options,
withSoftDeleted: withSoftDeleted(args.filter),
},
);
const result = await this.execute(query, authContext.workspace.id);
@@ -159,7 +165,10 @@ export class WorkspaceQueryRunnerService {
const query = await this.workspaceQueryBuilderFactory.findOne(
computedArgs,
options,
{
...options,
withSoftDeleted: withSoftDeleted(args.filter),
},
);
const result = await this.execute(query, authContext.workspace.id);
@@ -540,6 +549,7 @@ export class WorkspaceQueryRunnerService {
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { authContext, objectMetadataItem } = options;
let query: string;
assertMutationNotOnRemoteObject(objectMetadataItem);
@@ -555,13 +565,25 @@ export class WorkspaceQueryRunnerService {
args,
);
const query = await this.workspaceQueryBuilderFactory.deleteMany(
hookedArgs,
{
if (objectMetadataItem.isSoftDeletable) {
query = await this.workspaceQueryBuilderFactory.updateMany(
{
filter: hookedArgs.filter,
data: {
deletedAt: new Date().toISOString(),
},
},
{
...options,
atMost: maximumRecordAffected,
},
);
} else {
query = await this.workspaceQueryBuilderFactory.deleteMany(hookedArgs, {
...options,
atMost: maximumRecordAffected,
},
);
});
}
const result = await this.execute(query, authContext.workspace.id);
@@ -569,7 +591,7 @@ export class WorkspaceQueryRunnerService {
await this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'deleteFrom',
objectMetadataItem.isSoftDeletable ? 'update' : 'deleteFrom',
authContext.workspace.id,
)
)?.records;
@@ -596,6 +618,148 @@ export class WorkspaceQueryRunnerService {
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>(
args: DeleteOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
@@ -606,6 +770,7 @@ export class WorkspaceQueryRunnerService {
authContext.workspace.id,
objectMetadataItem.nameSingular,
);
let query: string;
assertMutationNotOnRemoteObject(objectMetadataItem);
assertIsValidUuid(args.id);
@@ -618,10 +783,22 @@ export class WorkspaceQueryRunnerService {
args,
);
const query = await this.workspaceQueryBuilderFactory.deleteOne(
hookedArgs,
options,
);
if (objectMetadataItem.isSoftDeletable) {
query = await this.workspaceQueryBuilderFactory.updateOne(
{
id: hookedArgs.id,
data: {
deletedAt: new Date().toISOString(),
},
},
options,
);
} else {
query = await this.workspaceQueryBuilderFactory.deleteOne(
hookedArgs,
options,
);
}
const existingRecord = await repository.findOne({
where: { id: args.id },
@@ -633,7 +810,7 @@ export class WorkspaceQueryRunnerService {
await this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'deleteFrom',
objectMetadataItem.isSoftDeletable ? 'update' : 'deleteFrom',
authContext.workspace.id,
)
)?.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 { CreateManyResolverFactory } from './create-many-resolver.factory';
@@ -19,6 +21,8 @@ export const workspaceResolverBuilderFactories = [
DeleteOneResolverFactory,
UpdateManyResolverFactory,
DeleteManyResolverFactory,
DestroyManyResolverFactory,
RestoreManyResolverFactory,
];
export const workspaceResolverBuilderMethodNames = {
@@ -34,5 +38,7 @@ export const workspaceResolverBuilderMethodNames = {
DeleteOneResolverFactory.methodName,
UpdateManyResolverFactory.methodName,
DeleteManyResolverFactory.methodName,
DestroyManyResolverFactory.methodName,
RestoreManyResolverFactory.methodName,
],
} 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',
DeleteOne = 'DeleteOne',
DeleteMany = 'DeleteMany',
RestoreMany = 'RestoreMany',
DestroyMany = 'DestroyMany',
}
export interface FindManyResolverArgs<
@@ -82,6 +84,14 @@ export interface DeleteManyResolverArgs<Filter = any> {
filter: Filter;
}
export interface RestoreManyResolverArgs<Filter = any> {
filter: Filter;
}
export interface DestroyManyResolverArgs<Filter = any> {
filter: Filter;
}
export type WorkspaceResolverBuilderQueryMethodNames =
(typeof workspaceResolverBuilderMethodNames.queries)[number];
@@ -106,4 +116,6 @@ export type ResolverArgs =
| FindOneResolverArgs
| FindDuplicatesResolverArgs
| 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 { 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 { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
@@ -36,6 +38,8 @@ export class WorkspaceResolverFactory {
private readonly deleteOneResolverFactory: DeleteOneResolverFactory,
private readonly updateManyResolverFactory: UpdateManyResolverFactory,
private readonly deleteManyResolverFactory: DeleteManyResolverFactory,
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
) {}
async create(
@@ -56,6 +60,8 @@ export class WorkspaceResolverFactory {
['deleteOne', this.deleteOneResolverFactory],
['updateMany', this.updateManyResolverFactory],
['deleteMany', this.deleteManyResolverFactory],
['restoreMany', this.restoreManyResolverFactory],
['destroyMany', this.destroyManyResolverFactory],
]);
const resolvers: IResolvers = {
Query: {},

View File

@@ -2,14 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
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 { 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 { 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 { 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 { 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, {
isArray: [
'updateMany',
'deleteMany',
'createMany',
'findDuplicates',
].includes(methodName),
isArray: allowedMethodNames.includes(methodName),
});
fieldConfigMap[name] = {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
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 { 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',
icon: 'Icon123',
})
@WorkspaceIsPimaryField()
@WorkspaceIsPrimaryField()
@WorkspaceIsSystem()
id: string;
@@ -25,7 +26,7 @@ export abstract class BaseWorkspaceEntity {
icon: 'IconCalendar',
defaultValue: 'now',
})
createdAt: Date;
createdAt: string;
@WorkspaceField({
standardId: BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt,
@@ -35,5 +36,15 @@ export abstract class BaseWorkspaceEntity {
icon: 'IconCalendarClock',
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,
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.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 { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.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 { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
@WorkspaceCustomObject()
@WorkspaceCustomEntity({
softDelete: true,
})
export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceField({
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 { TypedReflect } from 'src/utils/typed-reflect';
export function WorkspaceCustomObject(): ClassDecorator {
interface WorkspaceCustomEntityOptions {
softDelete?: boolean;
}
export function WorkspaceCustomEntity(
options: WorkspaceCustomEntityOptions = {},
): ClassDecorator {
return (target) => {
const gate = TypedReflect.getMetadata(
'workspace:gate-metadata-args',
@@ -11,6 +17,7 @@ export function WorkspaceCustomObject(): ClassDecorator {
metadataArgsStorage.addExtendedEntities({
target,
gate,
softDelete: options.softDelete,
});
};
}

View File

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

View File

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

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