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:
+17
-11
@@ -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,
|
||||
|
||||
+22
-24
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+8
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+82
@@ -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);
|
||||
});
|
||||
});
|
||||
+67
@@ -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;
|
||||
};
|
||||
+24
-1
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+148
-129
@@ -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}
|
||||
|
||||
+2
@@ -116,6 +116,8 @@ export const RecordTableCellFieldContextGeneric = ({
|
||||
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
|
||||
isCustom: fieldDefinition.metadata.isCustom ?? false,
|
||||
},
|
||||
fieldDefinition,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}),
|
||||
isForbidden: !hasObjectReadPermissions,
|
||||
}}
|
||||
|
||||
+2
@@ -73,6 +73,8 @@ export const RecordTableCellFieldContextLabelIdentifier = ({
|
||||
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
|
||||
isCustom: fieldDefinition.metadata.isCustom ?? false,
|
||||
},
|
||||
fieldDefinition,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}),
|
||||
maxWidth: recordField.size,
|
||||
onRecordChipClick: handleChipClick,
|
||||
|
||||
+2
@@ -118,6 +118,8 @@ export const WidgetActionFieldEdit = () => {
|
||||
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
|
||||
isCustom: fieldMetadataItem.isCustom ?? false,
|
||||
},
|
||||
fieldDefinition,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}),
|
||||
anchorId: recordFieldInputInstanceId,
|
||||
} satisfies GenericFieldContextType;
|
||||
|
||||
+2
@@ -100,6 +100,8 @@ export const FieldWidgetDisplay = ({
|
||||
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
|
||||
isCustom: fieldMetadataItem.isCustom ?? false,
|
||||
},
|
||||
fieldDefinition,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}),
|
||||
onMouseEnter: handleMouseEnter,
|
||||
anchorId: getRecordFieldInputInstanceId({
|
||||
|
||||
+11
-7
@@ -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);
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_WORKSPACE_MEMBER_SETTINGS = gql`
|
||||
mutation UpdateWorkspaceMemberSettings(
|
||||
$input: UpdateWorkspaceMemberSettingsInput!
|
||||
) {
|
||||
updateWorkspaceMemberSettings(input: $input)
|
||||
}
|
||||
`;
|
||||
+61
@@ -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,
|
||||
};
|
||||
};
|
||||
+146
@@ -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);
|
||||
});
|
||||
});
|
||||
+171
@@ -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;
|
||||
};
|
||||
+12
-32
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user