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
@@ -161,6 +161,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
isDefined(selectedRecord) && !selectedRecord.isRemote,
availableOn: [ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION],
component: <ExportMultipleRecordsAction />,
requiredPermissionFlag: PermissionFlagType.EXPORT_CSV,
},
[SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW]: {
type: ActionType.Standard,
@@ -12,6 +12,7 @@ import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
export const RestoreMultipleRecordsAction = () => {
@@ -86,10 +87,10 @@ export const RestoreMultipleRecordsAction = () => {
return (
<ActionModal
title="Restore Records"
subtitle="Are you sure you want to restore these records?"
title={t`Restore Records`}
subtitle={t`Are you sure you want to restore these records?`}
onConfirmClick={handleRestoreClick}
confirmButtonText="Restore Records"
confirmButtonText={t`Restore Records`}
confirmButtonAccent="default"
/>
);
@@ -39,10 +39,10 @@ export const DeleteSingleRecordAction = () => {
return (
<ActionModal
title="Delete Record"
title={t`Delete Record`}
subtitle={t`Are you sure you want to delete this record? It can be recovered from the Command menu.`}
onConfirmClick={handleDeleteClick}
confirmButtonText="Delete Record"
confirmButtonText={t`Delete Record`}
/>
);
};
@@ -3,6 +3,7 @@ import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
import { AppPath } from 'twenty-shared/types';
import { useNavigateApp } from '~/hooks/useNavigateApp';
@@ -31,10 +32,10 @@ export const DestroySingleRecordAction = () => {
return (
<ActionModal
title="Permanently Destroy Record"
subtitle="Are you sure you want to destroy this record? It cannot be recovered anymore."
title={t`Permanently Destroy Record`}
subtitle={t`Are you sure you want to destroy this record? It cannot be recovered anymore.`}
onConfirmClick={handleDeleteClick}
confirmButtonText="Permanently Destroy Record"
confirmButtonText={t`Permanently Destroy Record`}
closeSidePanelOnShowPageOptionsActionExecution={true}
/>
);
@@ -3,6 +3,7 @@ import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
export const RestoreSingleRecordAction = () => {
const { recordIndexId, objectMetadataItem } =
@@ -26,10 +27,10 @@ export const RestoreSingleRecordAction = () => {
return (
<ActionModal
title="Restore Record"
subtitle="Are you sure you want to restore this record?"
title={t`Restore Record`}
subtitle={t`Are you sure you want to restore this record?`}
onConfirmClick={handleRestoreClick}
confirmButtonText="Restore Record"
confirmButtonText={t`Restore Record`}
confirmButtonAccent="default"
/>
);
@@ -13,6 +13,7 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
@@ -65,7 +66,7 @@ export const CommandMenuActionMenuDropdown = () => {
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button title="Options" hotkeys={[getOsControlSymbol(), 'O']} />
<Button title={t`Options`} hotkeys={[getOsControlSymbol(), 'O']} />
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
@@ -17,6 +17,7 @@ import { useComponentInstanceStateContext } from '@/ui/utilities/state/component
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { t } from '@lingui/core/macro';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -131,7 +132,7 @@ export const RecordShowRightDrawerOpenRecordButton = ({
return (
<Button
title="Open"
title={t`Open`}
variant="primary"
accent="blue"
size="medium"
@@ -7,6 +7,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { type AttachmentFileCategory } from '@/activities/files/types/AttachmentFileCategory';
import { getFileType } from '@/activities/files/utils/getFileType';
import { FileIcon } from '@/file/components/FileIcon';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
@@ -106,7 +107,10 @@ export const FileBlock = createReactBlockSpec(
onChange={handleFileChange}
type="file"
/>
<Button onClick={handleUploadFileClick} title="Upload File"></Button>
<Button
onClick={handleUploadFileClick}
title={t`Upload File`}
></Button>
</StyledUploadFileContainer>
);
},
@@ -1,6 +1,7 @@
import styled from '@emotion/styled';
import { Button } from 'twenty-ui/input';
import { t } from '@lingui/core/macro';
import { IconArrowBackUp, IconUserCircle } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
const StyledThreadBottomBar = styled.div`
align-items: center;
@@ -16,19 +17,19 @@ export const ThreadBottomBar = () => {
<StyledThreadBottomBar>
<Button
Icon={IconArrowBackUp}
title="Reply"
title={t`Reply`}
variant="secondary"
accent="default"
/>
<Button
Icon={IconArrowBackUp}
title="Reply to all"
title={t`Reply to all`}
variant="secondary"
accent="default"
/>
<Button
Icon={IconUserCircle}
title="Share"
title={t`Share`}
variant="secondary"
accent="default"
/>
@@ -17,6 +17,7 @@ type AttachmentDropdownProps = {
onDelete: () => void;
onRename: () => void;
attachmentId: string;
hasDownloadPermission: boolean;
};
export const AttachmentDropdown = ({
@@ -24,6 +25,7 @@ export const AttachmentDropdown = ({
onDelete,
onRename,
attachmentId,
hasDownloadPermission,
}: AttachmentDropdownProps) => {
const dropdownId = `${attachmentId}-attachment-dropdown`;
@@ -53,11 +55,13 @@ export const AttachmentDropdown = ({
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
text="Download"
LeftIcon={IconDownload}
onClick={handleDownload}
/>
{hasDownloadPermission && (
<MenuItem
text="Download"
LeftIcon={IconDownload}
onClick={handleDownload}
/>
)}
<MenuItem
text="Rename"
LeftIcon={IconPencil}
@@ -12,10 +12,12 @@ import { Modal } from '@/ui/layout/modal/components/Modal';
import { useRecoilValue } from 'recoil';
import { ActivityList } from '@/activities/components/ActivityList';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { IconDownload, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { AttachmentRow } from './AttachmentRow';
const DocumentViewer = lazy(() =>
@@ -131,6 +133,14 @@ export const AttachmentList = ({
isAttachmentPreviewEnabledState,
);
const hasDownloadPermission = useHasPermissionFlag(
PermissionFlagType.DOWNLOAD_FILE,
);
const hasUploadPermission = useHasPermissionFlag(
PermissionFlagType.UPLOAD_FILE,
);
const { openModal, closeModal } = useModal();
const onUploadFile = async (file: File) => {
@@ -169,8 +179,10 @@ export const AttachmentList = ({
</StyledTitle>
{button}
</StyledTitleBar>
<StyledDropZoneContainer onDragEnter={() => setIsDraggingFile(true)}>
{isDraggingFile ? (
<StyledDropZoneContainer
onDragEnter={() => hasUploadPermission && setIsDraggingFile(true)}
>
{isDraggingFile && hasUploadPermission ? (
<DropZone
setIsDraggingFile={setIsDraggingFile}
onUploadFiles={onUploadFiles}
@@ -204,11 +216,13 @@ export const AttachmentList = ({
<StyledHeader>
<StyledModalTitle>{previewedAttachment.name}</StyledModalTitle>
<StyledButtonContainer>
<IconButton
Icon={IconDownload}
onClick={handleDownload}
size="small"
/>
{hasDownloadPermission && (
<IconButton
Icon={IconDownload}
onClick={handleDownload}
size="small"
/>
)}
<IconButton
Icon={IconX}
onClick={handleClosePreview}
@@ -17,8 +17,10 @@ import { isDefined } from 'twenty-shared/utils';
import { PREVIEWABLE_EXTENSIONS } from '@/activities/files/const/previewable-extensions.const';
import { FileIcon } from '@/file/components/FileIcon';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { isNavigationModifierPressed } from 'twenty-ui/utilities';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { formatToHumanReadableDate } from '~/utils/date-utils';
import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension';
@@ -81,6 +83,10 @@ export const AttachmentRow = ({
const theme = useTheme();
const [isEditing, setIsEditing] = useState(false);
const hasDownloadPermission = useHasPermissionFlag(
PermissionFlagType.DOWNLOAD_FILE,
);
const { name: originalFileName, extension: attachmentFileExtension } =
getFileNameAndExtension(attachment.name);
@@ -203,6 +209,7 @@ export const AttachmentRow = ({
onDelete={handleDelete}
onDownload={handleDownload}
onRename={handleRename}
hasDownloadPermission={hasDownloadPermission}
/>
</StyledRightContent>
</ActivityRow>
@@ -8,6 +8,7 @@ import { useAttachments } from '@/activities/files/hooks/useAttachments';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { Trans, useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -21,6 +22,7 @@ import {
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/layout';
import { PermissionFlagType } from '~/generated-metadata/graphql';
const StyledAttachmentsContainer = styled.div`
display: flex;
@@ -80,14 +82,22 @@ export const FilesCard = () => {
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const hasUploadPermission = useHasPermissionFlag(
PermissionFlagType.UPLOAD_FILE,
);
const canUploadFiles = hasObjectUpdatePermissions && hasUploadPermission;
if (loading && isAttachmentsEmpty) {
return <SkeletonLoader />;
}
if (isAttachmentsEmpty) {
return (
<StyledDropZoneContainer onDragEnter={() => setIsDraggingFile(true)}>
{isDraggingFile ? (
<StyledDropZoneContainer
onDragEnter={() => canUploadFiles && setIsDraggingFile(true)}
>
{isDraggingFile && canUploadFiles ? (
<DropZone
setIsDraggingFile={setIsDraggingFile}
onUploadFiles={onUploadFiles}
@@ -112,7 +122,7 @@ export const FilesCard = () => {
type="file"
multiple
/>
{hasObjectUpdatePermissions && (
{canUploadFiles && (
<Button
Icon={IconPlus}
title={t`Add file`}
@@ -139,7 +149,7 @@ export const FilesCard = () => {
title={t`All`}
attachments={attachments ?? []}
button={
hasObjectUpdatePermissions && (
canUploadFiles && (
<Button
Icon={IconPlus}
size="small"
@@ -7,6 +7,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
@@ -68,7 +69,7 @@ export const NotesCard = () => {
{hasObjectUpdatePermissions && (
<Button
Icon={IconPlus}
title="New note"
title={t`New note`}
variant="secondary"
onClick={() =>
openCreateActivity({
@@ -84,7 +85,7 @@ export const NotesCard = () => {
return (
<StyledNotesContainer>
<NoteList
title="All"
title={t`All`}
notes={notes}
button={
hasObjectUpdatePermissions && (
@@ -92,7 +93,7 @@ export const NotesCard = () => {
Icon={IconPlus}
size="small"
variant="secondary"
title="Add note"
title={t`Add note`}
onClick={() =>
openCreateActivity({
targetableObjects: [targetRecord],
@@ -3,6 +3,7 @@ import { type ActivityTargetableObject } from '@/activities/types/ActivityTarget
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { t } from '@lingui/core/macro';
import { IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -34,7 +35,7 @@ export const AddTaskButton = ({
Icon={IconPlus}
size="small"
variant="secondary"
title="Add task"
title={t`Add task`}
onClick={() =>
openCreateActivity({
targetableObjects: [activityTargetableObject],
@@ -10,6 +10,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import groupBy from 'lodash.groupby';
import { IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -86,7 +87,7 @@ export const TaskGroups = ({ targetableObject }: TaskGroupsProps) => {
{hasObjectUpdatePermissions && (
<Button
Icon={IconPlus}
title="New task"
title={t`New task`}
variant="secondary"
onClick={() =>
openCreateActivity({
@@ -7,6 +7,7 @@ import { useTimelineActivities } from '@/activities/timeline-activities/hooks/us
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { t } from '@lingui/core/macro';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
@@ -85,7 +86,7 @@ export const TimelineCard = () => {
<StyledMainContainer>
<EventList
targetableObject={targetRecord}
title="All"
title={t`All`}
events={timelineActivities ?? []}
/>
<CustomResolverFetchMoreLoader
@@ -6,6 +6,7 @@ import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoa
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { t } from '@lingui/core/macro';
import { Key } from 'ts-key-enum';
import { capitalize } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
@@ -71,7 +72,7 @@ export const AIChatThreadsList = () => {
variant="primary"
accent="blue"
size="medium"
title="New chat"
title={t`New chat`}
onClick={() => createChatThread()}
hotkeys={[getOsControlSymbol(), '⏎']}
/>
@@ -10,6 +10,7 @@ import { useEmailThreadInCommandMenu } from '@/command-menu/pages/message-thread
import { messageThreadComponentState } from '@/command-menu/pages/message-thread/states/messageThreadComponentState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { t } from '@lingui/core/macro';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp } from 'twenty-ui/display';
@@ -172,7 +173,7 @@ export const CommandMenuMessageThreadPage = () => {
<StyledButtonContainer isMobile={isMobile}>
<Button
onClick={handleReplyClick}
title="Reply"
title={t`Reply`}
Icon={IconArrowBackUp}
disabled={!canReply}
/>
@@ -25,7 +25,7 @@ export const AppErrorDisplay = ({
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={IconRefresh}
title="Reload"
title={t`Reload`}
variant="secondary"
onClick={resetErrorBoundary}
/>
@@ -9,7 +9,11 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { coreViewsState } from '@/views/states/coreViewState';
import { AppPath } from 'twenty-shared/types';
import { ViewOpenRecordIn, ViewType } from '~/generated/graphql';
import {
ViewOpenRecordIn,
ViewType,
ViewVisibility,
} from '~/generated/graphql';
import { getMockCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
import { mockedUserData } from '~/testing/mock-data/users';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
@@ -53,6 +57,8 @@ const renderHooks = ({
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
viewFilters: [],
visibility: ViewVisibility.WORKSPACE,
createdByUserWorkspaceId: null,
},
]);
} else {
@@ -8,6 +8,7 @@ import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUp
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyState';
import { hasInitializedCurrentRecordFiltersComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
@@ -86,8 +87,8 @@ export const AdvancedFilterCommandMenuCreateRootFilterButton = ({
variant="secondary"
accent="default"
onClick={addRootRecordFilterGroup}
ariaLabel="Add filter"
title="Add filter"
ariaLabel={t`Add filter`}
title={t`Add filter`}
disabled={readonly}
/>
);
@@ -9,6 +9,7 @@ import { ObjectOptionsDropdownMenuContent } from '@/object-record/object-options
import { ObjectOptionsDropdownRecordGroupFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent';
import { ObjectOptionsDropdownRecordGroupsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent';
import { ObjectOptionsDropdownRecordGroupSortContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent';
import { ObjectOptionsDropdownVisibilityContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownVisibilityContent';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
export const ObjectOptionsDropdownContent = () => {
@@ -35,6 +36,8 @@ export const ObjectOptionsDropdownContent = () => {
return <ObjectOptionsDropdownCalendarViewContent />;
case 'calendarFields':
return <ObjectOptionsDropdownCalendarFieldsContent />;
case 'visibility':
return <ObjectOptionsDropdownVisibilityContent />;
default:
return <ObjectOptionsDropdownMenuContent />;
}
@@ -4,10 +4,10 @@ import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdow
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
@@ -18,7 +18,6 @@ import { ViewKey } from '@/views/types/ViewKey';
import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType';
import { useDeleteViewFromCurrentState } from '@/views/view-picker/hooks/useDeleteViewFromCurrentState';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
@@ -26,9 +25,9 @@ import {
AppTooltip,
IconCalendar,
IconCalendarWeek,
IconCopy,
IconLayoutList,
IconListDetails,
IconShare,
IconTrash,
} from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
@@ -92,17 +91,14 @@ export const ObjectOptionsDropdownCustomView = ({
onBackToDefault?.();
};
const theme = useTheme();
const { enqueueSuccessSnackBar } = useSnackBar();
const selectableItemIdArray = [
'Layout',
'Visibility',
'Fields',
...(customViewData?.type === ViewType.Calendar
? ['CalendarDateField', 'CalendarView']
: []),
...(customViewData?.type !== ViewType.Calendar ? ['Group'] : []),
'Copy link to view',
'Delete view',
];
@@ -116,7 +112,7 @@ export const ObjectOptionsDropdownCustomView = ({
}
return (
<DropdownContent>
<DropdownContent widthInPixels={GenericDropdownContentWidth.Large}>
<ObjectOptionsDropdownMenuViewName currentView={customViewData} />
<DropdownMenuSeparator />
<SelectableList
@@ -141,6 +137,24 @@ export const ObjectOptionsDropdownCustomView = ({
hasSubMenu
/>
</SelectableListItem>
<SelectableListItem
itemId="Visibility"
onEnter={() => onContentChange('visibility')}
>
<MenuItem
focused={selectedItemId === 'Visibility'}
onClick={() => onContentChange('visibility')}
LeftIcon={IconShare}
text={t`Visibility`}
contextualText={
customViewData?.visibility === 'UNLISTED'
? t`Unlisted`
: t`Workspace`
}
contextualTextPosition="right"
hasSubMenu
/>
</SelectableListItem>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
@@ -245,37 +259,6 @@ export const ObjectOptionsDropdownCustomView = ({
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<SelectableListItem
itemId="Copy link to view"
onEnter={() => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
}}
>
<MenuItem
focused={selectedItemId === 'Copy link to view'}
onClick={() => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
}}
LeftIcon={IconCopy}
text={t`Copy link to view`}
/>
</SelectableListItem>
<div id="delete-view-menu-item">
<SelectableListItem
itemId="Delete view"
@@ -0,0 +1,162 @@
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { createPortal } from 'react-dom';
import {
AppTooltip,
IconChevronLeft,
IconCircle,
IconCircleDashed,
IconCopy,
} from 'twenty-ui/display';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { ViewVisibility } from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated/graphql';
export const ObjectOptionsDropdownVisibilityContent = () => {
const { t } = useLingui();
const theme = useTheme();
const { resetContent } = useObjectOptionsDropdown();
const { currentView } = useGetCurrentViewOnly();
const { updateCurrentView } = useUpdateCurrentView();
const { enqueueSuccessSnackBar } = useSnackBar();
const hasViewsPermission = useHasPermissionFlag(PermissionFlagType.VIEWS);
const { canPersistChanges } = useCanPersistViewChanges();
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
OBJECT_OPTIONS_DROPDOWN_ID,
);
const selectableItemIdArray = [
ViewVisibility.WORKSPACE,
ViewVisibility.UNLISTED,
];
const handleVisibilityChange = (visibility: ViewVisibility) => {
if (!canPersistChanges || !currentView) return;
updateCurrentView({
visibility,
});
resetContent();
};
const handleCopyLink = () => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
};
const currentVisibility = currentView?.visibility ?? ViewVisibility.WORKSPACE;
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.Large}>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={resetContent}
Icon={IconChevronLeft}
/>
}
>
{t`Visibility`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
>
<SelectableListItem
itemId={ViewVisibility.WORKSPACE}
onEnter={() =>
hasViewsPermission &&
canPersistChanges &&
handleVisibilityChange(ViewVisibility.WORKSPACE)
}
>
<>
<div id="workspace-visibility-option">
<MenuItemSelect
LeftIcon={IconCircle}
text={t`Workspace`}
contextualText={t`Everyone`}
selected={currentVisibility === ViewVisibility.WORKSPACE}
focused={selectedItemId === ViewVisibility.WORKSPACE}
onClick={() =>
handleVisibilityChange(ViewVisibility.WORKSPACE)
}
disabled={!hasViewsPermission || !canPersistChanges}
/>
</div>
{!hasViewsPermission &&
createPortal(
<AppTooltip
anchorSelect="#workspace-visibility-option"
content={t`Workspace views require manage views permission`}
positionStrategy="fixed"
/>,
document.body,
)}
</>
</SelectableListItem>
<SelectableListItem
itemId={ViewVisibility.UNLISTED}
onEnter={() =>
canPersistChanges &&
handleVisibilityChange(ViewVisibility.UNLISTED)
}
>
<MenuItemSelect
LeftIcon={IconCircleDashed}
text={t`Unlisted`}
contextualText={t`Visible to you`}
selected={currentVisibility === ViewVisibility.UNLISTED}
focused={selectedItemId === ViewVisibility.UNLISTED}
onClick={() => handleVisibilityChange(ViewVisibility.UNLISTED)}
disabled={!canPersistChanges}
/>
</SelectableListItem>
{currentVisibility === ViewVisibility.WORKSPACE && (
<>
<DropdownMenuSeparator />
<SelectableListItem
itemId="Copy view link"
onEnter={handleCopyLink}
>
<MenuItem
focused={selectedItemId === 'Copy view link'}
onClick={handleCopyLink}
LeftIcon={IconCopy}
text={t`Copy view link`}
/>
</SelectableListItem>
</>
)}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -8,4 +8,5 @@ export type ObjectOptionsContentId =
| 'recordGroupFields'
| 'recordGroupSort'
| 'calendarFields'
| 'calendarView';
| 'calendarView'
| 'visibility';
@@ -44,7 +44,7 @@ export const AggregateDropdownContent = () => {
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregations}
title="Count"
title={t`Count`}
/>
);
}
@@ -57,7 +57,7 @@ export const AggregateDropdownContent = () => {
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregations}
title="Percent"
title={t`Percent`}
/>
);
}
@@ -86,7 +86,7 @@ export const AggregateDropdownContent = () => {
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregationsWithoutDates}
title="More options"
title={t`More options`}
/>
);
}
@@ -11,6 +11,7 @@ import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { addMonths, format, subMonths } from 'date-fns';
import { useRecoilValue } from 'recoil';
import { type Nullable } from 'twenty-shared/types';
@@ -144,7 +145,7 @@ export const RecordCalendarTopBar = () => {
<Button
size="small"
variant="tertiary"
title="Today"
title={t`Today`}
onClick={handleTodayClick}
/>
<StyledNavigationButton
@@ -6,6 +6,7 @@ import { RecordDetailRecordsListContainer } from '@/object-record/record-field-l
import { RecordDetailRecordsListItemContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailRecordsListItemContainer';
import { RecordDetailSectionContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailSectionContainer';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowMerge } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
@@ -44,7 +45,7 @@ export const RecordDetailDuplicatesSection = ({
return (
<RecordDetailSectionContainer
title="Duplicates"
title={t`Duplicates`}
rightAdornment={
<LightIconButton
className="displayOnHover"
@@ -2,6 +2,7 @@ import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/reco
import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { createPortal } from 'react-dom';
@@ -21,10 +22,10 @@ export const RecordGroupReorderConfirmationModal = ({
{createPortal(
<ConfirmationModal
modalId={RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID}
title="Group sorting"
subtitle={`Would you like to remove ${recordGroupSort} group sorting?`}
title={t`Group sorting`}
subtitle={t`Would you like to remove ${recordGroupSort} group sorting?`}
onConfirmClick={onConfirmClick}
confirmButtonText="Remove"
confirmButtonText={t`Remove`}
/>,
document.body,
)}
@@ -2,6 +2,7 @@ import { type ConfigVariableFilterCategory } from '@/settings/admin-panel/config
import { type ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { type ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { IconSettings } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -41,7 +42,7 @@ export const ConfigVariableFilterDropdown = ({
<Button
variant="secondary"
size="medium"
title="Options"
title={t`Options`}
Icon={IconSettings}
/>
}
@@ -3,12 +3,13 @@ import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
SettingsAvailableStandardObjectItemTableRow,
StyledAvailableStandardObjectTableRow,
} from './SettingsAvailableStandardObjectItemTableRow';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
type SettingsAvailableStandardObjectsSectionProps = {
objectItems: ObjectMetadataItem[];
@@ -23,8 +24,8 @@ export const SettingsAvailableStandardObjectsSection = ({
}: SettingsAvailableStandardObjectsSectionProps) => (
<Section>
<H2Title
title="Available"
description="Select one or several standard objects to activate below"
title={t`Available`}
description={t`Select one or several standard objects to activate below`}
/>
<Table>
<StyledAvailableStandardObjectTableRow>
@@ -1,7 +1,8 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
type ObjectIndexesProps = {
objectMetadataItem: ObjectMetadataItem;
@@ -11,8 +12,8 @@ export const ObjectIndexes = ({ objectMetadataItem }: ObjectIndexesProps) => {
return (
<Section>
<H2Title
title="Indexes"
description={`Advanced feature to improve the performance of queries and to enforce unicity constraints.`}
title={t`Indexes`}
description={t`Advanced feature to improve the performance of queries and to enforce unicity constraints.`}
/>
<SettingsObjectIndexTable objectMetadataItem={objectMetadataItem} />
</Section>
@@ -1,5 +1,6 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsDnsRecordsTable } from '@/settings/components/SettingsDnsRecordsTable';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
@@ -57,8 +58,8 @@ export const SettingsDomainRecords = ({
return (
<Section>
<H2Title
title="Domain Setup"
description="Configure these DNS records with your domain provider"
title={t`Domain Setup`}
description={t`Configure these DNS records with your domain provider`}
/>
<SettingsDnsRecordsTable records={transformedRecords} />
</Section>
@@ -2,9 +2,10 @@ import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { type SettingsIntegration } from '@/settings/integrations/types/SettingsIntegration';
import { t } from '@lingui/core/macro';
import { Link } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { Pill } from 'twenty-ui/components';
import {
IconArrowUpRight,
IconBolt,
@@ -12,7 +13,7 @@ import {
IconPlus,
Status,
} from 'twenty-ui/display';
import { Pill } from 'twenty-ui/components';
import { Button } from 'twenty-ui/input';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
interface SettingsIntegrationComponentProps {
@@ -92,7 +93,7 @@ export const SettingsIntegrationComponent = ({
<Button
to={integration.link}
Icon={IconPlus}
title="Add"
title={t`Add`}
size="small"
/>
) : integration.type === 'Use' ? (
@@ -100,7 +101,7 @@ export const SettingsIntegrationComponent = ({
to={integration.link}
target="_blank"
Icon={IconBolt}
title="Use"
title={t`Use`}
size="small"
/>
) : integration.type === 'Copy' ? (
@@ -1,9 +1,10 @@
import { type FetchResult } from '@apollo/client';
import styled from '@emotion/styled';
import { type SyncRemoteTableSchemaChangesMutation } from '~/generated-metadata/graphql';
import { Button } from 'twenty-ui/input';
import { t } from '@lingui/core/macro';
import { IconReload } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { type SyncRemoteTableSchemaChangesMutation } from '~/generated-metadata/graphql';
const StyledText = styled.h3`
color: ${({ theme }) => theme.font.color.tertiary};
@@ -27,7 +28,7 @@ export const SettingsIntegrationRemoteTableSchemaUpdate = ({
{updatesText && (
<Button
Icon={IconReload}
title="Update"
title={t`Update`}
size="small"
onClick={onUpdate}
/>
@@ -3,23 +3,32 @@ import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/rol
import { SettingsRolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsTableRow';
import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useRecoilState } from 'recoil';
import {
H2Title,
IconApps,
IconCode,
IconCreditCard,
IconHierarchy,
IconKey,
IconLayoutSidebarRightCollapse,
IconLockOpen,
IconSettings,
IconSettingsAutomation,
IconShield,
IconSparkles,
IconSpy,
IconUsers,
} from 'twenty-ui/display';
import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
PermissionFlagType,
} from '~/generated-metadata/graphql';
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@@ -47,8 +56,13 @@ export const SettingsRolePermissionsSettingsSection = ({
settingsDraftRoleFamilyState(roleId),
);
const settingsPermissionsConfig: SettingsRolePermissionsSettingPermission[] =
[
const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const isApplicationEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_APPLICATION_ENABLED,
);
const settingsPermissionsConfig = useMemo(() => {
const allPermissions: SettingsRolePermissionsSettingPermission[] = [
{
key: PermissionFlagType.API_KEYS_AND_WEBHOOKS,
name: t`API Keys & Webhooks`,
@@ -76,7 +90,7 @@ export const SettingsRolePermissionsSettingsSection = ({
{
key: PermissionFlagType.DATA_MODEL,
name: t`Data Model`,
description: t`Edit CRM data structure and fields`,
description: t`Edit data structure and fields`,
Icon: IconHierarchy,
},
{
@@ -103,8 +117,46 @@ export const SettingsRolePermissionsSettingsSection = ({
description: t`Impersonate workspace users`,
Icon: IconSpy,
},
{
key: PermissionFlagType.APPLICATIONS,
name: t`Applications`,
description: t`Install and manage applications`,
Icon: IconApps,
},
{
key: PermissionFlagType.LAYOUTS,
name: t`Layouts`,
description: t`Customize page layouts and UI structure`,
Icon: IconLayoutSidebarRightCollapse,
},
{
key: PermissionFlagType.BILLING,
name: t`Billing`,
description: t`Manage billing and subscriptions`,
Icon: IconCreditCard,
},
{
key: PermissionFlagType.AI_SETTINGS,
name: t`AI`,
description: t`Create and configure AI agents`,
Icon: IconSparkles,
},
];
return allPermissions.filter((permission) => {
if (permission.key === PermissionFlagType.AI_SETTINGS && !isAIEnabled) {
return false;
}
if (
permission.key === PermissionFlagType.APPLICATIONS &&
!isApplicationEnabled
) {
return false;
}
return true;
});
}, [isAIEnabled, isApplicationEnabled]);
return (
<Section>
<H2Title title={t`Settings`} description={t`Settings permissions`} />
@@ -3,19 +3,28 @@ import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/rol
import { SettingsRolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsTableRow';
import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
import {
H2Title,
IconAt,
IconDownload,
IconFileExport,
IconFileImport,
IconFileUpload,
IconMail,
IconSparkles,
IconTable,
IconTool,
} from 'twenty-ui/display';
import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
PermissionFlagType,
} from '~/generated-metadata/graphql';
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@@ -43,11 +52,34 @@ export const SettingsRolePermissionsToolSection = ({
settingsDraftRoleFamilyState(roleId),
);
const toolPermissionsConfig: SettingsRolePermissionsSettingPermission[] = [
const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const allPermissions: SettingsRolePermissionsSettingPermission[] = [
{
key: PermissionFlagType.AI,
name: t`Ask AI`,
description: t`Chat with AI agents and use AI features`,
Icon: IconSparkles,
isToolPermission: true,
},
{
key: PermissionFlagType.UPLOAD_FILE,
name: t`Upload Files`,
description: t`Allow uploading files and attachments`,
Icon: IconFileUpload,
isToolPermission: true,
},
{
key: PermissionFlagType.DOWNLOAD_FILE,
name: t`Download Files`,
description: t`Allow downloading files and attachments`,
Icon: IconDownload,
isToolPermission: true,
},
{
key: PermissionFlagType.SEND_EMAIL_TOOL,
name: t`Send Email`,
description: t`Allow sending emails using connected accounts`,
description: t`Send emails via connected accounts`,
Icon: IconMail,
isToolPermission: true,
},
@@ -65,8 +97,29 @@ export const SettingsRolePermissionsToolSection = ({
Icon: IconFileExport,
isToolPermission: true,
},
{
key: PermissionFlagType.CONNECTED_ACCOUNTS,
name: t`Sync Account`,
description: t`Sync email and calendar accounts`,
Icon: IconAt,
isToolPermission: true,
},
{
key: PermissionFlagType.VIEWS,
name: t`Manage Views`,
description: t`Create, edit, and delete workspace views`,
Icon: IconTable,
isToolPermission: true,
},
];
const toolPermissionsConfig = allPermissions.filter((permission) => {
if (permission.key === PermissionFlagType.AI && !isAIEnabled) {
return false;
}
return true;
});
return (
<Section>
<H2Title title={t`Actions`} description={t`Actions permissions`} />
@@ -133,7 +133,7 @@ export const SettingsSecurityAuthProvidersOptionsList = () => {
{authProviders.google === true && (
<SettingsOptionCardContentToggle
Icon={IconGoogle}
title="Google"
title={t`Google`}
description={t`Allow logins through Google's single sign-on functionality.`}
checked={currentWorkspace.isGoogleAuthEnabled}
advancedMode
@@ -146,7 +146,7 @@ export const SettingsSecurityAuthProvidersOptionsList = () => {
{authProviders.microsoft === true && (
<SettingsOptionCardContentToggle
Icon={IconMicrosoft}
title="Microsoft"
title={t`Microsoft`}
description={t`Allow logins through Microsoft's single sign-on functionality.`}
checked={currentWorkspace.isMicrosoftAuthEnabled}
advancedMode
@@ -2,6 +2,7 @@ import { type ServerlessFunctionNewFormValues } from '@/settings/serverless-func
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { TextArea } from '@/ui/input/components/TextArea';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
@@ -25,7 +26,10 @@ export const SettingsServerlessFunctionNewForm = ({
return (
<Section>
<H2Title title="About" description="Name and describe your function" />
<H2Title
title={t`About`}
description={t`Name and describe your function`}
/>
<StyledInputsContainer>
<SettingsTextInput
instanceId={nameTextInputId}
@@ -7,6 +7,7 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title, IconPlayerPlay } from 'twenty-ui/display';
import { Button, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -32,7 +33,7 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
);
const TestButton = (
<Button
title="Test"
title={t`Test`}
variant="primary"
accent="blue"
size="small"
@@ -54,8 +55,8 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
return (
<Section>
<H2Title
title="Code your function"
description="Write your function (in typescript) below"
title={t`Code your function`}
description={t`Write your function (in typescript) below`}
/>
<CoreEditorHeader leftNodes={[HeaderTabList]} rightNodes={[TestButton]} />
{activeTabId && (
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
import { SupportButtonSkeletonLoader } from '@/support/components/SupportButtonSkeletonLoader';
import { useSupportChat } from '@/support/hooks/useSupportChat';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { IconHelpCircle } from 'twenty-ui/display';
import { Button, LightIconButton } from 'twenty-ui/input';
@@ -30,7 +31,7 @@ export const SupportButton = () => {
<Button
variant="tertiary"
size="small"
title="Support"
title={t`Support`}
Icon={IconHelpCircle}
/>
</StyledButtonContainer>
@@ -1,5 +1,5 @@
import { expect, userEvent, within } from '@storybook/test';
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
@@ -15,12 +15,14 @@ import {
import { SupportDropdown } from '@/support/components/SupportDropdown';
import { SupportDriver } from '~/generated-metadata/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { PrefetchLoadedDecorator } from '~/testing/decorators/PrefetchLoadedDecorator';
const meta: Meta<typeof SupportDropdown> = {
title: 'Modules/Support/SupportDropdown',
component: SupportDropdown,
decorators: [
I18nFrontDecorator,
(Story) => {
const setCurrentUser = useSetRecoilState(currentUserState);
const setSupportChat = useSetRecoilState(supportChatState);
@@ -123,7 +123,11 @@ export const UserAndViewsProviderEffect = () => {
if (isDefined(userQueryData.currentUser.currentUserWorkspace)) {
setCurrentUserWorkspace({
...userQueryData.currentUser.currentUserWorkspace,
permissionFlags:
userQueryData.currentUser.currentUserWorkspace.permissionFlags ?? [],
twoFactorAuthenticationMethodSummary:
userQueryData.currentUser.currentUserWorkspace
.twoFactorAuthenticationMethodSummary ?? [],
objectsPermissions:
(userQueryData.currentUser.currentUserWorkspace
.objectsPermissions as Array<
@@ -33,6 +33,7 @@ export const USER_QUERY_FRAGMENT = gql`
...DeletedWorkspaceMemberQueryFragment
}
currentUserWorkspace {
id
permissionFlags
objectsPermissions {
...ObjectPermissionFragment
@@ -81,7 +81,9 @@ export const useLoadCurrentUser = () => {
if (isDefined(user.currentUserWorkspace)) {
setCurrentUserWorkspace({
...user.currentUserWorkspace,
permissionFlags: user.currentUserWorkspace.permissionFlags ?? [],
twoFactorAuthenticationMethodSummary:
user.currentUserWorkspace.twoFactorAuthenticationMethodSummary ?? [],
objectsPermissions:
(user.currentUserWorkspace.objectsPermissions as Array<
ObjectPermissions & { objectMetadataId: string }
@@ -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,
});
@@ -44,7 +44,7 @@ export const WorkflowStepFooter = ({
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button title="Options" hotkeys={[getOsControlSymbol(), 'O']} />
<Button title={t`Options`} hotkeys={[getOsControlSymbol(), 'O']} />
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
@@ -99,7 +99,7 @@ export const WorkflowStepFooter = ({
const deleteButton = (
<Button
title="Delete"
title={t`Delete`}
onClick={() => {
deleteStep(stepId);
}}
@@ -500,7 +500,7 @@ export const WorkflowEditActionServerlessFunction = ({
activeTabId === WorkflowServerlessFunctionTabId.TEST
? [
<CmdEnterActionButton
title="Test"
title={t`Test`}
onClick={handleRunFunction}
disabled={isTesting}
/>,
@@ -153,8 +153,8 @@ export const WorkflowFindRecordsSorts = ({
variant="secondary"
accent="default"
onClick={handleAddSort}
ariaLabel="Add sort"
title="Add sort"
ariaLabel={t`Add sort`}
title={t`Add sort`}
disabled={readonly}
/>
</StyledAddButtonContainer>
@@ -15,6 +15,7 @@ import { useSubmitFormStep } from '@/workflow/workflow-steps/workflow-actions/fo
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -33,6 +34,7 @@ export const WorkflowEditActionFormFiller = ({
action,
actionOptions,
}: WorkflowEditActionFormFillerProps) => {
const { t } = useLingui();
const { getIcon } = useIcons();
const { submitFormStep } = useSubmitFormStep();
const [formData, setFormData] = useState<FormData>(action.settings.input);
@@ -201,7 +203,7 @@ export const WorkflowEditActionFormFiller = ({
<RightDrawerFooter
actions={[
<CmdEnterActionButton
title="Submit"
title={t`Submit`}
onClick={onSubmit}
disabled={!canSubmit}
/>,
@@ -18,6 +18,7 @@ import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/htt
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useEffect } from 'react';
import { IconPlayerPlay, IconSettings, useIcons } from 'twenty-ui/display';
import {
@@ -90,6 +91,7 @@ export const WorkflowEditActionHttpRequest = ({
action,
actionOptions,
}: WorkflowEditActionHttpRequestProps) => {
const { t } = useLingui();
const theme = useTheme();
const { getIcon } = useIcons();
const activeTabId = useRecoilComponentValue(
@@ -229,7 +231,7 @@ export const WorkflowEditActionHttpRequest = ({
activeTabId === WorkflowHttpRequestTabId.TEST
? [
<CmdEnterActionButton
title="Test"
title={t`Test`}
onClick={handleTestRequest}
disabled={isTesting}
/>,