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:
@@ -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
|
||||
}
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+4
-1
@@ -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';
|
||||
};
|
||||
|
||||
+2
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
+13
@@ -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(() => {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+98
-26
@@ -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
|
||||
|
||||
+48
-23
@@ -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
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+19
@@ -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,
|
||||
],
|
||||
|
||||
+10
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user