[permissions] QA fixes (#13698)

In this PR

- Introduction of readableFields and updatableFields in
objectMetadataItem selector to ease filtering from a developer
experience perspective ( + to help developers think to do it). In
discussion @lucasbordeau @charlesBochet
- Remove non-updatable field from CSV import process (@etiennejouan)
- QA fix / Non-readable fields should not show on show page
- QA fix / It should not be offered to create a kanban view on a
non-readable field
- QA fix / It should not be offered to create view groups on a
non-readable field
- QA fix / Rating field should have a readonly mode

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Marie
2025-08-07 18:21:59 +02:00
committed by GitHub
parent 258f21a4be
commit ee19ee5119
120 changed files with 1211 additions and 943 deletions
@@ -109,97 +109,6 @@ export const queries = {
}
}
`,
getCurrentUser: gql`
query GetCurrentUser {
currentUser {
...UserQueryFragment
}
}
fragment UserQueryFragment on User {
id
firstName
lastName
email
canAccessFullAdminPanel
canImpersonate
supportUserHash
onboardingStatus
workspaceMember {
...WorkspaceMemberQueryFragment
}
workspaceMembers {
...WorkspaceMemberQueryFragment
}
currentUserWorkspace {
permissionFlags
objectRecordsPermissions
}
currentWorkspace {
id
displayName
logo
inviteHash
allowImpersonation
activationStatus
isPublicInviteLinkEnabled
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
subdomain
hasValidEnterpriseKey
customDomain
workspaceUrls {
...WorkspaceUrlsFragment
}
featureFlags {
id
key
value
workspaceId
}
metadataVersion
currentBillingSubscription {
id
status
interval
}
billingSubscriptions {
id
status
}
workspaceMembersCount
}
workspaces {
workspace {
id
logo
displayName
subdomain
customDomain
workspaceUrls {
...WorkspaceUrlsFragment
}
}
}
userVars
}
fragment WorkspaceMemberQueryFragment on WorkspaceMember {
id
name {
firstName
lastName
}
colorScheme
avatarUrl
locale
userEmail
timeZone
dateFormat
timeFormat
}
`,
};
export const objectMetadataId = '25611fce-6637-4089-b0ca-91afeec95784';
@@ -14,6 +14,8 @@ import {
query as findManyObjectMetadataItemsQuery,
responseData as findManyObjectMetadataItemsResponseData,
} from '../__mocks__/useFindManyObjectMetadataItems';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { mockedUserData } from '~/testing/mock-data/users';
const mocks = [
{
@@ -27,6 +29,17 @@ const mocks = [
},
})),
},
{
request: {
query: GET_CURRENT_USER,
variables: {},
},
result: jest.fn(() => ({
data: {
currentUser: mockedUserData,
},
})),
},
{
request: {
query: findManyObjectMetadataItemsQuery,
@@ -11,10 +11,12 @@ import {
variables,
} from '../__mocks__/useDeleteOneObjectMetadataItem';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import {
query as findManyObjectMetadataItemsQuery,
responseData as findManyObjectMetadataItemsResponseData,
} from '../__mocks__/useFindManyObjectMetadataItems';
import { mockedUserData } from '~/testing/mock-data/users';
const mocks = [
{
@@ -28,6 +30,17 @@ const mocks = [
},
})),
},
{
request: {
query: GET_CURRENT_USER,
variables: {},
},
result: jest.fn(() => ({
data: {
currentUser: mockedUserData,
},
})),
},
{
request: {
query: findManyObjectMetadataItemsQuery,
@@ -15,6 +15,8 @@ import {
variables,
} from '../__mocks__/useFieldMetadataItem';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { mockedUserData } from '~/testing/mock-data/users';
import {
query as findManyObjectMetadataItemsQuery,
responseData as findManyObjectMetadataItemsResponseData,
@@ -97,6 +99,17 @@ const mocks = [
},
})),
},
{
request: {
query: GET_CURRENT_USER,
variables: {},
},
result: jest.fn(() => ({
data: {
currentUser: mockedUserData,
},
})),
},
{
request: {
query: queries.deleteMetadataField,
@@ -130,15 +143,6 @@ const mocks = [
},
})),
},
{
request: {
query: queries.getCurrentUser,
variables: {},
},
result: jest.fn(() => ({
data: responseData.getCurrentUser,
})),
},
{
request: {
query: findManyObjectMetadataItemsQuery,
@@ -9,7 +9,7 @@ export const useActiveFieldMetadataItems = ({
const activeFieldMetadataItems = useMemo(
() =>
objectMetadataItem
? objectMetadataItem.fields.filter(
? objectMetadataItem.readableFields.filter(
({ isActive, isSystem }) => isActive && !isSystem,
)
: [],
@@ -5,17 +5,13 @@ import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailab
import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { getReadRestrictedFieldMetadataIdsFromObjectPermissions } from '@/object-metadata/utils/getReadRestrictedFieldMetadataIdsFromObjectPermissions';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated/graphql';
import { formatFieldMetadataItemAsColumnDefinition } from '../utils/formatFieldMetadataItemAsColumnDefinition';
export const useColumnDefinitionsFromFieldMetadata = (
objectMetadataItem: ObjectMetadataItem,
) => {
const activeFieldMetadataItems = objectMetadataItem.fields.filter(
const activeFieldMetadataItems = objectMetadataItem.readableFields.filter(
({ isActive, isSystem }) => isActive && !isSystem,
);
@@ -31,23 +27,7 @@ export const useColumnDefinitionsFromFieldMetadata = (
}),
);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
let restrictedFieldMetadataIds: string[] = [];
const featureFlags = useFeatureFlagsMap();
const isFieldsPermissionsEnabled =
featureFlags[FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED];
if (isFieldsPermissionsEnabled) {
restrictedFieldMetadataIds =
getReadRestrictedFieldMetadataIdsFromObjectPermissions({
objectPermissions: [
objectPermissionsByObjectMetadataId[objectMetadataItem.id],
],
objectMetadataId: objectMetadataItem.id,
});
}
const restrictedFieldMetadataIds: string[] = [];
const columnDefinitions: ColumnDefinition<FieldMetadata>[] =
activeFieldMetadataItems
@@ -8,7 +8,7 @@ import {
import { CREATE_ONE_FIELD_METADATA_ITEM } from '../graphql/mutations';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { useRefreshCachedViews } from '@/views/hooks/useRefreshViews';
export const useCreateOneFieldMetadataItem = () => {
@@ -8,7 +8,7 @@ import {
import { CREATE_ONE_OBJECT_METADATA_ITEM } from '../graphql/mutations';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { useRefreshCachedViews } from '@/views/hooks/useRefreshViews';
export const useCreateOneObjectMetadataItem = () => {
@@ -6,7 +6,7 @@ import {
} from '~/generated-metadata/graphql';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecoilState } from 'recoil';
@@ -7,7 +7,7 @@ import {
import { DELETE_ONE_OBJECT_METADATA_ITEM } from '../graphql/mutations';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
export const useDeleteOneObjectMetadataItem = () => {
const [mutate] = useMutation<
@@ -8,6 +8,8 @@ import {
} from '~/generated-metadata/graphql';
import { logError } from '~/utils/logError';
import { enrichObjectMetadataItemsWithPermissions } from '@/object-metadata/utils/enrichObjectMetadataItemsWithPermissions';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '../graphql/queries';
import { mapPaginatedObjectMetadataItemsToObjectMetadataItems } from '../utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems';
@@ -18,6 +20,8 @@ export const useFindManyObjectMetadataItems = ({
} = {}) => {
const { enqueueErrorSnackBar } = useSnackBar();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { data, loading, error, refetch } = useQuery<
ObjectMetadataItemsQuery,
ObjectMetadataItemsQueryVariables
@@ -32,10 +36,16 @@ export const useFindManyObjectMetadataItems = ({
});
const objectMetadataItems = useMemo(() => {
return mapPaginatedObjectMetadataItemsToObjectMetadataItems({
pagedObjectMetadataItems: data,
const objectMetadataItemsArray =
mapPaginatedObjectMetadataItemsToObjectMetadataItems({
pagedObjectMetadataItems: data,
});
return enrichObjectMetadataItemsWithPermissions({
objectMetadataItems: objectMetadataItemsArray,
objectPermissionsByObjectMetadataId,
});
}, [data]);
}, [data, objectPermissionsByObjectMetadataId]);
return {
objectMetadataItems,
@@ -1,53 +0,0 @@
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapPaginatedObjectMetadataItemsToObjectMetadataItems } from '@/object-metadata/utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems';
import { FetchPolicy, useApolloClient } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { ObjectMetadataItemsQuery } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useRefreshObjectMetadataItems = (
fetchPolicy: FetchPolicy = 'network-only',
) => {
const client = useApolloClient();
const refreshObjectMetadataItems = async () => {
const result = await client.query<ObjectMetadataItemsQuery>({
query: FIND_MANY_OBJECT_METADATA_ITEMS,
variables: {},
fetchPolicy,
});
const objectMetadataItems =
mapPaginatedObjectMetadataItemsToObjectMetadataItems({
pagedObjectMetadataItems: result.data,
});
replaceObjectMetadataItemIfDifferent(objectMetadataItems);
return objectMetadataItems;
};
const replaceObjectMetadataItemIfDifferent = useRecoilCallback(
({ set, snapshot }) =>
(toSetObjectMetadataItems: ObjectMetadataItem[]) => {
if (
!isDeeplyEqual(
snapshot.getLoadable(objectMetadataItemsState).getValue(),
toSetObjectMetadataItems,
) &&
toSetObjectMetadataItems.length > 0
) {
set(objectMetadataItemsState, toSetObjectMetadataItems);
set(isAppWaitingForFreshObjectMetadataState, false);
}
},
[],
);
return {
refreshObjectMetadataItems,
};
};
@@ -0,0 +1,89 @@
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { enrichObjectMetadataItemsWithPermissions } from '@/object-metadata/utils/enrichObjectMetadataItemsWithPermissions';
import { mapPaginatedObjectMetadataItemsToObjectMetadataItems } from '@/object-metadata/utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems';
import { FetchPolicy, useApolloClient } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ObjectMetadataItemsQuery } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useRefreshObjectMetadataItems = (
fetchPolicy: FetchPolicy = 'network-only',
) => {
const client = useApolloClient();
const refreshObjectMetadataItems = async () => {
const objectMetadataItemsResult =
await client.query<ObjectMetadataItemsQuery>({
query: FIND_MANY_OBJECT_METADATA_ITEMS,
variables: {},
fetchPolicy,
});
const objectMetadataItems =
mapPaginatedObjectMetadataItemsToObjectMetadataItems({
pagedObjectMetadataItems: objectMetadataItemsResult.data,
});
return replaceObjectMetadataItemIfDifferent(objectMetadataItems);
};
const replaceObjectMetadataItemIfDifferent = useRecoilCallback(
({ set, snapshot }) =>
(
toSetObjectMetadataItems: Omit<
ObjectMetadataItem,
'readableFields' | 'updatableFields'
>[],
) => {
const currentUserWorkspace = snapshot
.getLoadable(currentUserWorkspaceState)
.getValue();
if (!isDefined(currentUserWorkspace)) {
return;
}
const objectPermissionsByObjectMetadataId =
currentUserWorkspace.objectPermissions.reduce(
(acc, objectPermission) => {
acc[objectPermission.objectMetadataId] = objectPermission;
return acc;
},
{} as Record<
string,
ObjectPermissions & { objectMetadataId: string }
>,
);
const newObjectMetadataItems = enrichObjectMetadataItemsWithPermissions(
{
objectMetadataItems: toSetObjectMetadataItems,
objectPermissionsByObjectMetadataId,
},
);
if (
!isDeeplyEqual(
snapshot.getLoadable(objectMetadataItemsState).getValue(),
newObjectMetadataItems,
) &&
newObjectMetadataItems.length > 0
) {
set(objectMetadataItemsState, newObjectMetadataItems);
set(isAppWaitingForFreshObjectMetadataState, false);
}
return newObjectMetadataItems;
},
[],
);
return {
refreshObjectMetadataItems,
};
};
@@ -9,7 +9,7 @@ import { UPDATE_ONE_FIELD_METADATA_ITEM } from '../graphql/mutations';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
@@ -8,7 +8,7 @@ import {
import { UPDATE_ONE_OBJECT_METADATA_ITEM } from '../graphql/mutations';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
// TODO: Slice the Apollo store synchronously in the update function instead of subscribing, so we can use update after read in the same function call
export const useUpdateOneObjectMetadataItem = () => {