Prevent remote object updates (#4804)

Backend: Adding a new util function that throw an error if the
objectMetadata is remote

Frontend: hiding the save button when remote

Also renaming `useObjectMetadataItemForSettings` since this hook is used
in other places than settings and is not in the settings repo. Name can
definitely be challenged!

---------

Co-authored-by: Thomas Trompette <thomast@twenty.com>
This commit is contained in:
Thomas Trompette 2024-04-04 15:47:08 +02:00 committed by GitHub
parent c5349291c8
commit 2e419091cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 79 additions and 40 deletions

View File

@ -1,6 +1,6 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useIcons } from '@/ui/display/icon/hooks/useIcons';
@ -9,7 +9,7 @@ import { GraphQLView } from '@/views/types/GraphQLView';
import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
export const ObjectMetadataNavItems = () => {
const { activeObjectMetadataItems } = useObjectMetadataItemForSettings();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const navigate = useNavigate();
const { getIcon } = useIcons();
const currentPath = useLocation().pathname;

View File

@ -7,8 +7,8 @@ import {
query,
responseData,
variables,
} from '@/object-metadata/hooks/__mocks__/useObjectMetadataItemForSettings';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
} from '@/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
@ -36,14 +36,14 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
const mockObjectMetadataItems = getObjectMetadataItemsMock();
describe('useObjectMetadataItemForSettings', () => {
describe('useFilteredObjectMetadataItems', () => {
it('should findActiveObjectMetadataItemBySlug', async () => {
const { result } = renderHook(
() => {
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(mockObjectMetadataItems);
return useObjectMetadataItemForSettings();
return useFilteredObjectMetadataItems();
},
{
wrapper: Wrapper,
@ -63,7 +63,7 @@ describe('useObjectMetadataItemForSettings', () => {
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(mockObjectMetadataItems);
return useObjectMetadataItemForSettings();
return useFilteredObjectMetadataItems();
},
{
wrapper: Wrapper,
@ -85,7 +85,7 @@ describe('useObjectMetadataItemForSettings', () => {
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(mockObjectMetadataItems);
return useObjectMetadataItemForSettings();
return useFilteredObjectMetadataItems();
},
{
wrapper: Wrapper,

View File

@ -4,7 +4,7 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { getObjectSlug } from '../utils/getObjectSlug';
export const useObjectMetadataItemForSettings = () => {
export const useFilteredObjectMetadataItems = () => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const activeObjectMetadataItems = objectMetadataItems.filter(

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel';
import { useIcons } from '@/ui/display/icon/hooks/useIcons';
@ -58,7 +58,7 @@ export const SettingsObjectFieldRelationForm = ({
}: SettingsObjectFieldRelationFormProps) => {
const { getIcon } = useIcons();
const { objectMetadataItems, findObjectMetadataItemById } =
useObjectMetadataItemForSettings();
useFilteredObjectMetadataItems();
const selectedObjectMetadataItem =
(values.objectMetadataId

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelDefaultValueForm } from '@/settings/data-model/components/SettingsDataModelDefaultValue';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
@ -79,7 +79,7 @@ export const SettingsDataModelFieldSettingsFormCard = ({
relationFieldMetadataItem,
values,
}: SettingsDataModelFieldSettingsFormCardProps) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
const { findObjectMetadataItemById } = useFilteredObjectMetadataItems();
if (!previewableTypes.includes(fieldMetadataItem.type)) return null;

View File

@ -1,7 +1,7 @@
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { useIcons } from '@/ui/display/icon/hooks/useIcons';
import { PageAddButton } from '@/ui/layout/page/PageAddButton';
@ -20,7 +20,10 @@ export const RecordIndexPageHeader = ({
const objectNamePlural = useParams().objectNamePlural ?? '';
const { findObjectMetadataItemByNamePlural } =
useObjectMetadataItemForSettings();
useFilteredObjectMetadataItems();
const objectMetadataItem =
findObjectMetadataItemByNamePlural(objectNamePlural);
const { getIcon } = useIcons();
const Icon = getIcon(
@ -29,12 +32,13 @@ export const RecordIndexPageHeader = ({
const recordIndexViewType = useRecoilValue(recordIndexViewTypeState);
const canAddRecord =
recordIndexViewType === ViewType.Table && !objectMetadataItem?.isRemote;
return (
<PageHeader title={capitalize(objectNamePlural)} Icon={Icon}>
<PageHotkeysEffect onAddButtonClick={createRecord} />
{recordIndexViewType === ViewType.Table && (
<PageAddButton onClick={createRecord} />
)}
{canAddRecord && <PageAddButton onClick={createRecord} />}
</PageHeader>
);
};

View File

@ -5,7 +5,7 @@ import { IconPlus, IconSettings } from 'twenty-ui';
import { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from '@/object-metadata/constants/LabelIdentifierFieldMetadataTypes';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
@ -45,7 +45,7 @@ export const SettingsObjectDetail = () => {
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useObjectMetadataItemForSettings();
useFilteredObjectMetadataItems();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const activeObjectMetadataItem =

View File

@ -6,7 +6,7 @@ import pick from 'lodash.pick';
import { IconArchive, IconSettings } from 'twenty-ui';
import { z } from 'zod';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
@ -44,7 +44,7 @@ export const SettingsObjectEdit = () => {
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useObjectMetadataItemForSettings();
useFilteredObjectMetadataItems();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const activeObjectMetadataItem =

View File

@ -5,8 +5,8 @@ import { isNonEmptyString } from '@sniptt/guards';
import { IconArchive, IconSettings } from 'twenty-ui';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
@ -52,7 +52,7 @@ export const SettingsObjectFieldEdit = () => {
const { objectSlug = '', fieldSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useObjectMetadataItemForSettings();
useFilteredObjectMetadataItems();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);

View File

@ -4,7 +4,7 @@ import styled from '@emotion/styled';
import { IconMinus, IconPlus, IconSettings } from 'twenty-ui';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
@ -39,7 +39,7 @@ export const SettingsObjectNewFieldStep1 = () => {
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useObjectMetadataItemForSettings();
useFilteredObjectMetadataItems();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
@ -114,11 +114,13 @@ export const SettingsObjectNewFieldStep1 = () => {
{ children: 'New Field' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
{!activeObjectMetadataItem.isRemote && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
)}
</SettingsHeaderContainer>
<StyledSection>
<H2Title

View File

@ -7,7 +7,7 @@ import { IconSettings } from 'twenty-ui';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
@ -46,7 +46,7 @@ export const SettingsObjectNewFieldStep2 = () => {
findActiveObjectMetadataItemBySlug,
findObjectMetadataItemById,
findObjectMetadataItemByNamePlural,
} = useObjectMetadataItemForSettings();
} = useFilteredObjectMetadataItems();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
@ -300,11 +300,13 @@ export const SettingsObjectNewFieldStep2 = () => {
{ children: 'New Field' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
{!activeObjectMetadataItem.isRemote && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
)}
</SettingsHeaderContainer>
<SettingsObjectFieldFormSection
iconKey={formValues.icon}

View File

@ -4,7 +4,7 @@ import styled from '@emotion/styled';
import { IconChevronRight, IconPlus, IconSettings } from 'twenty-ui';
import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
@ -39,7 +39,7 @@ export const SettingsObjects = () => {
const navigate = useNavigate();
const { activeObjectMetadataItems, inactiveObjectMetadataItems } =
useObjectMetadataItemForSettings();
useFilteredObjectMetadataItems();
const { deleteOneObjectMetadataItem } = useDeleteOneObjectMetadataItem();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();

View File

@ -46,6 +46,7 @@ import { EnvironmentService } from 'src/engine/integrations/environment/environm
import { NotFoundError } from 'src/engine/utils/graphql-errors.util';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters.factory';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface';
import {
@ -217,6 +218,9 @@ export class WorkspaceQueryRunnerService {
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, userId, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
const computedArgs = await this.queryRunnerArgsFactory.create(
args,
options,
@ -273,6 +277,8 @@ export class WorkspaceQueryRunnerService {
): Promise<Record | undefined> {
const { workspaceId, userId, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
const existingRecord = await this.findOne(
{ filter: { id: { eq: args.id } } } as FindOneResolverArgs,
options,
@ -318,6 +324,9 @@ export class WorkspaceQueryRunnerService {
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_RECORD_AFFECTED',
);
@ -359,6 +368,9 @@ export class WorkspaceQueryRunnerService {
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, userId, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_RECORD_AFFECTED',
);
@ -403,6 +415,9 @@ export class WorkspaceQueryRunnerService {
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
const { workspaceId, userId, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
const query = await this.workspaceQueryBuilderFactory.deleteOne(
args,
options,

View File

@ -38,6 +38,7 @@ import {
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
import { computeCustomName } from 'src/engine/utils/compute-custom-name.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import {
FieldMetadataEntity,
@ -94,6 +95,10 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
throw new NotFoundException('Object does not exist');
}
if (!fieldMetadataInput.isRemoteCreation) {
assertMutationNotOnRemoteObject(objectMetadata);
}
// Double check in case the service is directly called
if (isEnumFieldMetadataType(fieldMetadataInput.type)) {
if (
@ -273,6 +278,8 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
throw new NotFoundException('Object does not exist');
}
assertMutationNotOnRemoteObject(objectMetadata);
if (
objectMetadata.labelIdentifierFieldMetadataId ===
existingFieldMetadata.id &&

View File

@ -0,0 +1,9 @@
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
export const assertMutationNotOnRemoteObject = (
objectMetadataItem: ObjectMetadataInterface,
) => {
if (objectMetadataItem.isRemote) {
throw new Error('Remote objects are read-only');
}
};