Fix front data model edition + non nullable workspaceCustom application migration (#16016)
# Introduction Two things: - Enforcing non nullable workspace custom application Id for any workspace - Fixing front non editable data models following https://github.com/twentyhq/twenty/pull/15911 that associate any custom entities to an applicationId. The front was putting everything as readonly when under an app ( we will have to handle the twenty standard application in the future too ) ## Fallback ### Migration The non nullable migration will fail when released, that's why it's being swallowed and re-run in an upgrade command post workspace custom application creation for those that miss one. Allowing the migration to pass in the end The typeorm migration still need to exists for any new workspaces ### GetCurrentUser In order to dynamically display isReadOnly in data model settings we're fetching the workspaceCustomApplicationId through the `getCurrentUser` If not fallback this endpoint would throw until we're handling existing workspaces that do not have a custom workspace application The fallback should be removed post release
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { ApolloError, gql, InMemoryCache } from '@apollo/client';
|
||||
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
|
||||
|
||||
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
|
||||
import { WorkspaceActivationStatus } from '~/generated/graphql';
|
||||
import { ApolloFactory, type Options } from '../apollo.factory';
|
||||
|
||||
@@ -62,6 +63,8 @@ const mockWorkspace = {
|
||||
isTwoFactorAuthenticationEnforced: false,
|
||||
trashRetentionDays: 14,
|
||||
routerModel: 'auto',
|
||||
workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK,
|
||||
workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
|
||||
};
|
||||
|
||||
const createMockOptions = (): Options<any> => ({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
import { type Role, type Workspace } from '~/generated/graphql';
|
||||
import {
|
||||
type Application,
|
||||
type Role,
|
||||
type Workspace,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export type CurrentWorkspace = Pick<
|
||||
Workspace,
|
||||
@@ -32,6 +36,7 @@ export type CurrentWorkspace = Pick<
|
||||
| 'editableProfileFields'
|
||||
> & {
|
||||
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
|
||||
workspaceCustomApplication: Pick<Application, 'id'> | null;
|
||||
};
|
||||
|
||||
export const currentWorkspaceState = createState<CurrentWorkspace | null>({
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type Application } from '~/generated/graphql';
|
||||
|
||||
export const CUSTOM_WORKSPACE_APPLICATION_MOCK = {
|
||||
id: 'dc75f982-35a2-4c1b-a63d-bd1131215377',
|
||||
agents: [],
|
||||
applicationVariables: [],
|
||||
canBeUninstalled: false,
|
||||
description: 'workpace custom application',
|
||||
name: 'custom',
|
||||
objects: [],
|
||||
serverlessFunctions: [],
|
||||
universalIdentifier: '66a698b6-f6c1-4d35-a6e7-20aeadc3cd95',
|
||||
version: '1.0.0',
|
||||
} as const satisfies Application;
|
||||
+4
@@ -1,6 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
|
||||
import { useColumnDefinitionsFromObjectMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import {
|
||||
@@ -17,6 +18,9 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
contextStoreCurrentObjectMetadataNameSingular: 'company',
|
||||
onInitializeRecoilSnapshot: ({ set }) => {
|
||||
set(currentWorkspaceState, {
|
||||
workspaceCustomApplication: {
|
||||
id: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
|
||||
},
|
||||
id: '1',
|
||||
featureFlags: [],
|
||||
allowImpersonation: false,
|
||||
|
||||
+15
@@ -12,6 +12,7 @@ describe('isObjectMetadataSettingsReadOnly', () => {
|
||||
isUIReadOnly: false,
|
||||
isRemote: false,
|
||||
},
|
||||
workspaceCustomApplicationId: 'workspaceApplicationId',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
@@ -29,8 +30,22 @@ describe('isObjectMetadataSettingsReadOnly', () => {
|
||||
isUIReadOnly: false,
|
||||
isRemote: false,
|
||||
},
|
||||
workspaceCustomApplicationId: null,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if object is owned by workspace custom application', () => {
|
||||
const result = isObjectMetadataSettingsReadOnly({
|
||||
objectMetadataItem: {
|
||||
isUIReadOnly: false,
|
||||
isRemote: false,
|
||||
applicationId: 'workspaceApplicationId',
|
||||
},
|
||||
workspaceCustomApplicationId: 'workspaceApplicationId',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+9
-3
@@ -1,7 +1,7 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ObjectPermission } from '~/generated/graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type ObjectPermission } from '~/generated/graphql';
|
||||
|
||||
type IsObjectMetadataReadOnlyParams = {
|
||||
objectPermissions?: ObjectPermission;
|
||||
@@ -9,14 +9,20 @@ type IsObjectMetadataReadOnlyParams = {
|
||||
ObjectMetadataItem,
|
||||
'isUIReadOnly' | 'isRemote' | 'applicationId'
|
||||
>;
|
||||
workspaceCustomApplicationId: string | null;
|
||||
};
|
||||
|
||||
export const isObjectMetadataSettingsReadOnly = ({
|
||||
objectPermissions,
|
||||
objectMetadataItem,
|
||||
workspaceCustomApplicationId,
|
||||
}: IsObjectMetadataReadOnlyParams) => {
|
||||
return (
|
||||
isObjectMetadataReadOnly({ objectPermissions, objectMetadataItem }) ||
|
||||
isDefined(objectMetadataItem?.applicationId)
|
||||
(isDefined(objectMetadataItem?.applicationId)
|
||||
? isDefined(workspaceCustomApplicationId)
|
||||
? objectMetadataItem.applicationId !== workspaceCustomApplicationId
|
||||
: true
|
||||
: false)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Tag } from 'twenty-ui/components';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { getItemTagInfo } from '@/settings/data-model/utils/getItemTagInfo';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Tag } from 'twenty-ui/components';
|
||||
|
||||
type SettingsItemTypeTagProps = {
|
||||
item: {
|
||||
@@ -14,7 +16,12 @@ export const SettingsItemTypeTag = ({
|
||||
className,
|
||||
item: { isCustom, isRemote, applicationId },
|
||||
}: SettingsItemTypeTagProps) => {
|
||||
const itemTagInfo = getItemTagInfo({ isCustom, isRemote, applicationId });
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const itemTagInfo = getItemTagInfo({
|
||||
objectMetadataItem: { isCustom, isRemote, applicationId },
|
||||
workspaceCustomApplicationId:
|
||||
currentWorkspace?.workspaceCustomApplication?.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tag
|
||||
|
||||
+5
-1
@@ -1,3 +1,4 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useDeleteOneFieldMetadataItem } from '@/object-metadata/hooks/useDeleteOneFieldMetadataItem';
|
||||
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
|
||||
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
|
||||
@@ -14,7 +15,7 @@ import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMe
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
|
||||
import {
|
||||
getSettingsPath,
|
||||
@@ -66,8 +67,11 @@ export const SettingsObjectFieldItemTableRow = ({
|
||||
const { fieldMetadataItem, identifierType, objectMetadataItem } =
|
||||
settingsObjectDetailTableItem;
|
||||
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const readonly = isObjectMetadataSettingsReadOnly({
|
||||
objectMetadataItem,
|
||||
workspaceCustomApplicationId:
|
||||
currentWorkspace?.workspaceCustomApplication?.id,
|
||||
});
|
||||
|
||||
const isRemoteObjectField = objectMetadataItem.isRemote;
|
||||
|
||||
+8
-2
@@ -1,3 +1,4 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState';
|
||||
@@ -20,7 +21,12 @@ type SettingsUpdateDataModelObjectAboutFormProps = {
|
||||
export const SettingsUpdateDataModelObjectAboutForm = ({
|
||||
objectMetadataItem,
|
||||
}: SettingsUpdateDataModelObjectAboutFormProps) => {
|
||||
const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem });
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const readonly = isObjectMetadataSettingsReadOnly({
|
||||
objectMetadataItem,
|
||||
workspaceCustomApplicationId:
|
||||
currentWorkspace?.workspaceCustomApplication?.id,
|
||||
});
|
||||
const navigate = useNavigateSettings();
|
||||
const setUpdatedObjectNamePlural = useSetRecoilState(
|
||||
updatedObjectNamePluralState,
|
||||
|
||||
+5
@@ -1,3 +1,4 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
@@ -10,6 +11,7 @@ import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
display: flex;
|
||||
@@ -22,8 +24,11 @@ type ObjectFieldsProps = {
|
||||
};
|
||||
|
||||
export const ObjectFields = ({ objectMetadataItem }: ObjectFieldsProps) => {
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const readonly = isObjectMetadataSettingsReadOnly({
|
||||
objectMetadataItem,
|
||||
workspaceCustomApplicationId:
|
||||
currentWorkspace?.workspaceCustomApplication?.id,
|
||||
});
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
+9
-2
@@ -1,16 +1,18 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
|
||||
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
|
||||
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
|
||||
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { H2Title, IconArchive } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
|
||||
|
||||
type ObjectSettingsProps = {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
@@ -28,7 +30,12 @@ const StyledFormSection = styled(Section)`
|
||||
|
||||
export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
|
||||
const { t } = useLingui();
|
||||
const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem });
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const readonly = isObjectMetadataSettingsReadOnly({
|
||||
objectMetadataItem,
|
||||
workspaceCustomApplicationId:
|
||||
currentWorkspace?.workspaceCustomApplication?.id,
|
||||
});
|
||||
const navigate = useNavigateSettings();
|
||||
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
|
||||
|
||||
|
||||
+8
-1
@@ -1,3 +1,4 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
|
||||
@@ -9,6 +10,7 @@ import { t } from '@lingui/core/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isLabelIdentifierFieldMetadataTypes } from 'twenty-shared/utils';
|
||||
import { IconCircleOff, IconPlus, useIcons } from 'twenty-ui/display';
|
||||
import { type SelectOption } from 'twenty-ui/input';
|
||||
@@ -42,7 +44,12 @@ const StyledContainer = styled.div`
|
||||
export const SettingsDataModelObjectIdentifiersForm = ({
|
||||
objectMetadataItem,
|
||||
}: SettingsDataModelObjectIdentifiersFormProps) => {
|
||||
const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem });
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const readonly = isObjectMetadataSettingsReadOnly({
|
||||
objectMetadataItem,
|
||||
workspaceCustomApplicationId:
|
||||
currentWorkspace?.workspaceCustomApplication?.id,
|
||||
});
|
||||
const formConfig = useForm<SettingsDataModelObjectIdentifiersFormValues>({
|
||||
mode: 'onTouched',
|
||||
resolver: zodResolver(settingsDataModelObjectIdentifiersFormSchema),
|
||||
|
||||
@@ -27,15 +27,20 @@ type ManagedItemTagInfo = {
|
||||
};
|
||||
|
||||
export const getItemTagInfo = ({
|
||||
isCustom,
|
||||
isRemote,
|
||||
applicationId,
|
||||
objectMetadataItem: { isCustom, isRemote, applicationId },
|
||||
workspaceCustomApplicationId,
|
||||
}: {
|
||||
isCustom?: boolean;
|
||||
isRemote?: boolean;
|
||||
applicationId?: string | null;
|
||||
objectMetadataItem: {
|
||||
isCustom?: boolean;
|
||||
isRemote?: boolean;
|
||||
applicationId?: string | null;
|
||||
};
|
||||
workspaceCustomApplicationId?: string;
|
||||
}): ItemTagInfo => {
|
||||
if (isDefined(applicationId)) {
|
||||
if (
|
||||
isDefined(applicationId) &&
|
||||
applicationId !== workspaceCustomApplicationId
|
||||
) {
|
||||
return { labelText: 'Managed', labelColor: 'sky' };
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@ export const UserAndViewsProviderEffect = () => {
|
||||
...userQueryData.currentUser.currentWorkspace,
|
||||
defaultRole:
|
||||
userQueryData.currentUser.currentWorkspace.defaultRole ?? null,
|
||||
workspaceCustomApplication:
|
||||
userQueryData.currentUser.currentWorkspace
|
||||
.workspaceCustomApplication ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
isPasswordAuthBypassEnabled
|
||||
subdomain
|
||||
hasValidEnterpriseKey
|
||||
customDomain
|
||||
workspaceCustomApplication {
|
||||
id
|
||||
}
|
||||
isCustomDomainEnabled
|
||||
workspaceUrls {
|
||||
...WorkspaceUrlsFragment
|
||||
|
||||
@@ -107,7 +107,13 @@ export const useLoadCurrentUser = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = user.currentWorkspace ?? null;
|
||||
const workspace = isDefined(user.currentWorkspace)
|
||||
? {
|
||||
...user.currentWorkspace,
|
||||
workspaceCustomApplication:
|
||||
user.currentWorkspace.workspaceCustomApplication ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
setCurrentWorkspace(workspace);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user