Enhance role-check system with stricter checks (#15392)

## Overview

This PR strengthens our permission system by introducing more granular
role-based access control across the platform.

## Changes

### New Permissions Added
- **Applications** - Control who can install and manage applications
- **Layouts** - Control who can customize page layouts and UI structure
- **AI** - Control access to AI features and agents
- **Upload File** - Separate permission for file uploads
- **Download File** - Separate permission for file downloads (frontend
visibility)

### Security Enhancements
- Implemented whitelist-based validation for workspace field updates
- Added explicit permission guards to core entity resolvers
- Enhanced ESLint rule to enforce permission checks on all mutations
- Created `CustomPermissionGuard` and `NoPermissionGuard` for better
code documentation

### Affected Components
- Core entity resolvers: webhooks, files, domains, applications,
layouts, postgres credentials
- Workspace update mutations now use whitelist validation
- Settings UI updated with new permission controls

### Developer Experience
- ESLint now catches missing permission guards during development
- Explicit guard markers make permission requirements clear in code
review
- Comprehensive test coverage for new permission logic

## Testing
-  All TypeScript type checks pass
-  ESLint validation passes
-  New permission guards properly enforced
-  Frontend UI displays new permissions correctly

## Migration Notes
Existing workspaces will need to assign the new permissions to roles as
needed. By default, all new permissions are set to `false` for non-admin
roles.
This commit is contained in:
Félix Malfait
2025-11-07 15:37:17 +01:00
committed by GitHub
parent 44d6ec2594
commit cff17db6cb
306 changed files with 13863 additions and 976 deletions
@@ -14,6 +14,7 @@ import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryP
import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups';
import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters';
import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } from '@/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
@@ -35,6 +36,7 @@ const StyledContainer = styled.div`
export const UpdateViewButtonGroup = () => {
const { saveCurrentViewFilterAndSorts } = useSaveCurrentViewFiltersAndSorts();
const { canPersistChanges } = useCanPersistViewChanges();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
@@ -78,6 +80,7 @@ export const UpdateViewButtonGroup = () => {
};
const handleUpdateViewClick = async () => {
if (!canPersistChanges) return;
await saveCurrentViewFilterAndSorts();
await refreshCoreViewsByObjectMetadataId(objectMetadataItem.id);
};
@@ -111,7 +114,11 @@ export const UpdateViewButtonGroup = () => {
<StyledContainer>
{currentView?.key !== 'INDEX' ? (
<ButtonGroup size="small" accent="blue">
<Button title="Update view" onClick={handleUpdateViewClick} />
<Button
title={t`Update view`}
onClick={handleUpdateViewClick}
disabled={!canPersistChanges}
/>
<Dropdown
dropdownId={UPDATE_VIEW_BUTTON_DROPDOWN_ID}
clickableComponent={
@@ -27,6 +27,8 @@ export const VIEW_FRAGMENT = gql`
anyFieldFilterValue
calendarFieldMetadataId
calendarLayout
visibility
createdByUserWorkspaceId
viewFields {
...ViewFieldFragment
}
@@ -2,6 +2,7 @@ import { AggregateOperations } from '@/object-record/record-table/constants/Aggr
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { usePersistView } from '@/views/hooks/internal/usePersistView';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { renderHook } from '@testing-library/react';
import { useSetRecoilState } from 'recoil';
@@ -9,6 +10,7 @@ import { useUpdateViewAggregate } from '../useUpdateViewAggregate';
jest.mock('@/ui/utilities/state/component-state/hooks/useRecoilComponentValue');
jest.mock('@/views/hooks/internal/usePersistView');
jest.mock('@/views/hooks/useCanPersistViewChanges');
jest.mock('@/views/hooks/useRefreshCoreViewsByObjectMetadataId');
jest.mock('recoil');
describe('useUpdateViewAggregate', () => {
@@ -22,6 +24,9 @@ describe('useUpdateViewAggregate', () => {
(usePersistView as jest.Mock).mockReturnValue({
updateView: mockUpdateView,
});
(useCanPersistViewChanges as jest.Mock).mockReturnValue({
canPersistChanges: true,
});
(useSetRecoilState as jest.Mock).mockReturnValue(
mockSetRecordIndexKanbanAggregateOperationState,
);
@@ -0,0 +1,24 @@
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { ViewVisibility } from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated/graphql';
export const useCanPersistViewChanges = () => {
const { currentView } = useGetCurrentViewOnly();
const hasViewsPermission = useHasPermissionFlag(PermissionFlagType.VIEWS);
if (!currentView) {
return { canPersistChanges: false };
}
// Users with VIEWS permission can persist all views
if (hasViewsPermission) {
return { canPersistChanges: true };
}
// Users without VIEWS permission can only persist unlisted views
// (which are always their own, filtered by backend)
const canPersistChanges = currentView.visibility === ViewVisibility.UNLISTED;
return { canPersistChanges };
};
@@ -80,6 +80,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
kanbanFieldMetadataId,
calendarFieldMetadataId,
type,
visibility,
}: Partial<
Pick<
GraphQLView,
@@ -89,6 +90,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
| 'kanbanFieldMetadataId'
| 'calendarFieldMetadataId'
| 'type'
| 'visibility'
>
>,
shouldCopyFiltersAndSortsAndAggregate?: boolean,
@@ -145,6 +147,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
viewType === ViewType.Calendar
? calendarFieldMetadataId
: undefined,
visibility,
},
});
@@ -1,12 +1,14 @@
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { usePersistView } from '@/views/hooks/internal/usePersistView';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { convertUpdateViewInputToCore } from '@/views/utils/convertUpdateViewInputToCore';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useSaveAnyFieldFilterToView = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const { updateView } = usePersistView();
const { currentView } = useGetCurrentViewOnly();
@@ -18,31 +20,32 @@ export const useSaveAnyFieldFilterToView = () => {
const saveAnyFieldFilterToView = useRecoilCallback(
({ snapshot }) =>
async () => {
if (!isDefined(currentView)) {
if (!canPersistChanges || !isDefined(currentView)) {
return;
}
const currentViewAnyFieldFilterValue = currentView?.anyFieldFilterValue;
const currentViewAnyFieldFilterValue = currentView.anyFieldFilterValue;
const currentAnyFieldFilterValue = snapshot
.getLoadable(anyFieldFilterValueCallbackState)
.getValue();
if (currentAnyFieldFilterValue !== currentViewAnyFieldFilterValue) {
const formattedCurrentView = convertUpdateViewInputToCore({
...currentView,
anyFieldFilterValue: currentAnyFieldFilterValue,
});
await updateView({
id: currentView.id,
input: {
...formattedCurrentView,
input: convertUpdateViewInputToCore({
...currentView,
anyFieldFilterValue: currentAnyFieldFilterValue,
},
}),
});
}
},
[updateView, anyFieldFilterValueCallbackState, currentView],
[
canPersistChanges,
updateView,
anyFieldFilterValueCallbackState,
currentView,
],
);
return {
@@ -3,6 +3,7 @@ import { useRecoilCallback } from 'recoil';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { usePersistViewField } from '@/views/hooks/internal/usePersistViewField';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState';
import { isPersistingViewFieldsState } from '@/views/states/isPersistingViewFieldsState';
import { type ViewField } from '@/views/types/ViewField';
@@ -14,6 +15,7 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useSaveCurrentViewFields = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const { createViewFields, updateViewFields } = usePersistViewField();
const { getViewFromPrefetchState } = useGetViewFromPrefetchState();
@@ -25,6 +27,10 @@ export const useSaveCurrentViewFields = () => {
const saveViewFields = useRecoilCallback(
({ set, snapshot }) =>
async (viewFieldsToSave: Omit<ViewField, 'definition'>[]) => {
if (!canPersistChanges) {
return;
}
const currentViewId = snapshot
.getLoadable(currentViewIdCallbackState)
.getValue();
@@ -125,6 +131,7 @@ export const useSaveCurrentViewFields = () => {
set(isPersistingViewFieldsState, false);
},
[
canPersistChanges,
createViewFields,
currentViewIdCallbackState,
getViewFromPrefetchState,
@@ -3,6 +3,7 @@ import { useRecoilCallback } from 'recoil';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroup';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState';
import { type ViewGroup } from '@/views/types/ViewGroup';
import { isDefined } from 'twenty-shared/utils';
@@ -10,6 +11,7 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useSaveCurrentViewGroups = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const { createViewGroups, updateViewGroups } = usePersistViewGroupRecords();
const { getViewFromPrefetchState } = useGetViewFromPrefetchState();
@@ -21,6 +23,10 @@ export const useSaveCurrentViewGroups = () => {
const saveViewGroup = useRecoilCallback(
({ snapshot }) =>
async (viewGroupToSave: ViewGroup) => {
if (!canPersistChanges) {
return;
}
const currentViewId = snapshot
.getLoadable(currentViewIdCallbackState)
.getValue();
@@ -75,12 +81,21 @@ export const useSaveCurrentViewGroups = () => {
},
]);
},
[currentViewIdCallbackState, getViewFromPrefetchState, updateViewGroups],
[
canPersistChanges,
currentViewIdCallbackState,
getViewFromPrefetchState,
updateViewGroups,
],
);
const saveViewGroups = useRecoilCallback(
({ snapshot }) =>
async (viewGroupsToSave: ViewGroup[]) => {
if (!canPersistChanges) {
return;
}
const currentViewId = snapshot
.getLoadable(currentViewIdCallbackState)
.getValue();
@@ -156,6 +171,7 @@ export const useSaveCurrentViewGroups = () => {
]);
},
[
canPersistChanges,
createViewGroups,
currentViewIdCallbackState,
getViewFromPrefetchState,
@@ -2,6 +2,7 @@ import { currentRecordFilterGroupsComponentState } from '@/object-record/record-
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { usePersistViewFilterGroupRecords } from '@/views/hooks/internal/usePersistViewFilterGroup';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { getViewFilterGroupsToCreate } from '@/views/utils/getViewFilterGroupsToCreate';
import { getViewFilterGroupsToDelete } from '@/views/utils/getViewFilterGroupsToDelete';
@@ -11,6 +12,7 @@ import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordFilterGroupsToViewFilterGroups = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const {
createViewFilterGroups,
updateViewFilterGroups,
@@ -25,7 +27,7 @@ export const useSaveRecordFilterGroupsToViewFilterGroups = () => {
const saveRecordFilterGroupsToViewFilterGroups = useRecoilCallback(
({ snapshot }) =>
async () => {
if (!isDefined(currentView)) {
if (!canPersistChanges || !isDefined(currentView)) {
return;
}
@@ -68,6 +70,7 @@ export const useSaveRecordFilterGroupsToViewFilterGroups = () => {
await deleteViewFilterGroups(viewFilterGroupIdsToDelete);
},
[
canPersistChanges,
currentView,
currentRecordFilterGroupsCallbackState,
createViewFilterGroups,
@@ -2,6 +2,7 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistViewFilter';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { getViewFiltersToCreate } from '@/views/utils/getViewFiltersToCreate';
import { getViewFiltersToDelete } from '@/views/utils/getViewFiltersToDelete';
@@ -11,6 +12,7 @@ import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordFiltersToViewFilters = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const { createViewFilters, updateViewFilters, deleteViewFilters } =
usePersistViewFilterRecords();
@@ -23,7 +25,7 @@ export const useSaveRecordFiltersToViewFilters = () => {
const saveRecordFiltersToViewFilters = useRecoilCallback(
({ snapshot }) =>
async () => {
if (!isDefined(currentView)) {
if (!canPersistChanges || !isDefined(currentView)) {
return;
}
@@ -107,6 +109,7 @@ export const useSaveRecordFiltersToViewFilters = () => {
}
},
[
canPersistChanges,
currentView,
currentRecordFiltersCallbackState,
createViewFilters,
@@ -2,6 +2,7 @@ import { currentRecordSortsComponentState } from '@/object-record/record-sort/st
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSort';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { getViewSortsToCreate } from '@/views/utils/getViewSortsToCreate';
import { getViewSortsToDelete } from '@/views/utils/getViewSortsToDelete';
@@ -11,6 +12,7 @@ import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordSortsToViewSorts = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const { createViewSorts, updateViewSorts, deleteViewSorts } =
usePersistViewSortRecords();
@@ -23,7 +25,7 @@ export const useSaveRecordSortsToViewSorts = () => {
const saveRecordSortsToViewSorts = useRecoilCallback(
({ snapshot }) =>
async () => {
if (!isDefined(currentView)) {
if (!canPersistChanges || !isDefined(currentView)) {
return;
}
@@ -58,6 +60,7 @@ export const useSaveRecordSortsToViewSorts = () => {
await deleteViewSorts(viewSortsToDelete);
},
[
canPersistChanges,
currentView,
currentRecordSortsCallbackState,
createViewSorts,
@@ -2,6 +2,7 @@ import { useRecoilCallback } from 'recoil';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { type GraphQLView } from '@/views/types/GraphQLView';
@@ -10,6 +11,7 @@ import { isDefined } from 'twenty-shared/utils';
import { useUpdateCoreViewMutation } from '~/generated-metadata/graphql';
export const useUpdateCurrentView = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const currentViewIdCallbackState = useRecoilComponentCallbackState(
contextStoreCurrentViewIdComponentState,
);
@@ -22,6 +24,10 @@ export const useUpdateCurrentView = () => {
const updateCurrentView = useRecoilCallback(
({ snapshot }) =>
async (view: Partial<GraphQLView>) => {
if (!canPersistChanges) {
return;
}
const currentViewId = snapshot
.getLoadable(currentViewIdCallbackState)
.getValue();
@@ -53,6 +59,7 @@ export const useUpdateCurrentView = () => {
}
},
[
canPersistChanges,
currentViewIdCallbackState,
refreshCoreViewsByObjectMetadataId,
updateOneCoreView,
@@ -4,12 +4,14 @@ import { type ExtendedAggregateOperations } from '@/object-record/record-table/t
import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { usePersistView } from '@/views/hooks/internal/usePersistView';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useUpdateViewAggregate = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const currentViewId = useRecoilComponentValue(
contextStoreCurrentViewIdComponentState,
);
@@ -32,6 +34,10 @@ export const useUpdateViewAggregate = () => {
kanbanAggregateOperation: ExtendedAggregateOperations | null;
objectMetadataId: string;
}) => {
if (!canPersistChanges) {
return;
}
const convertedKanbanAggregateOperation = isDefined(
kanbanAggregateOperation,
)
@@ -60,6 +66,7 @@ export const useUpdateViewAggregate = () => {
refreshCoreViewsByObjectMetadataId(objectMetadataId);
},
[
canPersistChanges,
currentViewId,
updateView,
setRecordIndexKanbanAggregateOperationState,
@@ -9,6 +9,7 @@ import {
type ViewKey,
type ViewOpenRecordIn,
type ViewType,
type ViewVisibility,
} from '~/generated-metadata/graphql';
export type CoreViewWithRelations = {
@@ -37,5 +38,7 @@ export type CoreViewWithRelations = {
icon: string;
openRecordIn: ViewOpenRecordIn;
anyFieldFilterValue?: string | null;
visibility: ViewVisibility;
createdByUserWorkspaceId?: string | null;
__typename?: 'CoreView';
};
@@ -7,6 +7,7 @@ import { type ViewGroup } from '@/views/types/ViewGroup';
import { type ViewKey } from '@/views/types/ViewKey';
import { type ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { type ViewType } from '@/views/types/ViewType';
import { type ViewVisibility } from '~/generated-metadata/graphql';
import { type ViewCalendarLayout } from '~/generated/graphql';
export type GraphQLView = {
@@ -33,4 +34,6 @@ export type GraphQLView = {
anyFieldFilterValue?: string | null;
calendarLayout?: ViewCalendarLayout | null;
calendarFieldMetadataId?: string | null;
visibility: ViewVisibility;
createdByUserWorkspaceId?: string | null;
};
@@ -7,6 +7,7 @@ import { type ViewGroup } from '@/views/types/ViewGroup';
import { type ViewKey } from '@/views/types/ViewKey';
import { type ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { type ViewType } from '@/views/types/ViewType';
import { type ViewVisibility } from '~/generated-metadata/graphql';
import { type ViewCalendarLayout } from '~/generated/graphql';
export type View = {
@@ -33,5 +34,7 @@ export type View = {
icon: string;
openRecordIn: ViewOpenRecordInType;
anyFieldFilterValue?: string | null;
visibility: ViewVisibility;
createdByUserWorkspaceId?: string | null;
__typename: 'View';
};
@@ -8,6 +8,7 @@ import { ViewType } from '@/views/types/ViewType';
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ViewVisibility } from '~/generated-metadata/graphql';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
const mockObjectMetadataItemNameSingular = 'company';
@@ -41,6 +42,7 @@ describe('mapRecordFilterGroupToViewFilterGroup', () => {
icon: '',
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
visibility: ViewVisibility.WORKSPACE,
__typename: 'View',
};
@@ -7,6 +7,7 @@ import { convertCoreViewGroupToViewGroup } from '@/views/utils/convertCoreViewGr
import { convertCoreViewKeyToViewKey } from '@/views/utils/convertCoreViewKeyToViewKey';
import { convertCoreViewOpenRecordInToViewOpenRecordIn } from '@/views/utils/convertCoreViewOpenRecordInToViewOpenRecordIn';
import { convertCoreViewTypeToViewType } from '@/views/utils/convertCoreViewTypeToViewType';
import { ViewVisibility } from '~/generated-metadata/graphql';
export const convertCoreViewToView = (
coreView: CoreViewWithRelations,
@@ -47,6 +48,8 @@ export const convertCoreViewToView = (
icon: coreView.icon,
openRecordIn: convertedOpenRecordIn,
anyFieldFilterValue: coreView.anyFieldFilterValue ?? null,
visibility: coreView.visibility ?? ViewVisibility.UNLISTED,
createdByUserWorkspaceId: coreView.createdByUserWorkspaceId,
__typename: 'View',
};
};
@@ -45,5 +45,6 @@ export const convertUpdateViewInputToCore = (
...(isDefined(view.calendarFieldMetadataId) && {
calendarFieldMetadataId: view.calendarFieldMetadataId,
}),
...(isDefined(view.visibility) && { visibility: view.visibility }),
};
};
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
@@ -17,8 +18,11 @@ import { viewPickerKanbanFieldMetadataIdComponentState } from '@/views/view-pick
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { viewPickerSelectedIconComponentState } from '@/views/view-picker/states/viewPickerSelectedIconComponentState';
import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState';
import { viewPickerVisibilityComponentState } from '@/views/view-picker/states/viewPickerVisibilityComponentState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ViewVisibility } from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated/graphql';
export const ViewPickerContentEffect = () => {
const setViewPickerSelectedIcon = useSetRecoilComponentState(
@@ -27,6 +31,9 @@ export const ViewPickerContentEffect = () => {
const setViewPickerInputName = useSetRecoilComponentState(
viewPickerInputNameComponentState,
);
const setViewPickerVisibility = useSetRecoilComponentState(
viewPickerVisibilityComponentState,
);
const { viewPickerMode } = useViewPickerMode();
const [viewPickerKanbanFieldMetadataId, setViewPickerKanbanFieldMetadataId] =
@@ -66,6 +73,7 @@ export const ViewPickerContentEffect = () => {
const { availableFieldsForKanban } = useGetAvailableFieldsForKanban();
const { availableFieldsForCalendar } = useGetAvailableFieldsForCalendar();
const hasViewPermission = useHasPermissionFlag(PermissionFlagType.VIEWS);
useEffect(() => {
if (
@@ -81,6 +89,9 @@ export const ViewPickerContentEffect = () => {
} else {
setViewPickerSelectedIcon(referenceView.icon);
}
setViewPickerVisibility(
hasViewPermission ? referenceView.visibility : ViewVisibility.UNLISTED,
);
setViewPickerInputName(referenceView.name);
setViewPickerType(referenceView.type);
}
@@ -89,10 +100,12 @@ export const ViewPickerContentEffect = () => {
setViewPickerInputName,
setViewPickerSelectedIcon,
setViewPickerType,
setViewPickerVisibility,
viewPickerIsPersisting,
viewPickerIsDirty,
viewPickerMode,
viewPickerType,
hasViewPermission,
]);
useEffect(() => {
@@ -29,7 +29,7 @@ export const ViewPickerEditButton = () => {
if (viewPickerMode === 'edit') {
return (
<Button
title="Delete"
title={t`Delete`}
onClick={deleteViewFromCurrentState}
accent="danger"
fullWidth
@@ -64,7 +64,7 @@ export const ViewPickerEditButton = () => {
) {
return (
<Button
title="Create"
title={t`Create`}
onClick={createViewFromCurrentState}
accent="blue"
fullWidth
@@ -7,6 +7,7 @@ import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableIt
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
@@ -23,6 +24,7 @@ import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { IconPlus } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { ViewVisibility } from '~/generated-metadata/graphql';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
const StyledBoldDropdownMenuItemsContainer = styled(DropdownMenuItemsContainer)`
@@ -40,6 +42,17 @@ export const ViewPickerListContent = () => {
}),
);
const workspaceViews = viewsOnCurrentObject.filter(
(view) => view.visibility === ViewVisibility.WORKSPACE,
);
const unlistedViews = viewsOnCurrentObject.filter(
(view) => view.visibility === ViewVisibility.UNLISTED,
);
const shouldShowSectionLabels =
workspaceViews.length > 0 && unlistedViews.length > 0;
const { currentView } = useGetCurrentViewOnly();
const setViewPickerReferenceViewId = useSetRecoilComponentState(
@@ -73,11 +86,11 @@ export const ViewPickerListContent = () => {
setViewPickerMode('edit');
};
const handleDragEnd = useCallback(
const handleWorkspaceDragEnd = useCallback(
async (result: DropResult) => {
if (!result.destination) return;
const viewsReordered = moveArrayItem(viewsOnCurrentObject, {
const viewsReordered = moveArrayItem(workspaceViews, {
fromIndex: result.source.index,
toIndex: result.destination.index,
});
@@ -90,35 +103,94 @@ export const ViewPickerListContent = () => {
}),
);
},
[updateView, viewsOnCurrentObject],
[updateView, workspaceViews],
);
const handleUnlistedDragEnd = useCallback(
async (result: DropResult) => {
if (!result.destination) return;
const viewsReordered = moveArrayItem(unlistedViews, {
fromIndex: result.source.index,
toIndex: result.destination.index,
});
Promise.all(
viewsReordered.map(async (view, index) => {
if (view.position !== index) {
await updateView({ id: view.id, input: { position: index } });
}
}),
);
},
[updateView, unlistedViews],
);
return (
<DropdownContent>
<DropdownMenuItemsContainer hasMaxHeight>
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={viewsOnCurrentObject.map((view, index) => {
const isIndexView = view.key === 'INDEX';
return (
<DraggableItem
key={view.id}
draggableId={view.id}
index={index}
isDragDisabled={viewsOnCurrentObject.length === 1}
itemComponent={
<ViewPickerOptionDropdown
view={{ ...view, __typename: 'View' }}
handleViewSelect={handleViewSelect}
isIndexView={isIndexView}
onEdit={handleEditViewButtonClick}
{workspaceViews.length > 0 && (
<>
{shouldShowSectionLabels && (
<DropdownMenuSectionLabel label={t`Workspace`} />
)}
<DropdownMenuItemsContainer hasMaxHeight>
<DraggableList
onDragEnd={handleWorkspaceDragEnd}
draggableItems={workspaceViews.map((view, index) => {
const isIndexView = view.key === 'INDEX';
return (
<DraggableItem
key={view.id}
draggableId={view.id}
index={index}
isDragDisabled={workspaceViews.length === 1}
itemComponent={
<ViewPickerOptionDropdown
view={{ ...view, __typename: 'View' }}
handleViewSelect={handleViewSelect}
isIndexView={isIndexView}
onEdit={handleEditViewButtonClick}
/>
}
/>
}
/>
);
})}
/>
</DropdownMenuItemsContainer>
);
})}
/>
</DropdownMenuItemsContainer>
</>
)}
{unlistedViews.length > 0 && (
<>
{shouldShowSectionLabels && <DropdownMenuSeparator />}
{shouldShowSectionLabels && (
<DropdownMenuSectionLabel label={t`My unlisted views`} />
)}
<DropdownMenuItemsContainer hasMaxHeight>
<DraggableList
onDragEnd={handleUnlistedDragEnd}
draggableItems={unlistedViews.map((view, index) => {
const isIndexView = view.key === 'INDEX';
return (
<DraggableItem
key={view.id}
draggableId={view.id}
index={index}
isDragDisabled={unlistedViews.length === 1}
itemComponent={
<ViewPickerOptionDropdown
view={{ ...view, __typename: 'View' }}
handleViewSelect={handleViewSelect}
isIndexView={isIndexView}
onEdit={handleEditViewButtonClick}
/>
}
/>
);
})}
/>
</DropdownMenuItemsContainer>
</>
)}
<DropdownMenuSeparator />
<StyledBoldDropdownMenuItemsContainer scrollable={false}>
<MenuItem
@@ -1,5 +1,6 @@
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
@@ -10,7 +11,6 @@ import { useDeleteViewFromCurrentState } from '@/views/view-picker/hooks/useDele
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
IconHeart,
IconLock,
@@ -19,10 +19,20 @@ import {
useIcons,
} from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { ViewVisibility } from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated/graphql';
type ViewPickerOptionDropdownProps = {
isIndexView: boolean;
view: Pick<View, 'id' | 'name' | 'icon' | '__typename'>;
view: Pick<
View,
| 'id'
| 'name'
| 'icon'
| '__typename'
| 'visibility'
| 'createdByUserWorkspaceId'
>;
onEdit: (event: React.MouseEvent<HTMLElement>, viewId: string) => void;
handleViewSelect: (viewId: string) => void;
};
@@ -38,16 +48,21 @@ export const ViewPickerOptionDropdown = ({
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
const [isHovered, setIsHovered] = useState(false);
const { deleteViewFromCurrentState } = useDeleteViewFromCurrentState();
const setViewPickerReferenceViewId = useSetRecoilComponentState(
viewPickerReferenceViewIdComponentState,
);
const { setViewPickerMode } = useViewPickerMode();
const hasViewsPermission = useHasPermissionFlag(PermissionFlagType.VIEWS);
const { sortedFavorites: favorites } = useFavorites();
const { createFavorite } = useCreateFavorite();
// Users with VIEWS permission can edit all views
// Users without VIEWS permission can only edit unlisted views (which are always their own, filtered by backend)
const canEditView =
hasViewsPermission || view.visibility === ViewVisibility.UNLISTED;
const isFavorite = favorites.some(
(favorite) =>
favorite.recordId === view.id && favorite.forWorkspaceMemberId,
@@ -69,18 +84,24 @@ export const ViewPickerOptionDropdown = ({
closeDropdown(dropdownId);
};
const getVisibilityIcon = () => {
if (isIndexView) {
return IconLock;
}
return null;
};
const shouldShowIconAlways = isIndexView;
return (
<>
<MenuItemWithOptionDropdown
text={view.name}
LeftIcon={getIcon(view.icon)}
onClick={() => handleViewSelect(view.id)}
isIconDisplayedOnHoverOnly={!isIndexView}
RightIcon={!isHovered && isIndexView ? IconLock : null}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => {
setIsHovered(false);
}}
isIconDisplayedOnHoverOnly={!shouldShowIconAlways}
RightIcon={getVisibilityIcon()}
dropdownPlacement="bottom-start"
dropdownId={`view-picker-options-${view.id}`}
dropdownContent={
@@ -100,20 +121,24 @@ export const ViewPickerOptionDropdown = ({
onClick={handleAddToFavorites}
/>
<MenuItem
LeftIcon={IconPencil}
text={t`Edit`}
onClick={(event) => {
onEdit(event, view.id);
closeDropdown(dropdownId);
}}
/>
<MenuItem
LeftIcon={IconTrash}
text={t`Delete`}
onClick={handleDelete}
accent="danger"
/>
{canEditView && (
<>
<MenuItem
LeftIcon={IconPencil}
text={t`Edit`}
onClick={(event) => {
onEdit(event, view.id);
closeDropdown(dropdownId);
}}
/>
<MenuItem
LeftIcon={IconTrash}
text={t`Delete`}
onClick={handleDelete}
accent="danger"
/>
</>
)}
</>
)}
</DropdownMenuItemsContainer>
@@ -11,6 +11,7 @@ import { viewPickerKanbanFieldMetadataIdComponentState } from '@/views/view-pick
import { viewPickerModeComponentState } from '@/views/view-picker/states/viewPickerModeComponentState';
import { viewPickerSelectedIconComponentState } from '@/views/view-picker/states/viewPickerSelectedIconComponentState';
import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState';
import { viewPickerVisibilityComponentState } from '@/views/view-picker/states/viewPickerVisibilityComponentState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -51,6 +52,10 @@ export const useCreateViewFromCurrentState = () => {
viewPickerModeComponentState,
);
const viewPickerVisibilityCallbackState = useRecoilComponentCallbackState(
viewPickerVisibilityComponentState,
);
const { createViewFromCurrentView } = useCreateViewFromCurrentView();
const { changeView } = useChangeView();
@@ -78,6 +83,10 @@ export const useCreateViewFromCurrentState = () => {
snapshot,
viewPickerModeCallbackState,
);
const visibility = getSnapshotValue(
snapshot,
viewPickerVisibilityCallbackState,
);
const shouldCopyFiltersAndSortsAndAggregate =
viewPickerMode === 'create-from-current';
@@ -92,6 +101,7 @@ export const useCreateViewFromCurrentState = () => {
type,
kanbanFieldMetadataId,
calendarFieldMetadataId,
visibility,
},
shouldCopyFiltersAndSortsAndAggregate,
);
@@ -113,6 +123,7 @@ export const useCreateViewFromCurrentState = () => {
viewPickerSelectedIconCallbackState,
viewPickerTypeCallbackState,
viewPickerModeCallbackState,
viewPickerVisibilityCallbackState,
],
);
@@ -1,6 +1,7 @@
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { usePersistView } from '@/views/hooks/internal/usePersistView';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useChangeView } from '@/views/hooks/useChangeView';
import { useCloseAndResetViewPicker } from '@/views/view-picker/hooks/useCloseAndResetViewPicker';
import { viewPickerInputNameComponentState } from '@/views/view-picker/states/viewPickerInputNameComponentState';
@@ -8,9 +9,11 @@ import { viewPickerIsDirtyComponentState } from '@/views/view-picker/states/view
import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { viewPickerSelectedIconComponentState } from '@/views/view-picker/states/viewPickerSelectedIconComponentState';
import { viewPickerVisibilityComponentState } from '@/views/view-picker/states/viewPickerVisibilityComponentState';
import { useRecoilCallback } from 'recoil';
export const useUpdateViewFromCurrentState = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const { closeAndResetViewPicker } = useCloseAndResetViewPicker();
const viewPickerInputNameCallbackState = useRecoilComponentCallbackState(
@@ -32,12 +35,21 @@ export const useUpdateViewFromCurrentState = () => {
const viewPickerReferenceViewIdCallbackState =
useRecoilComponentCallbackState(viewPickerReferenceViewIdComponentState);
const viewPickerVisibilityCallbackState = useRecoilComponentCallbackState(
viewPickerVisibilityComponentState,
);
const { updateView } = usePersistView();
const { changeView } = useChangeView();
const updateViewFromCurrentState = useRecoilCallback(
({ set, snapshot }) =>
async () => {
if (!canPersistChanges) {
closeAndResetViewPicker();
return;
}
set(viewPickerIsPersistingCallbackState, true);
set(viewPickerIsDirtyCallbackState, false);
closeAndResetViewPicker();
@@ -54,23 +66,30 @@ export const useUpdateViewFromCurrentState = () => {
snapshot,
viewPickerSelectedIconCallbackState,
);
const visibility = getSnapshotValue(
snapshot,
viewPickerVisibilityCallbackState,
);
await updateView({
id: viewPickerReferenceViewId,
input: {
name: viewPickerInputName,
icon: viewPickerSelectedIcon,
visibility: visibility,
},
});
changeView(viewPickerReferenceViewId);
},
[
canPersistChanges,
viewPickerIsPersistingCallbackState,
viewPickerIsDirtyCallbackState,
closeAndResetViewPicker,
viewPickerReferenceViewIdCallbackState,
viewPickerInputNameCallbackState,
viewPickerSelectedIconCallbackState,
viewPickerVisibilityCallbackState,
updateView,
changeView,
],
@@ -0,0 +1,10 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { ViewVisibility } from '~/generated-metadata/graphql';
export const viewPickerVisibilityComponentState =
createComponentState<ViewVisibility>({
key: 'viewPickerVisibilityComponentState',
defaultValue: ViewVisibility.UNLISTED,
componentInstanceContext: ViewComponentInstanceContext,
});