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