Add object level form to role creation (#12826)

## Context
- Add object-level form to role creation
- Add isSaving props for save button isLoading state
<img width="594" alt="Screenshot 2025-06-24 at 15 03 59"
src="https://github.com/user-attachments/assets/77d9d399-4e1a-4e35-be45-c19100ef06c1"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Weiko
2025-06-24 15:15:37 +02:00
committed by GitHub
parent 540f3ffd67
commit 8cf7649a4c
9 changed files with 190 additions and 162 deletions

View File

@ -3,10 +3,9 @@ import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDr
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { PENDING_ROLE_ID } from '~/pages/settings/roles/SettingsRoleCreate'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles'; import { getRolesMock } from '~/testing/mock-data/roles';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const SettingsRoleAssignmentWrapper = ( const SettingsRoleAssignmentWrapper = (
args: React.ComponentProps<typeof SettingsRoleAssignment>, args: React.ComponentProps<typeof SettingsRoleAssignment>,
@ -41,6 +40,6 @@ export const Default: Story = {
export const PendingRole: Story = { export const PendingRole: Story = {
args: { args: {
roleId: PENDING_ROLE_ID, roleId: 'newRoleId',
}, },
}; };

View File

@ -12,13 +12,11 @@ const StyledRolePermissionsContainer = styled.div`
type SettingsRolePermissionsProps = { type SettingsRolePermissionsProps = {
roleId: string; roleId: string;
isEditable: boolean; isEditable: boolean;
isCreateMode: boolean;
}; };
export const SettingsRolePermissions = ({ export const SettingsRolePermissions = ({
roleId, roleId,
isEditable, isEditable,
isCreateMode,
}: SettingsRolePermissionsProps) => { }: SettingsRolePermissionsProps) => {
return ( return (
<StyledRolePermissionsContainer> <StyledRolePermissionsContainer>
@ -26,12 +24,10 @@ export const SettingsRolePermissions = ({
roleId={roleId} roleId={roleId}
isEditable={isEditable} isEditable={isEditable}
/> />
{!isCreateMode && ( <SettingsRolePermissionsObjectLevelSection
<SettingsRolePermissionsObjectLevelSection roleId={roleId}
roleId={roleId} isEditable={isEditable}
isEditable={isEditable} />
/>
)}
<SettingsRolePermissionsSettingsSection <SettingsRolePermissionsSettingsSection
roleId={roleId} roleId={roleId}
isEditable={isEditable} isEditable={isEditable}

View File

@ -4,7 +4,6 @@ import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { PENDING_ROLE_ID } from '~/pages/settings/roles/SettingsRoleCreate';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles'; import { getRolesMock } from '~/testing/mock-data/roles';
@ -57,7 +56,7 @@ export const ReadOnly: Story = {
export const PendingRole: Story = { export const PendingRole: Story = {
args: { args: {
roleId: PENDING_ROLE_ID, roleId: 'newRoleId',
isEditable: true, isEditable: true,
isCreateMode: true, isCreateMode: true,
}, },

View File

@ -4,7 +4,6 @@ import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { PENDING_ROLE_ID } from '~/pages/settings/roles/SettingsRoleCreate';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles'; import { getRolesMock } from '~/testing/mock-data/roles';
@ -57,7 +56,7 @@ export const ReadOnly: Story = {
export const PendingRole: Story = { export const PendingRole: Story = {
args: { args: {
roleId: PENDING_ROLE_ID, roleId: 'newRoleId',
isEditable: true, isEditable: true,
isCreateMode: false, isCreateMode: false,
}, },

View File

@ -20,10 +20,10 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { getOperationName } from '@apollo/client/utilities'; import { getOperationName } from '@apollo/client/utilities';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display'; import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display';
import { v4 } from 'uuid';
import { import {
Role, Role,
useCreateOneRoleMutation, useCreateOneRoleMutation,
@ -65,6 +65,8 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
const [upsertSettingPermissions] = useUpsertSettingPermissionsMutation(); const [upsertSettingPermissions] = useUpsertSettingPermissionsMutation();
const [upsertObjectPermissions] = useUpsertObjectPermissionsMutation(); const [upsertObjectPermissions] = useUpsertObjectPermissionsMutation();
const [isSaving, setIsSaving] = useState(false);
const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId); const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId);
const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState); const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState);
@ -114,6 +116,8 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
}; };
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true);
const dirtyFields = getDirtyFields( const dirtyFields = getDirtyFields(
settingsDraftRole, settingsDraftRole,
settingsPersistedRole, settingsPersistedRole,
@ -126,151 +130,156 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
return; return;
} }
if (isCreateMode) { try {
const roleId = v4(); if (isCreateMode) {
const { data } = await createRole({
const { data } = await createRole({
variables: {
createRoleInput: {
id: roleId,
label: settingsDraftRole.label,
description: settingsDraftRole.description,
icon: settingsDraftRole.icon,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canReadAllObjectRecords: settingsDraftRole.canReadAllObjectRecords,
canUpdateAllObjectRecords:
settingsDraftRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
if (!data) {
return;
}
if (isDefined(dirtyFields.workspaceMembers)) {
await addWorkspaceMembersToRole({
roleId: data.createOneRole.id,
workspaceMemberIds: settingsDraftRole.workspaceMembers.map(
(member) => member.id,
),
});
}
if (isDefined(dirtyFields.settingPermissions)) {
await upsertSettingPermissions({
variables: { variables: {
upsertSettingPermissionsInput: { createRoleInput: {
roleId: data.createOneRole.id,
settingPermissionKeys:
settingsDraftRole.settingPermissions?.map(
(settingPermission) => settingPermission.setting,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isDefined(dirtyFields.objectPermissions)) {
await upsertObjectPermissions({
variables: {
upsertObjectPermissionsInput: {
roleId: data.createOneRole.id,
objectPermissions:
settingsDraftRole.objectPermissions?.map(
(objectPermission) => ({
objectMetadataId: objectPermission.objectMetadataId,
canReadObjectRecords: objectPermission.canReadObjectRecords,
canUpdateObjectRecords:
objectPermission.canUpdateObjectRecords,
canSoftDeleteObjectRecords:
objectPermission.canSoftDeleteObjectRecords,
canDestroyObjectRecords:
objectPermission.canDestroyObjectRecords,
}),
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
navigateSettings(SettingsPath.RoleDetail, {
roleId: data.createOneRole.id,
});
} else {
if (isDefined(dirtyFields.settingPermissions)) {
await upsertSettingPermissions({
variables: {
upsertSettingPermissionsInput: {
roleId: roleId,
settingPermissionKeys:
settingsDraftRole.settingPermissions?.map(
(settingPermission) => settingPermission.setting,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) {
await updateRole({
variables: {
updateRoleInput: {
id: roleId, id: roleId,
update: { label: settingsDraftRole.label,
label: settingsDraftRole.label, description: settingsDraftRole.description,
description: settingsDraftRole.description, icon: settingsDraftRole.icon,
icon: settingsDraftRole.icon, canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings, canReadAllObjectRecords:
canReadAllObjectRecords: settingsDraftRole.canReadAllObjectRecords,
settingsDraftRole.canReadAllObjectRecords, canUpdateAllObjectRecords:
canUpdateAllObjectRecords: settingsDraftRole.canUpdateAllObjectRecords,
settingsDraftRole.canUpdateAllObjectRecords, canSoftDeleteAllObjectRecords:
canSoftDeleteAllObjectRecords: settingsDraftRole.canSoftDeleteAllObjectRecords,
settingsDraftRole.canSoftDeleteAllObjectRecords, canDestroyAllObjectRecords:
canDestroyAllObjectRecords: settingsDraftRole.canDestroyAllObjectRecords,
settingsDraftRole.canDestroyAllObjectRecords, },
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
if (!data) {
return;
}
if (isDefined(dirtyFields.settingPermissions)) {
await upsertSettingPermissions({
variables: {
upsertSettingPermissionsInput: {
roleId: data.createOneRole.id,
settingPermissionKeys:
settingsDraftRole.settingPermissions?.map(
(settingPermission) => settingPermission.setting,
) ?? [],
}, },
}, },
}, refetchQueries: [getOperationName(GET_ROLES) ?? ''],
refetchQueries: [getOperationName(GET_ROLES) ?? ''], });
}); }
}
if (isDefined(dirtyFields.objectPermissions)) { if (isDefined(dirtyFields.objectPermissions)) {
await upsertObjectPermissions({ await upsertObjectPermissions({
variables: { variables: {
upsertObjectPermissionsInput: { upsertObjectPermissionsInput: {
roleId: roleId, roleId: data.createOneRole.id,
objectPermissions: objectPermissions:
settingsDraftRole.objectPermissions?.map( settingsDraftRole.objectPermissions?.map(
(objectPermission) => ({ (objectPermission) => ({
objectMetadataId: objectPermission.objectMetadataId, objectMetadataId: objectPermission.objectMetadataId,
canReadObjectRecords: objectPermission.canReadObjectRecords, canReadObjectRecords:
canUpdateObjectRecords: objectPermission.canReadObjectRecords,
objectPermission.canUpdateObjectRecords, canUpdateObjectRecords:
canSoftDeleteObjectRecords: objectPermission.canUpdateObjectRecords,
objectPermission.canSoftDeleteObjectRecords, canSoftDeleteObjectRecords:
canDestroyObjectRecords: objectPermission.canSoftDeleteObjectRecords,
objectPermission.canDestroyObjectRecords, canDestroyObjectRecords:
}), objectPermission.canDestroyObjectRecords,
) ?? [], }),
) ?? [],
},
}, },
}, refetchQueries: [getOperationName(GET_ROLES) ?? ''],
refetchQueries: [getOperationName(GET_ROLES) ?? ''], });
}); }
}
}
await loadCurrentUser(); if (isDefined(dirtyFields.workspaceMembers)) {
await addWorkspaceMembersToRole({
roleId: data.createOneRole.id,
workspaceMemberIds: settingsDraftRole.workspaceMembers.map(
(member) => member.id,
),
});
}
navigateSettings(SettingsPath.RoleDetail, {
roleId: data.createOneRole.id,
});
} else {
if (isDefined(dirtyFields.settingPermissions)) {
await upsertSettingPermissions({
variables: {
upsertSettingPermissionsInput: {
roleId: roleId,
settingPermissionKeys:
settingsDraftRole.settingPermissions?.map(
(settingPermission) => settingPermission.setting,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) {
await updateRole({
variables: {
updateRoleInput: {
id: roleId,
update: {
label: settingsDraftRole.label,
description: settingsDraftRole.description,
icon: settingsDraftRole.icon,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canReadAllObjectRecords:
settingsDraftRole.canReadAllObjectRecords,
canUpdateAllObjectRecords:
settingsDraftRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
},
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isDefined(dirtyFields.objectPermissions)) {
await upsertObjectPermissions({
variables: {
upsertObjectPermissionsInput: {
roleId: roleId,
objectPermissions:
settingsDraftRole.objectPermissions?.map(
(objectPermission) => ({
objectMetadataId: objectPermission.objectMetadataId,
canReadObjectRecords:
objectPermission.canReadObjectRecords,
canUpdateObjectRecords:
objectPermission.canUpdateObjectRecords,
canSoftDeleteObjectRecords:
objectPermission.canSoftDeleteObjectRecords,
canDestroyObjectRecords:
objectPermission.canDestroyObjectRecords,
}),
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
}
await loadCurrentUser();
} finally {
setIsSaving(false);
}
}; };
return ( return (
@ -292,7 +301,11 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
actionButton={ actionButton={
isRoleEditable && isRoleEditable &&
isDirty && ( isDirty && (
<SaveAndCancelButtons onSave={handleSave} onCancel={handleCancel} /> <SaveAndCancelButtons
onSave={handleSave}
onCancel={handleCancel}
isLoading={isSaving}
/>
) )
} }
> >
@ -311,7 +324,6 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
<SettingsRolePermissions <SettingsRolePermissions
roleId={roleId} roleId={roleId}
isEditable={isRoleEditable} isEditable={isRoleEditable}
isCreateMode={isCreateMode}
/> />
)} )}
{activeTabId === SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS && ( {activeTabId === SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS && (

View File

@ -1,5 +1,6 @@
import { SETTINGS_ROLE_DETAIL_TABS } from '@/settings/roles/role/constants/SettingsRoleDetailTabs'; import { SETTINGS_ROLE_DETAIL_TABS } from '@/settings/roles/role/constants/SettingsRoleDetailTabs';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
@ -16,6 +17,11 @@ export const SettingsRoleCreateEffect = ({
const setSettingsDraftRole = useSetRecoilState( const setSettingsDraftRole = useSetRecoilState(
settingsDraftRoleFamilyState(roleId), settingsDraftRoleFamilyState(roleId),
); );
const setSettingsPersistedRole = useSetRecoilState(
settingsPersistedRoleFamilyState(roleId),
);
const setActiveTabId = useSetRecoilComponentStateV2( const setActiveTabId = useSetRecoilComponentStateV2(
activeTabIdComponentState, activeTabIdComponentState,
SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID + '-' + roleId, SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID + '-' + roleId,
@ -44,9 +50,16 @@ export const SettingsRoleCreateEffect = ({
workspaceMembers: [], workspaceMembers: [],
}; };
setSettingsPersistedRole(undefined);
setSettingsDraftRole(newRole); setSettingsDraftRole(newRole);
setIsInitialized(true); setIsInitialized(true);
}, [isInitialized, roleId, setActiveTabId, setSettingsDraftRole]); }, [
isInitialized,
roleId,
setActiveTabId,
setSettingsDraftRole,
setSettingsPersistedRole,
]);
return null; return null;
}; };

View File

@ -1,15 +1,16 @@
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect'; import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
import { SettingsRole } from '@/settings/roles/role/components/SettingsRole'; import { SettingsRole } from '@/settings/roles/role/components/SettingsRole';
import { SettingsRoleCreateEffect } from '@/settings/roles/role/components/SettingsRoleCreateEffect'; import { SettingsRoleCreateEffect } from '@/settings/roles/role/components/SettingsRoleCreateEffect';
import { v4 } from 'uuid';
export const PENDING_ROLE_ID = 'pending-role-id';
export const SettingsRoleCreate = () => { export const SettingsRoleCreate = () => {
const newRoleId = v4();
return ( return (
<> <>
<SettingsRolesQueryEffect /> <SettingsRolesQueryEffect />
<SettingsRoleCreateEffect roleId={PENDING_ROLE_ID} /> <SettingsRoleCreateEffect roleId={newRoleId} />
<SettingsRole roleId={PENDING_ROLE_ID} isCreateMode={true} /> <SettingsRole roleId={newRoleId} isCreateMode={true} />
</> </>
); );
}; };

View File

@ -3,21 +3,29 @@ import { SettingsRole } from '@/settings/roles/role/components/SettingsRole';
import { SettingsRoleEditEffect } from '@/settings/roles/role/components/SettingsRoleEditEffect'; import { SettingsRoleEditEffect } from '@/settings/roles/role/components/SettingsRoleEditEffect';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { Navigate, useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { settingsPersistedRoleFamilyState } from '~/modules/settings/roles/states/settingsPersistedRoleFamilyState';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SettingsRoleEdit = () => { export const SettingsRoleEdit = () => {
const { roleId } = useParams(); const { roleId } = useParams();
const persistedRole = useRecoilValue(
settingsPersistedRoleFamilyState(roleId ?? ''),
);
if (!isDefined(roleId)) { if (!isDefined(roleId)) {
return <Navigate to={getSettingsPath(SettingsPath.Roles)} />; return <Navigate to={getSettingsPath(SettingsPath.Roles)} />;
} }
const isCreateMode = !isDefined(persistedRole?.id);
return ( return (
<> <>
<SettingsRolesQueryEffect /> <SettingsRolesQueryEffect />
<SettingsRoleEditEffect roleId={roleId} /> <SettingsRoleEditEffect roleId={roleId} />
<SettingsRole roleId={roleId} isCreateMode={false} /> <SettingsRole roleId={roleId} isCreateMode={isCreateMode} />
</> </>
); );
}; };

View File

@ -67,6 +67,7 @@ export class RoleService {
await this.validateRoleInputOrThrow({ input, workspaceId }); await this.validateRoleInputOrThrow({ input, workspaceId });
const role = await this.roleRepository.save({ const role = await this.roleRepository.save({
id: input.id,
label: input.label, label: input.label,
description: input.description, description: input.description,
icon: input.icon, icon: input.icon,