Introduce updateWorkspaceMemberSettings and clarify product (#19441)

## Summary

Introduces a dedicated **metadata** mutation to update **standard
(non-custom)** workspace member settings, moves profile-related UI to
use it, and aligns **workspace member** record permissions with the rest
of the CRM so users cannot escalate visibility via RLS by editing their
own member record.

## Product behaviour

### Profile and appearance (standard fields)

- Users can still update **their own** standard workspace member fields
that the product exposes in **Settings / Profile** (e.g. name, locale,
color scheme, avatar flow) via the new
**`updateWorkspaceMemberSettings`** mutation.
- The mutation returns a **boolean**; the app **merges** the updated
fields into local state so the UI stays in sync without refetching the
full workspace member record.
- **Locale** changes also keep **`userWorkspace`** in sync when a locale
is present in the payload (including from the workspace `updateOne` path
when applicable).

### Custom fields on workspace members

- The dedicated metadata mutation **rejects** any **custom** workspace
member field (and unknown keys). Those updates must go through the
normal **object** `updateOne` pipeline, which is subject to **object-
and field-level** permissions like other records. But since we don't
have object- and field-level permission configuration for system objects
yet, this permission is derived from Workspace member settings
permission.
- **Workspace member** is no longer exempt from ORM permission
validation for updates merely because it is a **system** object. Users
who **do not** have workspace member access (e.g. no **Workspace
members** settings permission and no equivalent broad settings access on
the role) **cannot** use `updateOne` on `workspaceMember` to change
**custom** (or other) fields on their own row—even though that row is
used for RLS predicates.
- This closes a path where someone could widen what they can see by
writing to fields that drive row-level rules.

### Who can change another member

- Updating **another** user’s workspace member still requires
**Workspace members** (or equivalent) settings permission, consistent
with admin tooling.
This commit is contained in:
Marie
2026-04-14 18:29:00 +02:00
committed by GitHub
parent 42f452311b
commit bc28e1557c
58 changed files with 1986 additions and 478 deletions
@@ -5,11 +5,13 @@ import { useCallback, useState } from 'react';
import { CalendarEventParticipantsResponseStatus } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatus';
import { type CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext';
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import {
@@ -19,9 +21,9 @@ import {
} from '@/object-record/record-field/ui/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
AvatarOrIcon,
@@ -32,7 +34,6 @@ import {
} from 'twenty-ui/components';
import { IconCalendarEvent } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
type CalendarEventDetailsProps = {
@@ -70,7 +71,6 @@ const StyledTitle = styled.h2<{ canceled?: boolean }>`
font-weight: ${themeCssVariables.font.weight.semiBold};
margin: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[0]}
${themeCssVariables.spacing[2]};
text-decoration: ${({ canceled }) => (canceled ? 'line-through' : 'none')};
`;
@@ -149,12 +149,21 @@ export const CalendarEventDetails = ({
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const isRecordReadOnly = useIsRecordReadOnly({
recordId: calendarEvent.id,
objectMetadataId: objectMetadataItem.id,
});
const renderField = (fieldMetadataItem: FieldMetadataItem) => {
const fieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
showLabel: true,
labelWidth: 72,
});
const isReadOnly = isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
@@ -164,6 +173,8 @@ export const CalendarEventDetails = ({
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
});
return (
@@ -173,12 +184,7 @@ export const CalendarEventDetails = ({
value={{
recordId: calendarEvent.id,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
showLabel: true,
labelWidth: 72,
}),
fieldDefinition,
useUpdateRecord: useUpdateOneCalendarEventRecordMutation,
maxWidth: 300,
isRecordFieldReadOnly: isReadOnly,
@@ -15,12 +15,12 @@ import { detectNumberFormat } from '@/localization/utils/detection/detectNumberF
import { detectTimeFormat } from '@/localization/utils/detection/detectTimeFormat';
import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone';
import { getWorkspaceMemberUpdateFromFormatPreferences } from '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { CalendarStartDay } from 'twenty-shared/constants';
import { FirstDayOfTheWeek } from 'twenty-shared/types';
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: jest.fn(),
jest.mock('@/settings/profile/hooks/useUpdateWorkspaceMemberSettings', () => ({
useUpdateWorkspaceMemberSettings: jest.fn(),
}));
jest.mock('@/localization/utils/detection/detectTimeZone');
jest.mock('@/localization/utils/detection/detectDateFormat');
@@ -31,9 +31,10 @@ jest.mock(
'@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences',
);
const mockUseUpdateOneRecord = useUpdateOneRecord as jest.MockedFunction<
typeof useUpdateOneRecord
>;
const mockUseUpdateWorkspaceMemberSettings =
useUpdateWorkspaceMemberSettings as jest.MockedFunction<
typeof useUpdateWorkspaceMemberSettings
>;
const mockDetectTimeZone = detectTimeZone as jest.MockedFunction<
typeof detectTimeZone
>;
@@ -53,7 +54,7 @@ const mockGetWorkspaceMemberUpdateFromFormatPreferences =
typeof getWorkspaceMemberUpdateFromFormatPreferences
>;
const mockUpdateOneRecord = jest.fn();
const mockUpdateWorkspaceMemberSettingsFn = jest.fn();
const mockCurrentWorkspaceMember = {
id: 'workspace-member-1',
@@ -94,8 +95,8 @@ describe('useFormatPreferences', () => {
mockCurrentWorkspaceMember,
);
mockUseUpdateOneRecord.mockReturnValue({
updateOneRecord: mockUpdateOneRecord,
mockUseUpdateWorkspaceMemberSettings.mockReturnValue({
updateWorkspaceMemberSettings: mockUpdateWorkspaceMemberSettingsFn,
});
mockDetectTimeZone.mockReturnValue('America/New_York');
@@ -105,7 +106,7 @@ describe('useFormatPreferences', () => {
mockDetectCalendarStartDay.mockReturnValue(FirstDayOfTheWeek.MONDAY);
mockGetWorkspaceMemberUpdateFromFormatPreferences.mockReturnValue({});
mockUpdateOneRecord.mockResolvedValue({});
mockUpdateWorkspaceMemberSettingsFn.mockResolvedValue(undefined);
});
it('should be a function', () => {
@@ -151,10 +152,9 @@ describe('useFormatPreferences', () => {
await result.current.updateFormatPreference('timeZone', newTimeZone);
});
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: mockCurrentWorkspaceMember.id,
objectNameSingular: 'workspaceMember',
updateOneRecordInput: { timeZone: newTimeZone },
expect(mockUpdateWorkspaceMemberSettingsFn).toHaveBeenCalledWith({
workspaceMemberId: mockCurrentWorkspaceMember.id,
update: { timeZone: newTimeZone },
});
});
@@ -173,10 +173,9 @@ describe('useFormatPreferences', () => {
});
expect(mockDetectTimeZone).toHaveBeenCalled();
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: mockCurrentWorkspaceMember.id,
objectNameSingular: 'workspaceMember',
updateOneRecordInput: { timeZone: 'SYSTEM' },
expect(mockUpdateWorkspaceMemberSettingsFn).toHaveBeenCalledWith({
workspaceMemberId: mockCurrentWorkspaceMember.id,
update: { timeZone: 'SYSTEM' },
});
});
@@ -198,10 +197,9 @@ describe('useFormatPreferences', () => {
await result.current.updateMultipleFormatPreferences(updates);
});
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: mockCurrentWorkspaceMember.id,
objectNameSingular: 'workspaceMember',
updateOneRecordInput: { timeZone: 'Europe/Paris' },
expect(mockUpdateWorkspaceMemberSettingsFn).toHaveBeenCalledWith({
workspaceMemberId: mockCurrentWorkspaceMember.id,
update: { timeZone: 'Europe/Paris' },
});
});
@@ -216,7 +214,7 @@ describe('useFormatPreferences', () => {
await result.current.updateFormatPreference('timeZone', 'Europe/London');
});
expect(mockUpdateOneRecord).not.toHaveBeenCalled();
expect(mockUpdateWorkspaceMemberSettingsFn).not.toHaveBeenCalled();
});
it('should handle update errors gracefully', async () => {
@@ -225,7 +223,7 @@ describe('useFormatPreferences', () => {
});
const error = new Error('Update failed');
mockUpdateOneRecord.mockRejectedValue(error);
mockUpdateWorkspaceMemberSettingsFn.mockRejectedValue(error);
await expect(
act(async () => {
@@ -15,11 +15,9 @@ import { detectTimeFormat } from '@/localization/utils/detection/detectTimeForma
import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone';
import { getFormatPreferencesFromWorkspaceMember } from '@/localization/utils/format-preferences/getFormatPreferencesFromWorkspaceMember';
import { getWorkspaceMemberUpdateFromFormatPreferences } from '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { CalendarStartDay } from 'twenty-shared/constants';
import { logError } from '~/utils/logError';
@@ -31,11 +29,8 @@ export const useFormatPreferences = () => {
setWorkspaceMemberFormatPreferences,
] = useAtomState(workspaceMemberFormatPreferencesState);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const setCurrentWorkspaceMember = useSetAtomState(
currentWorkspaceMemberState,
);
const { updateOneRecord } = useUpdateOneRecord();
const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings();
const updateFormatPreference = useCallback(
async <K extends FormatPreferenceKey>(
@@ -96,19 +91,9 @@ export const useFormatPreferences = () => {
});
try {
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: currentWorkspaceMember.id,
updateOneRecordInput: workspaceMemberUpdate,
});
// Update the currentWorkspaceMemberState with the new backend values
setCurrentWorkspaceMember((prev) => {
if (!prev) return prev;
return {
...prev,
...workspaceMemberUpdate,
};
await updateWorkspaceMemberSettings({
workspaceMemberId: currentWorkspaceMember.id,
update: workspaceMemberUpdate,
});
} catch (error) {
logError(error);
@@ -117,9 +102,8 @@ export const useFormatPreferences = () => {
},
[
currentWorkspaceMember,
updateOneRecord,
updateWorkspaceMemberSettings,
setWorkspaceMemberFormatPreferences,
setCurrentWorkspaceMember,
],
);
@@ -167,19 +151,9 @@ export const useFormatPreferences = () => {
getWorkspaceMemberUpdateFromFormatPreferences(updates);
try {
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: currentWorkspaceMember.id,
updateOneRecordInput: workspaceMemberUpdate,
});
// Update the currentWorkspaceMemberState with the new backend values
setCurrentWorkspaceMember((prev) => {
if (!prev) return prev;
return {
...prev,
...workspaceMemberUpdate,
};
await updateWorkspaceMemberSettings({
workspaceMemberId: currentWorkspaceMember.id,
update: workspaceMemberUpdate,
});
} catch (error) {
logError(error);
@@ -188,9 +162,8 @@ export const useFormatPreferences = () => {
},
[
currentWorkspaceMember,
updateOneRecord,
updateWorkspaceMemberSettings,
setWorkspaceMemberFormatPreferences,
setCurrentWorkspaceMember,
],
);
@@ -1,4 +1,5 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
@@ -39,10 +40,17 @@ export const useIsRecordFieldReadOnly = ({
return false;
}
const fieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
});
return isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions,
fieldMetadataItem,
fieldDefinition,
objectPermissionsByObjectMetadataId,
});
};
@@ -0,0 +1,82 @@
import { isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission } from '@/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
const buildObjectPermissionsMap = (opts: {
sourceId: string;
targetId: string;
targetCanUpdate: boolean;
}) => ({
[opts.sourceId]: {
objectMetadataId: opts.sourceId,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
[opts.targetId]: {
objectMetadataId: opts.targetId,
canReadObjectRecords: true,
canUpdateObjectRecords: opts.targetCanUpdate,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
});
describe('isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission', () => {
const sourceObjectMetadataId = 'source-object-id';
const targetObjectMetadataId = 'workspace-member-object-id';
const oneToManyFieldDefinition = {
type: FieldMetadataType.RELATION,
fieldMetadataId: 'field-on-person',
label: 'yehe',
iconName: 'IconRelation',
metadata: {
fieldName: 'yehe',
relationType: RelationType.ONE_TO_MANY,
relationObjectMetadataId: targetObjectMetadataId,
relationObjectMetadataNameSingular: 'workspaceMember',
relationObjectMetadataNamePlural: 'workspaceMembers',
relationFieldMetadataId: 'target-field',
objectMetadataNameSingular: 'person',
targetFieldMetadataName: 'person',
settings: null,
isCustom: true,
isUIReadOnly: false,
},
} as FieldDefinition<FieldMetadata>;
it('should return true when the related object cannot be updated', () => {
expect(
isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({
fieldDefinition: oneToManyFieldDefinition,
objectPermissionsByObjectMetadataId: buildObjectPermissionsMap({
sourceId: sourceObjectMetadataId,
targetId: targetObjectMetadataId,
targetCanUpdate: false,
}),
}),
).toBe(true);
});
it('should return false when the related object can be updated', () => {
expect(
isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({
fieldDefinition: oneToManyFieldDefinition,
objectPermissionsByObjectMetadataId: buildObjectPermissionsMap({
sourceId: sourceObjectMetadataId,
targetId: targetObjectMetadataId,
targetCanUpdate: true,
}),
}),
).toBe(false);
});
});
@@ -0,0 +1,67 @@
import { isNonEmptyString } from '@sniptt/guards';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type ObjectPermissionsByObjectMetadataId = Record<
string,
ObjectPermissions & { objectMetadataId: string }
>;
type IsOneToManyRelationFieldReadOnlyDueToTargetUpdatePermissionParams = {
fieldDefinition: FieldDefinition<FieldMetadata>;
objectPermissionsByObjectMetadataId: ObjectPermissionsByObjectMetadataId;
};
// One-to-many edits persist by updating the related (or junction) record, not the
// source row — require canUpdate on that object metadata.
export const isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission = ({
fieldDefinition,
objectPermissionsByObjectMetadataId,
}: IsOneToManyRelationFieldReadOnlyDueToTargetUpdatePermissionParams): boolean => {
if (isFieldRelationOneToMany(fieldDefinition)) {
const relationObjectMetadataId =
fieldDefinition.metadata.relationObjectMetadataId;
if (!isNonEmptyString(relationObjectMetadataId)) {
return false;
}
const relationObjectPermissions = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
relationObjectMetadataId,
);
return relationObjectPermissions.canUpdateObjectRecords === false;
}
if (isFieldMorphRelationOneToMany(fieldDefinition)) {
const morphTargetIds = [
...new Set(
fieldDefinition.metadata.morphRelations.map(
(relation) => relation.targetObjectMetadata.id,
),
),
];
if (!isDefined(morphTargetIds[0])) {
return false;
}
return morphTargetIds.every((targetObjectMetadataId) => {
const targetPermissions = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
targetObjectMetadataId,
);
return targetPermissions.canUpdateObjectRecords === false;
});
}
return false;
};
@@ -1,6 +1,16 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
import { isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission } from '@/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type ObjectPermission } from '~/generated-metadata/graphql';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type ObjectPermissionsByObjectMetadataId = Record<
string,
ObjectPermissions & { objectMetadataId: string }
>;
type IsRecordFieldReadOnlyParams = {
isRecordReadOnly: boolean;
@@ -10,6 +20,8 @@ type IsRecordFieldReadOnlyParams = {
'id' | 'isUIReadOnly' | 'isCustom'
>;
objectPermissions: ObjectPermission;
fieldDefinition?: FieldDefinition<FieldMetadata>;
objectPermissionsByObjectMetadataId?: ObjectPermissionsByObjectMetadataId;
};
export const isRecordFieldReadOnly = ({
@@ -17,16 +29,27 @@ export const isRecordFieldReadOnly = ({
isRecordReadOnly,
isSystemObject,
fieldMetadataItem,
fieldDefinition,
objectPermissionsByObjectMetadataId,
}: IsRecordFieldReadOnlyParams) => {
const fieldReadOnlyByPermissions = isFieldMetadataReadOnlyByPermissions({
objectPermissions,
fieldMetadataId: fieldMetadataItem.id,
});
const oneToManyTargetReadOnly =
isDefined(fieldDefinition) &&
isDefined(objectPermissionsByObjectMetadataId) &&
isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({
fieldDefinition,
objectPermissionsByObjectMetadataId,
});
return (
isRecordReadOnly ||
(isSystemObject === true && fieldMetadataItem.isCustom !== true) ||
fieldMetadataItem.isUIReadOnly ||
fieldReadOnlyByPermissions
fieldReadOnlyByPermissions ||
oneToManyTargetReadOnly
);
};
@@ -28,6 +28,7 @@ export const RecordBoardCardBody = () => {
const {
labelIdentifierFieldMetadataItem,
fieldDefinitionByFieldMetadataItemId,
objectPermissionsByObjectMetadataId,
} = useRecordIndexContextOrThrow();
const useUpdateOneRecordHook: RecordUpdateHook = () => {
@@ -83,6 +84,8 @@ export const RecordBoardCardBody = () => {
isCustom:
correspondingFieldDefinition.metadata.isCustom ?? false,
},
fieldDefinition: correspondingFieldDefinition,
objectPermissionsByObjectMetadataId,
}),
fieldDefinition: correspondingFieldDefinition,
useUpdateRecord: useUpdateOneRecordHook,
@@ -48,6 +48,7 @@ export const RecordCalendarCardBody = ({
const {
labelIdentifierFieldMetadataItem,
fieldDefinitionByFieldMetadataItemId,
objectPermissionsByObjectMetadataId,
} = useRecordIndexContextOrThrow();
const visibleRecordFields = useAtomComponentSelectorValue(
@@ -92,6 +93,8 @@ export const RecordCalendarCardBody = ({
isCustom:
correspondingFieldDefinition.metadata.isCustom ?? false,
},
fieldDefinition: correspondingFieldDefinition,
objectPermissionsByObjectMetadataId,
}),
fieldDefinition: correspondingFieldDefinition,
useUpdateRecord: useUpdateOneRecordHook,
@@ -95,28 +95,87 @@ export const RecordFieldList = ({
>
<PropertyBox dataTestId="record-fields-list-container">
{legacyActivityTargetFieldMetadataItems?.map(
(fieldMetadataItem, index) => (
(fieldMetadataItem, index) => {
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
return (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
onMouseEnter: () => handleMouseEnter(index),
anchorId: `${getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}`,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
}}
>
<ActivityTargetsInlineCell
componentInstanceId={getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}
activityObjectNameSingular={
objectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task
}
activityRecordId={objectRecordId}
showLabel={true}
maxWidth={200}
/>
</FieldContext.Provider>
);
},
)}
{inlineFieldMetadataItems?.map((fieldMetadataItem, index) => {
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
return (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
onMouseEnter: () => handleMouseEnter(index),
anchorId: `${getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}`,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
@@ -130,90 +189,44 @@ export const RecordFieldList = ({
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
}}
>
<ActivityTargetsInlineCell
componentInstanceId={getRecordFieldInputInstanceId({
onMouseEnter: () =>
handleMouseEnter(
index +
(legacyActivityTargetFieldMetadataItems?.length ?? 0),
),
anchorId: `${getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}
activityObjectNameSingular={
objectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task
}
activityRecordId={objectRecordId}
showLabel={true}
maxWidth={200}
/>
</FieldContext.Provider>
),
)}
{inlineFieldMetadataItems?.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
})}`,
isForbidden: isJunctionRelationForbidden({
fieldMetadataItem,
sourceObjectMetadataId: objectMetadataItem.id,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
}),
}}
>
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
onMouseEnter: () =>
handleMouseEnter(
index + (legacyActivityTargetFieldMetadataItems?.length ?? 0),
),
anchorId: `${getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}`,
isForbidden: isJunctionRelationForbidden({
fieldMetadataItem,
sourceObjectMetadataId: objectMetadataItem.id,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
}),
}}
>
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
}}
>
<RecordInlineCell
loading={recordLoading}
instanceIdPrefix={instanceId}
/>
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
))}
}}
>
<RecordInlineCell
loading={recordLoading}
instanceIdPrefix={instanceId}
/>
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
);
})}
</PropertyBox>
{showDuplicatesSection && (
<RecordDetailDuplicatesSection
@@ -227,42 +240,48 @@ export const RecordFieldList = ({
fieldMetadataItem.type === FieldMetadataType.RELATION ||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION,
)
.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
}}
>
{fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION ? (
<RecordDetailMorphRelationSection loading={recordLoading} />
) : (
<RecordDetailRelationSection loading={recordLoading} />
)}
</FieldContext.Provider>
))}
.map((fieldMetadataItem, index) => {
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
});
return (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
}}
>
{fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION ? (
<RecordDetailMorphRelationSection loading={recordLoading} />
) : (
<RecordDetailRelationSection loading={recordLoading} />
)}
</FieldContext.Provider>
);
})}
<RecordFieldListCellHoveredPortal
objectMetadataItem={objectMetadataItem}
@@ -116,6 +116,8 @@ export const RecordTableCellFieldContextGeneric = ({
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
isCustom: fieldDefinition.metadata.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
isForbidden: !hasObjectReadPermissions,
}}
@@ -73,6 +73,8 @@ export const RecordTableCellFieldContextLabelIdentifier = ({
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
isCustom: fieldDefinition.metadata.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
maxWidth: recordField.size,
onRecordChipClick: handleChipClick,
@@ -118,6 +118,8 @@ export const WidgetActionFieldEdit = () => {
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
anchorId: recordFieldInputInstanceId,
} satisfies GenericFieldContextType;
@@ -100,6 +100,8 @@ export const FieldWidgetDisplay = ({
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
onMouseEnter: handleMouseEnter,
anchorId: getRecordFieldInputInstanceId({
@@ -55,6 +55,14 @@ export const FieldsWidgetFieldItem = ({
targetObjectNameSingular,
);
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: globalIndex,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
return (
<FieldContext.Provider
key={recordId + fieldMetadataItem.id}
@@ -62,13 +70,7 @@ export const FieldsWidgetFieldItem = ({
recordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: globalIndex,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
fieldDefinition,
useUpdateRecord,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
@@ -83,6 +85,8 @@ export const FieldsWidgetFieldItem = ({
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
onMouseEnter,
anchorId: `${getRecordFieldInputInstanceId({
@@ -87,6 +87,8 @@ export const useWidgetActions = ({
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
});
if (!isFieldReadOnly) {
@@ -6,10 +6,8 @@ import { useDebouncedCallback } from 'use-debounce';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { logError } from '~/utils/logError';
@@ -29,9 +27,7 @@ type NameFieldsProps = {
export const NameFields = ({ autoSave = true }: NameFieldsProps) => {
const { t } = useLingui();
const currentUser = useAtomStateValue(currentUserState);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useAtomState(
currentWorkspaceMemberState,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { canEdit: canEditFirstName } = useCanEditProfileField('firstName');
const { canEdit: canEditLastName } = useCanEditProfileField('lastName');
@@ -42,7 +38,7 @@ export const NameFields = ({ autoSave = true }: NameFieldsProps) => {
currentWorkspaceMember?.name?.lastName ?? '',
);
const { updateOneRecord } = useUpdateOneRecord();
const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings();
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
const debouncedUpdate = useDebouncedCallback(async () => {
@@ -52,24 +48,15 @@ export const NameFields = ({ autoSave = true }: NameFieldsProps) => {
}
if (autoSave) {
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: currentWorkspaceMember?.id,
updateOneRecordInput: {
await updateWorkspaceMemberSettings({
workspaceMemberId: currentWorkspaceMember.id,
update: {
name: {
firstName: firstName,
lastName: lastName,
},
},
});
setCurrentWorkspaceMember({
...currentWorkspaceMember,
name: {
firstName,
lastName,
},
});
}
} catch (error) {
logError(error);
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPDATE_WORKSPACE_MEMBER_SETTINGS = gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`;
@@ -0,0 +1,61 @@
import { useMutation } from '@apollo/client/react';
import {
type CurrentWorkspaceMember,
currentWorkspaceMemberState,
} from '@/auth/states/currentWorkspaceMemberState';
import { UPDATE_WORKSPACE_MEMBER_SETTINGS } from '@/settings/profile/graphql/mutations/updateWorkspaceMemberSettings';
import {
mergeWorkspaceMemberSettingsIntoCurrent,
type WorkspaceMemberSettingsUpdateInput,
} from '@/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
type UpdateWorkspaceMemberSettingsMutationData = {
updateWorkspaceMemberSettings: boolean;
};
export type { WorkspaceMemberSettingsUpdateInput };
export const useUpdateWorkspaceMemberSettings = () => {
const [updateWorkspaceMemberSettingsMutation] =
useMutation<UpdateWorkspaceMemberSettingsMutationData>(
UPDATE_WORKSPACE_MEMBER_SETTINGS,
);
const setCurrentWorkspaceMember = useSetAtomState(
currentWorkspaceMemberState,
);
const updateWorkspaceMemberSettings = async ({
workspaceMemberId,
update,
}: {
workspaceMemberId: string;
update:
| WorkspaceMemberSettingsUpdateInput
| Partial<CurrentWorkspaceMember>
| Record<string, unknown>;
}) => {
const { data } = await updateWorkspaceMemberSettingsMutation({
variables: {
input: {
workspaceMemberId,
update,
},
},
});
if (data?.updateWorkspaceMemberSettings === true) {
setCurrentWorkspaceMember((previous) => {
if (!previous || previous.id !== workspaceMemberId) {
return previous;
}
return mergeWorkspaceMemberSettingsIntoCurrent(previous, update);
});
}
};
return {
updateWorkspaceMemberSettings,
};
};
@@ -0,0 +1,146 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { mergeWorkspaceMemberSettingsIntoCurrent } from '@/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent';
const createBaseWorkspaceMember = (): CurrentWorkspaceMember => ({
id: 'workspace-member-id',
name: {
firstName: 'Jane',
lastName: 'Doe',
},
locale: 'en',
colorScheme: 'System',
userEmail: 'jane@example.com',
avatarUrl: 'https://example.com/avatar.png',
timeZone: 'Europe/London',
dateFormat: null,
timeFormat: null,
numberFormat: null,
calendarStartDay: 1,
});
describe('mergeWorkspaceMemberSettingsIntoCurrent', () => {
it('should not mutate the previous object', () => {
const previous = createBaseWorkspaceMember();
const update = { locale: 'fr-FR' };
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, update);
expect(result).not.toBe(previous);
expect(previous.locale).toBe('en');
expect(result.locale).toBe('fr-FR');
});
it('should merge locale and colorScheme', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
locale: 'de-DE',
colorScheme: 'Dark',
});
expect(result.locale).toBe('de-DE');
expect(result.colorScheme).toBe('Dark');
expect(result.userEmail).toBe(previous.userEmail);
expect(result.id).toBe(previous.id);
});
it('should merge name when payload only includes firstName key', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
name: { firstName: 'Janet' },
} as Record<string, unknown>);
expect(result.name.firstName).toBe('Janet');
expect(result.name.lastName).toBe('Doe');
});
it('should merge name when payload only includes lastName key', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
name: { lastName: 'Smith' },
} as Record<string, unknown>);
expect(result.name.firstName).toBe('Jane');
expect(result.name.lastName).toBe('Smith');
});
it('should merge both name fields when both are non-empty strings', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
name: { firstName: 'A', lastName: 'B' },
});
expect(result.name).toEqual({ firstName: 'A', lastName: 'B' });
});
it('should set avatarUrl to null when update is empty string', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
avatarUrl: '',
});
expect(result.avatarUrl).toBeNull();
});
it('should set avatarUrl to null when update is null', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
avatarUrl: null,
});
expect(result.avatarUrl).toBeNull();
});
it('should set avatarUrl to the new URL when provided', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
avatarUrl: 'https://example.com/new.png',
});
expect(result.avatarUrl).toBe('https://example.com/new.png');
});
it('should merge timeZone and calendarStartDay', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
timeZone: 'America/New_York',
calendarStartDay: 0,
});
expect(result.timeZone).toBe('America/New_York');
expect(result.calendarStartDay).toBe(0);
});
it('should merge format fields from Record payload', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
dateFormat: 'MONTH_FIRST',
timeFormat: 'HOUR_12',
numberFormat: 'COMMAS_AND_DOT',
} as Record<string, unknown>);
expect(result.dateFormat).toBe('MONTH_FIRST');
expect(result.timeFormat).toBe('HOUR_12');
expect(result.numberFormat).toBe('COMMAS_AND_DOT');
});
it('should ignore unknown keys in Record payload', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
locale: 'es',
unknownField: 'ignored',
} as Record<string, unknown>);
expect(result.locale).toBe('es');
expect('unknownField' in result).toBe(false);
});
});
@@ -0,0 +1,171 @@
import { isNull, isNumber, isString } from '@sniptt/guards';
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
import {
WorkspaceMemberDateFormatEnum,
WorkspaceMemberNumberFormatEnum,
WorkspaceMemberTimeFormatEnum,
} from '~/generated-metadata/graphql';
export type WorkspaceMemberNameUpdate = {
firstName: string;
lastName: string;
};
export type WorkspaceMemberSettingsUpdateInput = {
name?: WorkspaceMemberNameUpdate;
colorScheme?: string;
avatarUrl?: string | null;
locale?: string;
calendarStartDay?: number;
timeZone?: string;
dateFormat?: string;
timeFormat?: string;
numberFormat?: string;
position?: number;
};
const isColorScheme = (value: unknown): value is ColorScheme =>
value === 'Dark' || value === 'Light' || value === 'System';
const WORKSPACE_MEMBER_DATE_FORMAT_VALUES: ReadonlySet<string> = new Set(
Object.values(WorkspaceMemberDateFormatEnum),
);
const isWorkspaceMemberDateFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['dateFormat']> =>
isString(value) && WORKSPACE_MEMBER_DATE_FORMAT_VALUES.has(value);
const WORKSPACE_MEMBER_TIME_FORMAT_VALUES: ReadonlySet<string> = new Set(
Object.values(WorkspaceMemberTimeFormatEnum),
);
const isWorkspaceMemberTimeFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['timeFormat']> =>
isString(value) && WORKSPACE_MEMBER_TIME_FORMAT_VALUES.has(value);
const WORKSPACE_MEMBER_NUMBER_FORMAT_VALUES: ReadonlySet<string> = new Set(
Object.values(WorkspaceMemberNumberFormatEnum),
);
const isWorkspaceMemberNumberFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['numberFormat']> =>
isString(value) && WORKSPACE_MEMBER_NUMBER_FORMAT_VALUES.has(value);
export const mergeWorkspaceMemberSettingsIntoCurrent = (
previous: CurrentWorkspaceMember,
update: WorkspaceMemberSettingsUpdateInput | Record<string, unknown>,
): CurrentWorkspaceMember => {
if (!isPlainObject(update)) {
return { ...previous };
}
const payload = update;
let next: CurrentWorkspaceMember = { ...previous };
if ('name' in payload && isPlainObject(payload.name)) {
const namePayload = payload.name;
const firstName = isString(namePayload.firstName)
? namePayload.firstName
: undefined;
const lastName = isString(namePayload.lastName)
? namePayload.lastName
: undefined;
next = {
...next,
name: {
...previous.name,
...(isDefined(firstName) ? { firstName } : {}),
...(isDefined(lastName) ? { lastName } : {}),
},
};
}
if (
'locale' in payload &&
(payload.locale === null || isDefined(payload.locale))
) {
if (isString(payload.locale) || isNull(payload.locale)) {
next = { ...next, locale: payload.locale };
}
}
if ('colorScheme' in payload && isDefined(payload.colorScheme)) {
if (isColorScheme(payload.colorScheme)) {
next = { ...next, colorScheme: payload.colorScheme };
}
}
if ('avatarUrl' in payload) {
const value = payload.avatarUrl;
if (value === '' || isNull(value)) {
next = { ...next, avatarUrl: null };
} else if (isString(value)) {
next = { ...next, avatarUrl: value };
}
}
if (
'timeZone' in payload &&
(isNull(payload.timeZone) || isDefined(payload.timeZone))
) {
if (isString(payload.timeZone) || isNull(payload.timeZone)) {
next = { ...next, timeZone: payload.timeZone };
}
}
if (
'dateFormat' in payload &&
(payload.dateFormat === null || isDefined(payload.dateFormat))
) {
const value = payload.dateFormat;
if (isNull(value)) {
next = { ...next, dateFormat: null };
} else if (isWorkspaceMemberDateFormat(value)) {
next = { ...next, dateFormat: value };
}
}
if (
'timeFormat' in payload &&
(payload.timeFormat === null || isDefined(payload.timeFormat))
) {
const value = payload.timeFormat;
if (isNull(value)) {
next = { ...next, timeFormat: null };
} else if (isWorkspaceMemberTimeFormat(value)) {
next = { ...next, timeFormat: value };
}
}
if (
'numberFormat' in payload &&
(payload.numberFormat === null || isDefined(payload.numberFormat))
) {
const value = payload.numberFormat;
if (isNull(value)) {
next = { ...next, numberFormat: null };
} else if (isWorkspaceMemberNumberFormat(value)) {
next = { ...next, numberFormat: value };
}
}
if (
'calendarStartDay' in payload &&
(isNull(payload.calendarStartDay) || isDefined(payload.calendarStartDay))
) {
const value = payload.calendarStartDay;
if (isNull(value)) {
next = { ...next, calendarStartDay: null };
} else if (isNumber(value) && Number.isFinite(value)) {
next = { ...next, calendarStartDay: value };
}
}
return next;
};
@@ -2,14 +2,13 @@ import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { isDefined } from 'twenty-shared/utils';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import { UploadWorkspaceMemberProfilePictureDocument } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@@ -32,15 +31,13 @@ export const WorkspaceMemberPictureUploader = ({
const [uploadController, setUploadController] =
useState<AbortController | null>(null);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useAtomState(
currentWorkspaceMemberState,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const [uploadPicture] = useMutation(
UploadWorkspaceMemberProfilePictureDocument,
);
const { updateOneRecord } = useUpdateOneRecord();
const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings();
const { canEdit: canEditProfilePicture } =
useCanEditProfileField('profilePicture');
@@ -74,20 +71,11 @@ export const WorkspaceMemberPictureUploader = ({
throw new Error('Avatar upload failed');
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: workspaceMemberId,
updateOneRecordInput: { avatarUrl: signedFile.url },
});
newAvatarUrl = signedFile.url;
if (isEditingSelf && isDefined(currentWorkspaceMember)) {
setCurrentWorkspaceMember({
...currentWorkspaceMember,
avatarUrl: newAvatarUrl,
});
}
await updateWorkspaceMemberSettings({
workspaceMemberId,
update: { avatarUrl: newAvatarUrl },
});
if (isDefined(onAvatarUpdated)) {
onAvatarUpdated(newAvatarUrl);
@@ -114,19 +102,11 @@ export const WorkspaceMemberPictureUploader = ({
setErrorMessage(null);
try {
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: workspaceMemberId,
updateOneRecordInput: { avatarUrl: '' },
await updateWorkspaceMemberSettings({
workspaceMemberId,
update: { avatarUrl: '' },
});
if (isEditingSelf && isDefined(currentWorkspaceMember)) {
setCurrentWorkspaceMember({
...currentWorkspaceMember,
avatarUrl: null,
});
}
if (isDefined(onAvatarUpdated)) {
onAvatarUpdated(null);
}
@@ -7,11 +7,11 @@ import {
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { resetJotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
const updateOneRecordMock = jest.fn();
const mockUpdateWorkspaceMemberSettings = jest.fn();
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: () => ({
updateOneRecord: updateOneRecordMock,
jest.mock('@/settings/profile/hooks/useUpdateWorkspaceMemberSettings', () => ({
useUpdateWorkspaceMemberSettings: () => ({
updateWorkspaceMemberSettings: mockUpdateWorkspaceMemberSettings,
}),
}));
@@ -1,8 +1,7 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useCallback } from 'react';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { persistedColorSchemeState } from '@/ui/theme/states/persistedColorSchemeState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember';
@@ -18,7 +17,7 @@ export const useColorScheme = () => {
currentWorkspaceMemberState,
);
const { updateOneRecord } = useUpdateOneRecord();
const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings();
const setPersistedColorScheme = useSetAtomState(persistedColorSchemeState);
const colorScheme = currentWorkspaceMember?.colorScheme ?? 'System';
@@ -38,10 +37,9 @@ export const useColorScheme = () => {
colorScheme: value,
};
});
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: currentWorkspaceMember?.id,
updateOneRecordInput: {
await updateWorkspaceMemberSettings({
workspaceMemberId: currentWorkspaceMember.id,
update: {
colorScheme: value,
},
});
@@ -50,7 +48,7 @@ export const useColorScheme = () => {
currentWorkspaceMember,
setCurrentWorkspaceMember,
setPersistedColorScheme,
updateOneRecord,
updateWorkspaceMemberSettings,
],
);