mirror of
https://github.com/lingble/twenty.git
synced 2025-11-02 05:37:56 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconCirclePlus, IconEditCircle, useIcons } from 'twenty-ui';
|
||||
import { IconCirclePlus, IconEditCircle, IconTrash, useIcons } from 'twenty-ui';
|
||||
|
||||
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
@@ -19,6 +19,9 @@ export const EventIconDynamicComponent = ({
|
||||
if (eventAction === 'updated') {
|
||||
return <IconEditCircle />;
|
||||
}
|
||||
if (eventAction === 'deleted') {
|
||||
return <IconTrash />;
|
||||
}
|
||||
|
||||
const IconComponent = getIcon(linkedObjectMetadataItem?.icon);
|
||||
|
||||
|
||||
@@ -45,6 +45,17 @@ export const EventRowMainObject = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'deleted': {
|
||||
return (
|
||||
<StyledMainContainer>
|
||||
<StyledEventRowItemColumn>
|
||||
{labelIdentifierValue}
|
||||
</StyledEventRowItemColumn>
|
||||
<StyledEventRowItemAction>was deleted by</StyledEventRowItemAction>
|
||||
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
|
||||
</StyledMainContainer>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import styled from '@emotion/styled';
|
||||
import { Banner, IconComponent } from 'twenty-ui';
|
||||
import { Banner, BannerVariant, IconComponent } from 'twenty-ui';
|
||||
|
||||
const StyledBanner = styled(Banner)`
|
||||
position: absolute;
|
||||
@@ -14,26 +14,30 @@ const StyledText = styled.div`
|
||||
|
||||
export const InformationBanner = ({
|
||||
message,
|
||||
variant = 'default',
|
||||
buttonTitle,
|
||||
buttonIcon,
|
||||
buttonOnClick,
|
||||
}: {
|
||||
message: string;
|
||||
buttonTitle: string;
|
||||
variant?: BannerVariant;
|
||||
buttonTitle?: string;
|
||||
buttonIcon?: IconComponent;
|
||||
buttonOnClick: () => void;
|
||||
buttonOnClick?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<StyledBanner>
|
||||
<StyledBanner variant={variant}>
|
||||
<StyledText>{message}</StyledText>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
Icon={buttonIcon}
|
||||
size="small"
|
||||
inverted
|
||||
onClick={buttonOnClick}
|
||||
/>
|
||||
{buttonTitle && buttonOnClick && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
Icon={buttonIcon}
|
||||
size="small"
|
||||
inverted
|
||||
onClick={buttonOnClick}
|
||||
/>
|
||||
)}
|
||||
</StyledBanner>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconRefresh } from 'twenty-ui';
|
||||
|
||||
const StyledInformationBannerDeletedRecord = styled.div`
|
||||
height: 40px;
|
||||
position: relative;
|
||||
|
||||
&:empty {
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const InformationBannerDeletedRecord = ({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
}: {
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
}) => {
|
||||
const { restoreManyRecords } = useRestoreManyRecords({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledInformationBannerDeletedRecord>
|
||||
<InformationBanner
|
||||
variant="danger"
|
||||
message={`This record has been deleted`}
|
||||
buttonTitle="Restore"
|
||||
buttonIcon={IconRefresh}
|
||||
buttonOnClick={() => restoreManyRecords([recordId])}
|
||||
/>
|
||||
</StyledInformationBannerDeletedRecord>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const INFORMATION_BANNER_HEIGHT = '40px';
|
||||
@@ -13,7 +13,7 @@ import { isDefined } from '~/utils/isDefined';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
type useDeleteOneRecordProps = {
|
||||
type useDeleteManyRecordProps = {
|
||||
objectNameSingular: string;
|
||||
refetchFindManyQuery?: boolean;
|
||||
};
|
||||
@@ -25,7 +25,7 @@ type DeleteManyRecordsOptions = {
|
||||
|
||||
export const useDeleteManyRecords = ({
|
||||
objectNameSingular,
|
||||
}: useDeleteOneRecordProps) => {
|
||||
}: useDeleteManyRecordProps) => {
|
||||
const apiConfig = useRecoilValue(apiConfigState);
|
||||
|
||||
const mutationPageSize =
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
|
||||
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const useDestroyManyRecordsMutation = ({
|
||||
objectNameSingular,
|
||||
}: {
|
||||
objectNameSingular: string;
|
||||
}) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
if (isUndefinedOrNull(objectMetadataItem)) {
|
||||
return { destroyManyRecordsMutation: EMPTY_MUTATION };
|
||||
}
|
||||
|
||||
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
|
||||
|
||||
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
|
||||
objectMetadataItem.namePlural,
|
||||
);
|
||||
|
||||
const destroyManyRecordsMutation = gql`
|
||||
mutation DestroyMany${capitalizedObjectName}($filter: ${capitalize(
|
||||
objectMetadataItem.nameSingular,
|
||||
)}FilterInput!) {
|
||||
${mutationResponseField}(filter: $filter) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return {
|
||||
destroyManyRecordsMutation,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
|
||||
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
|
||||
import { apiConfigState } from '@/client-config/states/apiConfigState';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
|
||||
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
|
||||
import { useDestroyManyRecordsMutation } from '@/object-record/hooks/useDestroyManyRecordMutation';
|
||||
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
type useDestroyManyRecordProps = {
|
||||
objectNameSingular: string;
|
||||
refetchFindManyQuery?: boolean;
|
||||
};
|
||||
|
||||
type DestroyManyRecordsOptions = {
|
||||
skipOptimisticEffect?: boolean;
|
||||
delayInMsBetweenRequests?: number;
|
||||
};
|
||||
|
||||
export const useDestroyManyRecords = ({
|
||||
objectNameSingular,
|
||||
}: useDestroyManyRecordProps) => {
|
||||
const apiConfig = useRecoilValue(apiConfigState);
|
||||
|
||||
const mutationPageSize =
|
||||
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
|
||||
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const getRecordFromCache = useGetRecordFromCache({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const { destroyManyRecordsMutation } = useDestroyManyRecordsMutation({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
|
||||
objectMetadataItem.namePlural,
|
||||
);
|
||||
|
||||
const destroyManyRecords = async (
|
||||
idsToDestroy: string[],
|
||||
options?: DestroyManyRecordsOptions,
|
||||
) => {
|
||||
const numberOfBatches = Math.ceil(idsToDestroy.length / mutationPageSize);
|
||||
|
||||
const destroyedRecords = [];
|
||||
|
||||
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
|
||||
const batchIds = idsToDestroy.slice(
|
||||
batchIndex * mutationPageSize,
|
||||
(batchIndex + 1) * mutationPageSize,
|
||||
);
|
||||
|
||||
const destroyedRecordsResponse = await apolloClient.mutate({
|
||||
mutation: destroyManyRecordsMutation,
|
||||
variables: {
|
||||
filter: { id: { in: batchIds } },
|
||||
},
|
||||
optimisticResponse: options?.skipOptimisticEffect
|
||||
? undefined
|
||||
: {
|
||||
[mutationResponseField]: batchIds.map((idToDestroy) => ({
|
||||
__typename: capitalize(objectNameSingular),
|
||||
id: idToDestroy,
|
||||
})),
|
||||
},
|
||||
update: options?.skipOptimisticEffect
|
||||
? undefined
|
||||
: (cache, { data }) => {
|
||||
const records = data?.[mutationResponseField];
|
||||
|
||||
if (!records?.length) return;
|
||||
|
||||
const cachedRecords = records
|
||||
.map((record) => getRecordFromCache(record.id, cache))
|
||||
.filter(isDefined);
|
||||
|
||||
triggerDeleteRecordsOptimisticEffect({
|
||||
cache,
|
||||
objectMetadataItem,
|
||||
recordsToDelete: cachedRecords,
|
||||
objectMetadataItems,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const destroyedRecordsForThisBatch =
|
||||
destroyedRecordsResponse.data?.[mutationResponseField] ?? [];
|
||||
|
||||
destroyedRecords.push(...destroyedRecordsForThisBatch);
|
||||
|
||||
if (isDefined(options?.delayInMsBetweenRequests)) {
|
||||
await sleep(options.delayInMsBetweenRequests);
|
||||
}
|
||||
}
|
||||
|
||||
return destroyedRecords;
|
||||
};
|
||||
|
||||
return { destroyManyRecords };
|
||||
};
|
||||
@@ -17,11 +17,13 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
|
||||
recordGqlFields,
|
||||
onCompleted,
|
||||
skip,
|
||||
withSoftDeleted = false,
|
||||
}: ObjectMetadataItemIdentifier & {
|
||||
objectRecordId: string | undefined;
|
||||
recordGqlFields?: RecordGqlOperationGqlRecordFields;
|
||||
onCompleted?: (data: T) => void;
|
||||
skip?: boolean;
|
||||
withSoftDeleted?: boolean;
|
||||
}) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
@@ -33,6 +35,7 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
|
||||
const { findOneRecordQuery } = useFindOneRecordQuery({
|
||||
objectNameSingular,
|
||||
recordGqlFields: computedRecordGqlFields,
|
||||
withSoftDeleted,
|
||||
});
|
||||
|
||||
const { data, loading, error } = useQuery<{
|
||||
|
||||
@@ -10,9 +10,11 @@ import { capitalize } from '~/utils/string/capitalize';
|
||||
export const useFindOneRecordQuery = ({
|
||||
objectNameSingular,
|
||||
recordGqlFields,
|
||||
withSoftDeleted = false,
|
||||
}: {
|
||||
objectNameSingular: string;
|
||||
recordGqlFields?: RecordGqlOperationGqlRecordFields;
|
||||
withSoftDeleted?: boolean;
|
||||
}) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
@@ -25,6 +27,16 @@ export const useFindOneRecordQuery = ({
|
||||
objectMetadataItem.nameSingular,
|
||||
)}($objectRecordId: ID!) {
|
||||
${objectMetadataItem.nameSingular}(filter: {
|
||||
${
|
||||
withSoftDeleted
|
||||
? `
|
||||
or: [
|
||||
{ deletedAt: { is: NULL } },
|
||||
{ deletedAt: { is: NOT_NULL } }
|
||||
],
|
||||
`
|
||||
: ''
|
||||
}
|
||||
id: {
|
||||
eq: $objectRecordId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
|
||||
import { apiConfigState } from '@/client-config/states/apiConfigState';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
|
||||
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
|
||||
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
type useRestoreManyRecordProps = {
|
||||
objectNameSingular: string;
|
||||
refetchFindManyQuery?: boolean;
|
||||
};
|
||||
|
||||
type RestoreManyRecordsOptions = {
|
||||
skipOptimisticEffect?: boolean;
|
||||
delayInMsBetweenRequests?: number;
|
||||
};
|
||||
|
||||
export const useRestoreManyRecords = ({
|
||||
objectNameSingular,
|
||||
}: useRestoreManyRecordProps) => {
|
||||
const apiConfig = useRecoilValue(apiConfigState);
|
||||
|
||||
const mutationPageSize =
|
||||
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
|
||||
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const { restoreManyRecordsMutation } = useRestoreManyRecordsMutation({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const mutationResponseField = getRestoreManyRecordsMutationResponseField(
|
||||
objectMetadataItem.namePlural,
|
||||
);
|
||||
|
||||
const restoreManyRecords = async (
|
||||
idsToRestore: string[],
|
||||
options?: RestoreManyRecordsOptions,
|
||||
) => {
|
||||
const numberOfBatches = Math.ceil(idsToRestore.length / mutationPageSize);
|
||||
|
||||
const restoredRecords = [];
|
||||
|
||||
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
|
||||
const batchIds = idsToRestore.slice(
|
||||
batchIndex * mutationPageSize,
|
||||
(batchIndex + 1) * mutationPageSize,
|
||||
);
|
||||
|
||||
// TODO: fix optimistic effect
|
||||
const findOneQueryName = `FindOne${capitalize(objectNameSingular)}`;
|
||||
const findManyQueryName = `FindMany${capitalize(objectMetadataItem.namePlural)}`;
|
||||
|
||||
const restoredRecordsResponse = await apolloClient.mutate({
|
||||
mutation: restoreManyRecordsMutation,
|
||||
refetchQueries: [findOneQueryName, findManyQueryName],
|
||||
variables: {
|
||||
filter: { id: { in: batchIds } },
|
||||
},
|
||||
optimisticResponse: options?.skipOptimisticEffect
|
||||
? undefined
|
||||
: {
|
||||
[mutationResponseField]: batchIds.map((idToRestore) => ({
|
||||
__typename: capitalize(objectNameSingular),
|
||||
id: idToRestore,
|
||||
deletedAt: null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const restoredRecordsForThisBatch =
|
||||
restoredRecordsResponse.data?.[mutationResponseField] ?? [];
|
||||
|
||||
restoredRecords.push(...restoredRecordsForThisBatch);
|
||||
|
||||
if (isDefined(options?.delayInMsBetweenRequests)) {
|
||||
await sleep(options.delayInMsBetweenRequests);
|
||||
}
|
||||
}
|
||||
|
||||
return restoredRecords;
|
||||
};
|
||||
|
||||
return { restoreManyRecords };
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
|
||||
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const useRestoreManyRecordsMutation = ({
|
||||
objectNameSingular,
|
||||
}: {
|
||||
objectNameSingular: string;
|
||||
}) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
if (isUndefinedOrNull(objectMetadataItem)) {
|
||||
return { restoreManyRecordsMutation: EMPTY_MUTATION };
|
||||
}
|
||||
|
||||
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
|
||||
|
||||
const mutationResponseField = getRestoreManyRecordsMutationResponseField(
|
||||
objectMetadataItem.namePlural,
|
||||
);
|
||||
|
||||
const restoreManyRecordsMutation = gql`
|
||||
mutation RestoreMany${capitalizedObjectName}($filter: ${capitalize(
|
||||
objectMetadataItem.nameSingular,
|
||||
)}FilterInput!) {
|
||||
${mutationResponseField}(filter: $filter) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return {
|
||||
restoreManyRecordsMutation,
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { FilterDefinition } from './FilterDefinition';
|
||||
|
||||
export type Filter = {
|
||||
id: string;
|
||||
variant?: 'default' | 'danger';
|
||||
fieldMetadataId: string;
|
||||
value: string;
|
||||
displayValue: string;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
|
||||
import { useCombinedViewFilters } from '@/views/hooks/useCombinedViewFilters';
|
||||
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
type UseHandleToggleTrashColumnFilterProps = {
|
||||
objectNameSingular: string;
|
||||
viewBarId: string;
|
||||
};
|
||||
|
||||
export const useHandleToggleTrashColumnFilter = ({
|
||||
viewBarId,
|
||||
objectNameSingular,
|
||||
}: UseHandleToggleTrashColumnFilterProps) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const { columnDefinitions } =
|
||||
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
|
||||
|
||||
const { upsertCombinedViewFilter } = useCombinedViewFilters(viewBarId);
|
||||
|
||||
const handleToggleTrashColumnFilter = useCallback(() => {
|
||||
const trashFieldMetadata = objectMetadataItem.fields.find(
|
||||
(field) => field.name === 'deletedAt',
|
||||
);
|
||||
|
||||
if (!isDefined(trashFieldMetadata)) return;
|
||||
|
||||
const correspondingColumnDefinition = columnDefinitions.find(
|
||||
(columnDefinition) =>
|
||||
columnDefinition.fieldMetadataId === trashFieldMetadata.id,
|
||||
);
|
||||
|
||||
if (!isDefined(correspondingColumnDefinition)) return;
|
||||
|
||||
const filterType = getFilterTypeFromFieldType(
|
||||
correspondingColumnDefinition?.type,
|
||||
);
|
||||
|
||||
const newFilter: Filter = {
|
||||
id: v4(),
|
||||
variant: 'danger',
|
||||
fieldMetadataId: trashFieldMetadata.id,
|
||||
operand: ViewFilterOperand.IsNotEmpty,
|
||||
displayValue: '',
|
||||
definition: {
|
||||
label: 'Trash',
|
||||
iconName: 'IconTrash',
|
||||
fieldMetadataId: trashFieldMetadata.id,
|
||||
type: filterType,
|
||||
},
|
||||
value: '',
|
||||
};
|
||||
|
||||
upsertCombinedViewFilter(newFilter);
|
||||
}, [columnDefinitions, objectMetadataItem.fields, upsertCombinedViewFilter]);
|
||||
|
||||
return handleToggleTrashColumnFilter;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconFileImport,
|
||||
IconSettings,
|
||||
IconTag,
|
||||
IconTrash,
|
||||
} from 'twenty-ui';
|
||||
|
||||
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
|
||||
@@ -37,6 +38,7 @@ import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
|
||||
import { ViewType } from '@/views/types/ViewType';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
|
||||
|
||||
type RecordIndexOptionsMenu = 'fields' | 'hiddenFields';
|
||||
|
||||
@@ -88,6 +90,11 @@ export const RecordIndexOptionsDropdownContent = ({
|
||||
hiddenTableColumns,
|
||||
} = useRecordIndexOptionsForTable(recordIndexId);
|
||||
|
||||
const handleToggleTrashColumnFilter = useHandleToggleTrashColumnFilter({
|
||||
objectNameSingular,
|
||||
viewBarId: recordIndexId,
|
||||
});
|
||||
|
||||
const {
|
||||
visibleBoardFields,
|
||||
hiddenBoardFields,
|
||||
@@ -153,6 +160,14 @@ export const RecordIndexOptionsDropdownContent = ({
|
||||
LeftIcon={IconFileExport}
|
||||
text={displayedExportProgress(progress)}
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleToggleTrashColumnFilter();
|
||||
closeDropdown();
|
||||
}}
|
||||
LeftIcon={IconTrash}
|
||||
text="Trash"
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
{currentMenu === 'fields' && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
|
||||
import { Note } from '@/activities/types/Note';
|
||||
import { Task } from '@/activities/types/Task';
|
||||
import { InformationBannerDeletedRecord } from '@/information-banner/components/deleted-record/InformationBannerDeletedRecord';
|
||||
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
@@ -126,7 +127,11 @@ export const RecordShowContainer = ({
|
||||
);
|
||||
|
||||
const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy(
|
||||
availableFieldMetadataItems,
|
||||
availableFieldMetadataItems.filter(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.name !== 'createdAt' &&
|
||||
fieldMetadataItem.name !== 'deletedAt',
|
||||
),
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.type === FieldMetadataType.Relation
|
||||
? 'relationFieldMetadataItems'
|
||||
@@ -301,25 +306,33 @@ export const RecordShowContainer = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<ShowPageContainer>
|
||||
<ShowPageLeftContainer forceMobile={isMobile}>
|
||||
{!isMobile && summaryCard}
|
||||
{!isMobile && fieldsBox}
|
||||
</ShowPageLeftContainer>
|
||||
<ShowPageRightContainer
|
||||
targetableObject={{
|
||||
id: objectRecordId,
|
||||
targetObjectNameSingular: objectMetadataItem?.nameSingular,
|
||||
}}
|
||||
timeline
|
||||
tasks
|
||||
notes
|
||||
emails
|
||||
isInRightDrawer={isInRightDrawer}
|
||||
summaryCard={isMobile ? summaryCard : <></>}
|
||||
fieldsBox={fieldsBox}
|
||||
loading={isPrefetchLoading || loading || recordLoading}
|
||||
/>
|
||||
</ShowPageContainer>
|
||||
<>
|
||||
{recordFromStore && recordFromStore.deletedAt && (
|
||||
<InformationBannerDeletedRecord
|
||||
recordId={objectRecordId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
/>
|
||||
)}
|
||||
<ShowPageContainer>
|
||||
<ShowPageLeftContainer forceMobile={isMobile}>
|
||||
{!isMobile && summaryCard}
|
||||
{!isMobile && fieldsBox}
|
||||
</ShowPageLeftContainer>
|
||||
<ShowPageRightContainer
|
||||
targetableObject={{
|
||||
id: objectRecordId,
|
||||
targetObjectNameSingular: objectMetadataItem?.nameSingular,
|
||||
}}
|
||||
timeline
|
||||
tasks
|
||||
notes
|
||||
emails
|
||||
isInRightDrawer={isInRightDrawer}
|
||||
summaryCard={isMobile ? summaryCard : <></>}
|
||||
fieldsBox={fieldsBox}
|
||||
loading={isPrefetchLoading || loading || recordLoading}
|
||||
/>
|
||||
</ShowPageContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export const buildFindOneRecordForShowPageOperationSignature: RecordGqlOperation
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(objectMetadataItem.nameSingular === 'Note'
|
||||
...(objectMetadataItem.nameSingular === CoreObjectNameSingular.Note
|
||||
? {
|
||||
noteTargets: {
|
||||
id: true,
|
||||
|
||||
@@ -50,6 +50,7 @@ export const useRecordShowPage = (
|
||||
objectRecordId,
|
||||
objectNameSingular,
|
||||
recordGqlFields: FIND_ONE_RECORD_FOR_SHOW_PAGE_OPERATION_SIGNATURE.fields,
|
||||
withSoftDeleted: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const getDestroyManyRecordsMutationResponseField = (
|
||||
objectNamePlural: string,
|
||||
) => `destroy${capitalize(objectNamePlural)}`;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const getRestoreManyRecordsMutationResponseField = (
|
||||
objectNamePlural: string,
|
||||
) => `restore${capitalize(objectNamePlural)}`;
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
IconColorSwatch,
|
||||
IconCurrencyDollar,
|
||||
IconDoorEnter,
|
||||
IconFunction,
|
||||
IconHierarchy2,
|
||||
IconMail,
|
||||
IconRocket,
|
||||
IconSettings,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
IconFunction,
|
||||
} from 'twenty-ui';
|
||||
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
@@ -49,7 +49,6 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
path={SettingsPath.Appearance}
|
||||
Icon={IconColorSwatch}
|
||||
/>
|
||||
|
||||
<NavigationDrawerItemGroup>
|
||||
<SettingsNavigationDrawerItem
|
||||
label="Accounts"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
|
||||
const StyledSettingsPageContainer = styled.div<{ width?: number }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21,4 +24,17 @@ const StyledSettingsPageContainer = styled.div<{ width?: number }>`
|
||||
}};
|
||||
`;
|
||||
|
||||
export { StyledSettingsPageContainer as SettingsPageContainer };
|
||||
const StyledScrollWrapper = styled(ScrollWrapper)`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
`;
|
||||
|
||||
export const SettingsPageContainer = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<StyledScrollWrapper>
|
||||
<StyledSettingsPageContainer>{children}</StyledSettingsPageContainer>
|
||||
</StyledScrollWrapper>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconEye } from 'twenty-ui';
|
||||
import { FloatingButton } from '@/ui/input/button/components/FloatingButton';
|
||||
import { Card } from '@/ui/layout/card/components/Card';
|
||||
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import DarkCoverImage from '../assets/cover-dark.png';
|
||||
import LightCoverImage from '../assets/cover-light.png';
|
||||
|
||||
@@ -34,7 +35,7 @@ export const SettingsObjectCoverImage = () => {
|
||||
Icon={IconEye}
|
||||
title="Visualize"
|
||||
size="small"
|
||||
to="/settings/objects/overview"
|
||||
to={'/settings/' + SettingsPath.ObjectOverview}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
</StyledCoverImageContainer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Section } from '@react-email/components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { H2Title } from 'twenty-ui';
|
||||
|
||||
import { useDeleteOneDatabaseConnection } from '@/databases/hooks/useDeleteOneDatabaseConnection';
|
||||
@@ -31,6 +31,7 @@ export const SettingsIntegrationDatabaseConnectionShowContainer = () => {
|
||||
SettingsPath.Integrations,
|
||||
);
|
||||
|
||||
// TODO: move breadcrumb to header?
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Section } from '@react-email/components';
|
||||
import pick from 'lodash.pick';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { H2Title } from 'twenty-ui';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -94,6 +94,7 @@ export const SettingsIntegrationEditDatabaseConnectionContent = ({
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: move breadcrumb to header?
|
||||
return (
|
||||
<>
|
||||
<FormProvider
|
||||
|
||||
@@ -15,7 +15,7 @@ export enum AppPath {
|
||||
|
||||
// Onboarded
|
||||
Index = '/',
|
||||
TasksPage = '/tasks',
|
||||
TasksPage = '/objects/tasks',
|
||||
OpportunitiesPage = '/objects/opportunities',
|
||||
|
||||
RecordIndexPage = '/objects/:objectNamePlural',
|
||||
|
||||
@@ -336,6 +336,7 @@ const StyledButton = styled('button', {
|
||||
flex-direction: row;
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: 500;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import isPropValid from '@emotion/is-prop-valid';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -19,12 +20,11 @@ export type FloatingButtonProps = {
|
||||
to?: string;
|
||||
};
|
||||
|
||||
const shouldForwardProp = (prop: string) =>
|
||||
!['applyBlur', 'applyShadow', 'focus', 'position', 'size', 'to'].includes(
|
||||
prop,
|
||||
);
|
||||
|
||||
const StyledButton = styled('button', { shouldForwardProp })<
|
||||
const StyledButton = styled('button', {
|
||||
shouldForwardProp: (prop) =>
|
||||
!['applyBlur', 'applyShadow', 'focus', 'position', 'size'].includes(prop) &&
|
||||
isPropValid(prop),
|
||||
})<
|
||||
Pick<
|
||||
FloatingButtonProps,
|
||||
| 'size'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
IconChevronDown,
|
||||
@@ -18,7 +18,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
|
||||
export const PAGE_BAR_MIN_HEIGHT = 40;
|
||||
|
||||
const StyledTopBarContainer = styled.div`
|
||||
const StyledTopBarContainer = styled.div<{ width?: number }>`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.noisy};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
@@ -31,6 +31,7 @@ const StyledTopBarContainer = styled.div`
|
||||
padding-left: 0;
|
||||
padding-right: ${({ theme }) => theme.spacing(3)};
|
||||
z-index: 20;
|
||||
width: ${({ width }) => width + 'px' || '100%'};
|
||||
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
padding-left: ${({ theme }) => theme.spacing(3)};
|
||||
@@ -76,8 +77,8 @@ const StyledTopBarButtonContainer = styled.div`
|
||||
margin-right: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
type PageHeaderProps = ComponentProps<'div'> & {
|
||||
title: string;
|
||||
type PageHeaderProps = {
|
||||
title: ReactNode;
|
||||
hasClosePageButton?: boolean;
|
||||
onClosePage?: () => void;
|
||||
hasPaginationButtons?: boolean;
|
||||
@@ -87,6 +88,7 @@ type PageHeaderProps = ComponentProps<'div'> & {
|
||||
navigateToNextRecord?: () => void;
|
||||
Icon: IconComponent;
|
||||
children?: ReactNode;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export const PageHeader = ({
|
||||
@@ -100,13 +102,14 @@ export const PageHeader = ({
|
||||
navigateToNextRecord,
|
||||
Icon,
|
||||
children,
|
||||
width,
|
||||
}: PageHeaderProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const theme = useTheme();
|
||||
const isNavigationDrawerOpen = useRecoilValue(isNavigationDrawerOpenState);
|
||||
|
||||
return (
|
||||
<StyledTopBarContainer>
|
||||
<StyledTopBarContainer width={width}>
|
||||
<StyledLeftContainer>
|
||||
{!isMobile && !isNavigationDrawerOpen && (
|
||||
<StyledTopBarButtonContainer>
|
||||
@@ -143,7 +146,11 @@ export const PageHeader = ({
|
||||
)}
|
||||
{Icon && <Icon size={theme.icon.size.md} />}
|
||||
<StyledTitleContainer data-testid="top-bar-title">
|
||||
<OverflowingTextWithTooltip text={title} />
|
||||
{typeof title === 'string' ? (
|
||||
<OverflowingTextWithTooltip text={title} />
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</StyledTitleContainer>
|
||||
</StyledTopBarIconStyledTitleContainer>
|
||||
</StyledLeftContainer>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
|
||||
const StyledPanel = styled.div`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const PagePanel = ({ children }: { children: React.ReactNode }) => (
|
||||
type PagePanelProps = {
|
||||
children: React.ReactNode;
|
||||
hasInformationBar?: boolean;
|
||||
};
|
||||
|
||||
export const PagePanel = ({ children }: PagePanelProps) => (
|
||||
<StyledPanel>{children}</StyledPanel>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { JSX } from 'react';
|
||||
import { JSX, ReactNode } from 'react';
|
||||
import { IconComponent } from 'twenty-ui';
|
||||
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
|
||||
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
|
||||
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
|
||||
import { PageBody } from './PageBody';
|
||||
import { PageHeader } from './PageHeader';
|
||||
|
||||
type SubMenuTopBarContainerProps = {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
title: string;
|
||||
title: string | ReactNode;
|
||||
actionButton?: ReactNode;
|
||||
Icon: IconComponent;
|
||||
className?: string;
|
||||
};
|
||||
@@ -25,6 +27,7 @@ const StyledContainer = styled.div<{ isMobile: boolean }>`
|
||||
export const SubMenuTopBarContainer = ({
|
||||
children,
|
||||
title,
|
||||
actionButton,
|
||||
Icon,
|
||||
className,
|
||||
}: SubMenuTopBarContainerProps) => {
|
||||
@@ -32,7 +35,13 @@ export const SubMenuTopBarContainer = ({
|
||||
|
||||
return (
|
||||
<StyledContainer isMobile={isMobile} className={className}>
|
||||
{isMobile && <PageHeader title={title} Icon={Icon} />}
|
||||
<PageHeader
|
||||
title={title}
|
||||
Icon={Icon}
|
||||
width={OBJECT_SETTINGS_WIDTH + 4 * 8}
|
||||
>
|
||||
{actionButton}
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<InformationBannerWrapper />
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconDotsVertical, IconTrash } from 'twenty-ui';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { IconDotsVertical, IconRestore, IconTrash } from 'twenty-ui';
|
||||
|
||||
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
||||
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
|
||||
@@ -11,6 +11,9 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
|
||||
import { useDestroyManyRecords } from '@/object-record/hooks/useDestroyManyRecords';
|
||||
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { Dropdown } from '../../dropdown/components/Dropdown';
|
||||
import { DropdownMenu } from '../../dropdown/components/DropdownMenu';
|
||||
|
||||
@@ -32,6 +35,12 @@ export const ShowPageMoreButton = ({
|
||||
const { deleteOneRecord } = useDeleteOneRecord({
|
||||
objectNameSingular,
|
||||
});
|
||||
const { destroyManyRecords } = useDestroyManyRecords({
|
||||
objectNameSingular,
|
||||
});
|
||||
const { restoreManyRecords } = useRestoreManyRecords({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteOneRecord(recordId);
|
||||
@@ -39,6 +48,21 @@ export const ShowPageMoreButton = ({
|
||||
navigate(navigationMemorizedUrl, { replace: true });
|
||||
};
|
||||
|
||||
const handleDestroy = () => {
|
||||
destroyManyRecords([recordId]);
|
||||
closeDropdown();
|
||||
navigate(navigationMemorizedUrl, { replace: true });
|
||||
};
|
||||
|
||||
const handleRestore = () => {
|
||||
restoreManyRecords([recordId]);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const [recordFromStore] = useRecoilState<any>(
|
||||
recordStoreFamilyState(recordId),
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Dropdown
|
||||
@@ -56,12 +80,29 @@ export const ShowPageMoreButton = ({
|
||||
dropdownComponents={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
onClick={handleDelete}
|
||||
accent="danger"
|
||||
LeftIcon={IconTrash}
|
||||
text="Delete"
|
||||
/>
|
||||
{recordFromStore && !recordFromStore.deletedAt && (
|
||||
<MenuItem
|
||||
onClick={handleDelete}
|
||||
accent="danger"
|
||||
LeftIcon={IconTrash}
|
||||
text="Delete"
|
||||
/>
|
||||
)}
|
||||
{recordFromStore && recordFromStore.deletedAt && (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={handleDestroy}
|
||||
accent="danger"
|
||||
LeftIcon={IconTrash}
|
||||
text="Destroy"
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={handleRestore}
|
||||
LeftIcon={IconRestore}
|
||||
text="Restore"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownMenu>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type BreadcrumbProps = {
|
||||
className?: string;
|
||||
@@ -9,10 +9,10 @@ type BreadcrumbProps = {
|
||||
|
||||
const StyledWrapper = styled.nav`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.extraLight};
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
// font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
`;
|
||||
@@ -23,7 +23,7 @@ const StyledLink = styled(Link)`
|
||||
`;
|
||||
|
||||
const StyledText = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
`;
|
||||
|
||||
export const Breadcrumb = ({ className, links }: BreadcrumbProps) => (
|
||||
|
||||
@@ -2,12 +2,37 @@ import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconComponent, IconX } from 'twenty-ui';
|
||||
|
||||
const StyledChip = styled.div`
|
||||
const StyledChip = styled.div<{ variant: SortOrFitlerChipVariant }>`
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.accent.quaternary};
|
||||
border: 1px solid ${({ theme }) => theme.accent.tertiary};
|
||||
background-color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return theme.background.danger;
|
||||
case 'default':
|
||||
default:
|
||||
return theme.accent.quaternary;
|
||||
}
|
||||
}};
|
||||
border: 1px solid
|
||||
${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return theme.border.color.danger;
|
||||
case 'default':
|
||||
default:
|
||||
return theme.accent.tertiary;
|
||||
}
|
||||
}};
|
||||
border-radius: 4px;
|
||||
color: ${({ theme }) => theme.color.blue};
|
||||
color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return theme.color.red;
|
||||
case 'default':
|
||||
default:
|
||||
return theme.color.blue;
|
||||
}
|
||||
}};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -24,7 +49,7 @@ const StyledIcon = styled.div`
|
||||
margin-right: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledDelete = styled.div`
|
||||
const StyledDelete = styled.div<{ variant: SortOrFitlerChipVariant }>`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -33,7 +58,15 @@ const StyledDelete = styled.div`
|
||||
margin-top: 1px;
|
||||
user-select: none;
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.accent.secondary};
|
||||
background-color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return theme.color.red20;
|
||||
case 'default':
|
||||
default:
|
||||
return theme.accent.secondary;
|
||||
}
|
||||
}};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
}
|
||||
`;
|
||||
@@ -42,9 +75,12 @@ const StyledLabelKey = styled.div`
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
type SortOrFitlerChipVariant = 'default' | 'danger';
|
||||
|
||||
type SortOrFilterChipProps = {
|
||||
labelKey?: string;
|
||||
labelValue: string;
|
||||
variant?: SortOrFitlerChipVariant;
|
||||
Icon?: IconComponent;
|
||||
onRemove: () => void;
|
||||
onClick?: () => void;
|
||||
@@ -54,6 +90,7 @@ type SortOrFilterChipProps = {
|
||||
export const SortOrFilterChip = ({
|
||||
labelKey,
|
||||
labelValue,
|
||||
variant = 'default',
|
||||
Icon,
|
||||
onRemove,
|
||||
testId,
|
||||
@@ -67,7 +104,7 @@ export const SortOrFilterChip = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledChip onClick={onClick}>
|
||||
<StyledChip onClick={onClick} variant={variant}>
|
||||
{Icon && (
|
||||
<StyledIcon>
|
||||
<Icon size={theme.icon.size.sm} />
|
||||
@@ -76,6 +113,7 @@ export const SortOrFilterChip = ({
|
||||
{labelKey && <StyledLabelKey>{labelKey}</StyledLabelKey>}
|
||||
{labelValue}
|
||||
<StyledDelete
|
||||
variant={variant}
|
||||
onClick={handleDeleteClick}
|
||||
data-testid={'remove-icon-' + testId}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useIcons } from 'twenty-ui';
|
||||
|
||||
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
|
||||
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
|
||||
import { useCombinedViewFilters } from '@/views/hooks/useCombinedViewFilters';
|
||||
|
||||
type VariantFilterChipProps = {
|
||||
viewFilter: Filter;
|
||||
};
|
||||
|
||||
export const VariantFilterChip = ({ viewFilter }: VariantFilterChipProps) => {
|
||||
const { removeCombinedViewFilter } = useCombinedViewFilters();
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const handleRemoveClick = () => {
|
||||
removeCombinedViewFilter(viewFilter.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<SortOrFilterChip
|
||||
key={viewFilter.fieldMetadataId}
|
||||
testId={viewFilter.fieldMetadataId}
|
||||
variant={viewFilter.variant}
|
||||
labelValue={viewFilter.definition.label}
|
||||
Icon={getIcon(viewFilter.definition.iconName)}
|
||||
onRemove={handleRemoveClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { AddObjectFilterFromDetailsButton } from '@/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton';
|
||||
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
|
||||
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
|
||||
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
|
||||
import { EditableFilterDropdownButton } from '@/views/components/EditableFilterDropdownButton';
|
||||
import { EditableSortChip } from '@/views/components/EditableSortChip';
|
||||
@@ -14,6 +15,7 @@ import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
|
||||
import { useResetCurrentView } from '@/views/hooks/useResetCurrentView';
|
||||
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
|
||||
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
|
||||
import { VariantFilterChip } from './VariantFilterChip';
|
||||
|
||||
export type ViewBarDetailsProps = {
|
||||
hasFilterButton?: boolean;
|
||||
@@ -118,6 +120,29 @@ export const ViewBarDetails = ({
|
||||
const { resetCurrentView } = useResetCurrentView();
|
||||
const canResetView = canPersistView && !hasFiltersQueryParams;
|
||||
|
||||
const { otherViewFilters, defaultViewFilters } = useMemo(() => {
|
||||
if (!currentViewWithCombinedFiltersAndSorts) {
|
||||
return {
|
||||
otherViewFilters: [],
|
||||
defaultViewFilters: [],
|
||||
};
|
||||
}
|
||||
|
||||
const otherViewFilters =
|
||||
currentViewWithCombinedFiltersAndSorts.viewFilters.filter(
|
||||
(viewFilter) => viewFilter.variant && viewFilter.variant !== 'default',
|
||||
);
|
||||
const defaultViewFilters =
|
||||
currentViewWithCombinedFiltersAndSorts.viewFilters.filter(
|
||||
(viewFilter) => !viewFilter.variant || viewFilter.variant === 'default',
|
||||
);
|
||||
|
||||
return {
|
||||
otherViewFilters,
|
||||
defaultViewFilters,
|
||||
};
|
||||
}, [currentViewWithCombinedFiltersAndSorts]);
|
||||
|
||||
const handleCancelClick = () => {
|
||||
resetCurrentView();
|
||||
};
|
||||
@@ -136,6 +161,22 @@ export const ViewBarDetails = ({
|
||||
<StyledBar>
|
||||
<StyledFilterContainer>
|
||||
<StyledChipcontainer>
|
||||
{otherViewFilters.map((viewFilter) => (
|
||||
<VariantFilterChip
|
||||
key={viewFilter.fieldMetadataId}
|
||||
// Why do we have two types, Filter and ViewFilter?
|
||||
// Why key defition is already present in the Filter type and added on the fly here with mapViewFiltersToFilters ?
|
||||
// Also as filter is spread into viewFilter, definition is present
|
||||
// FixMe: Ugly hack to make it work
|
||||
viewFilter={viewFilter as unknown as Filter}
|
||||
/>
|
||||
))}
|
||||
{!!otherViewFilters.length &&
|
||||
!!currentViewWithCombinedFiltersAndSorts?.viewSorts?.length && (
|
||||
<StyledSeperatorContainer>
|
||||
<StyledSeperator />
|
||||
</StyledSeperatorContainer>
|
||||
)}
|
||||
{mapViewSortsToSorts(
|
||||
currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [],
|
||||
availableSortDefinitions,
|
||||
@@ -143,13 +184,13 @@ export const ViewBarDetails = ({
|
||||
<EditableSortChip key={sort.fieldMetadataId} viewSort={sort} />
|
||||
))}
|
||||
{!!currentViewWithCombinedFiltersAndSorts?.viewSorts?.length &&
|
||||
!!currentViewWithCombinedFiltersAndSorts?.viewFilters?.length && (
|
||||
!!defaultViewFilters.length && (
|
||||
<StyledSeperatorContainer>
|
||||
<StyledSeperator />
|
||||
</StyledSeperatorContainer>
|
||||
)}
|
||||
{mapViewFiltersToFilters(
|
||||
currentViewWithCombinedFiltersAndSorts?.viewFilters ?? [],
|
||||
defaultViewFilters,
|
||||
availableFilterDefinitions,
|
||||
).map((viewFilter) => (
|
||||
<ObjectFilterDropdownScope
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ViewFilterOperand } from './ViewFilterOperand';
|
||||
export type ViewFilter = {
|
||||
__typename: 'ViewFilter';
|
||||
id: string;
|
||||
variant?: 'default' | 'danger';
|
||||
fieldMetadataId: string;
|
||||
operand: ViewFilterOperand;
|
||||
value: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -104,8 +104,5 @@ export default defineConfig(({ command, mode }) => {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@tabler/icons-react'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -36,6 +36,7 @@ export class FindManyQueryFactory {
|
||||
const argsString = this.argsStringFactory.create(
|
||||
args,
|
||||
options.fieldMetadataCollection,
|
||||
!options.withSoftDeleted && !!options.objectMetadataItem.isSoftDeletable,
|
||||
);
|
||||
|
||||
return `
|
||||
|
||||
@@ -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 `
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface Record {
|
||||
[key: string]: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export type RecordFilter = {
|
||||
|
||||
@@ -8,4 +8,5 @@ export interface WorkspaceQueryBuilderOptions {
|
||||
info: GraphQLResolveInfo;
|
||||
fieldMetadataCollection: FieldMetadataInterface[];
|
||||
objectMetadataCollection: ObjectMetadataInterface[];
|
||||
withSoftDeleted?: boolean;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -20,4 +20,5 @@ export interface ObjectMetadataInterface {
|
||||
isAuditLogged: boolean;
|
||||
labelIdentifierFieldMetadataId?: string | null;
|
||||
imageIdentifierFieldMetadataId?: string | null;
|
||||
isSoftDeletable?: boolean | null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user