Make workflow objects read only in frontend (#7545)

Expected behavior:
- workflows can be added and deleted. Only name field is editable
- versions and runs cannot be added nor deleted. No fields are editable

Added two new utils for those needs:
- `isReadOnlyObject` the similar logic between remote objects, versions
and runs
- `isFieldReadonlyFromObjectMetadataName` to easily block field edition
from object context
This commit is contained in:
Thomas Trompette 2024-10-10 15:29:43 +02:00 committed by GitHub
parent 66a483c332
commit c055d167f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 190 additions and 112 deletions

View File

@ -3,6 +3,7 @@ import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetadataReadOnly';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData';
import {
@ -55,12 +56,13 @@ export const useComputeActionsBasedOnContextStore = ({
filename: `${objectMetadataItem.nameSingular}.csv`,
});
const isRemoteObject = objectMetadataItem.isRemote;
const isRemote = objectMetadataItem.isRemote;
const numberOfSelectedRecords = contextStoreTargetedRecordIds.length;
const canDelete =
!isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT;
!isObjectMetadataReadOnly(objectMetadataItem) &&
numberOfSelectedRecords < DELETE_MAX_COUNT;
const menuActions: ActionMenuEntry[] = useMemo(
() =>
@ -125,7 +127,7 @@ export const useComputeActionsBasedOnContextStore = ({
return {
availableActionsInContext: [
...menuActions,
...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected
...(!isRemote && isFavorite && hasOnlyOneRecordSelected
? [
{
label: 'Remove from favorites',
@ -134,7 +136,7 @@ export const useComputeActionsBasedOnContextStore = ({
},
]
: []),
...(!isRemoteObject && !isFavorite && hasOnlyOneRecordSelected
...(!isRemote && !isFavorite && hasOnlyOneRecordSelected
? [
{
label: 'Add to favorites',

View File

@ -1,5 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useObjectIsRemote = (objectMetadataItem: ObjectMetadataItem) => {
return objectMetadataItem.isRemote ?? false;
};

View File

@ -31,4 +31,5 @@ export enum CoreObjectNameSingular {
Workflow = 'workflow',
MessageChannelMessageAssociation = 'messageChannelMessageAssociation',
WorkflowVersion = 'workflowVersion',
WorkflowRun = 'workflowRun',
}

View File

@ -0,0 +1,8 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isWorkflowSubObjectMetadata } from '@/object-metadata/utils/isWorkflowSubObjectMetadata';
export const isObjectMetadataReadOnly = (
objectMetadataItem: Pick<ObjectMetadataItem, 'isRemote' | 'nameSingular'>,
) =>
objectMetadataItem.isRemote ||
isWorkflowSubObjectMetadata(objectMetadataItem.nameSingular);

View File

@ -0,0 +1,7 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
export const isWorkflowSubObjectMetadata = (
objectMetadataNameSingular?: string,
) =>
objectMetadataNameSingular === CoreObjectNameSingular.WorkflowVersion ||
objectMetadataNameSingular === CoreObjectNameSingular.WorkflowRun;

View File

@ -3,14 +3,16 @@ import { useContext } from 'react';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { FieldContext } from '../contexts/FieldContext';
import { isFieldMetadataReadOnly } from '../utils/isFieldMetadataReadOnly';
export const useIsFieldReadOnly = () => {
const { fieldDefinition } = useContext(FieldContext);
const { metadata } = fieldDefinition;
return (
fieldDefinition.metadata.fieldName === 'noteTargets' ||
fieldDefinition.metadata.fieldName === 'taskTargets' ||
isFieldActor(fieldDefinition) ||
isFieldRichText(fieldDefinition)
isFieldRichText(fieldDefinition) ||
isFieldMetadataReadOnly(metadata)
);
};

View File

@ -0,0 +1,19 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { isWorkflowSubObjectMetadata } from '@/object-metadata/utils/isWorkflowSubObjectMetadata';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
export const isFieldMetadataReadOnly = (fieldMetadata: FieldMetadata) => {
if (
fieldMetadata.fieldName === 'noteTargets' ||
fieldMetadata.fieldName === 'taskTargets'
) {
return true;
}
return (
isWorkflowSubObjectMetadata(fieldMetadata.objectMetadataNameSingular) ||
(fieldMetadata.objectMetadataNameSingular ===
CoreObjectNameSingular.Workflow &&
fieldMetadata.fieldName !== 'name')
);
};

View File

@ -2,6 +2,7 @@ import { useRecoilValue } from 'recoil';
import { useIcons } from 'twenty-ui';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetadataReadOnly';
import { RecordIndexPageKanbanAddButton } from '@/object-record/record-index/components/RecordIndexPageKanbanAddButton';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
@ -30,8 +31,11 @@ export const RecordIndexPageHeader = () => {
const recordIndexViewType = useRecoilValue(recordIndexViewTypeState);
const isTable =
recordIndexViewType === ViewType.Table && !objectMetadataItem?.isRemote;
const shouldDisplayAddButton = objectMetadataItem
? !isObjectMetadataReadOnly(objectMetadataItem)
: false;
const isTable = recordIndexViewType === ViewType.Table;
const pageHeaderTitle =
objectMetadataItem?.labelPlural ?? capitalize(objectNamePlural);
@ -43,11 +47,12 @@ export const RecordIndexPageHeader = () => {
return (
<PageHeader title={pageHeaderTitle} Icon={Icon}>
<PageHotkeysEffect onAddButtonClick={handleAddButtonClick} />
{isTable ? (
<PageAddButton onClick={handleAddButtonClick} />
) : (
<RecordIndexPageKanbanAddButton />
)}
{shouldDisplayAddButton &&
(isTable ? (
<PageAddButton onClick={handleAddButtonClick} />
) : (
<RecordIndexPageKanbanAddButton />
))}
</PageHeader>
);
};

View File

@ -24,6 +24,7 @@ import {
} from '@/object-record/record-field/contexts/FieldContext';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldMetadataReadOnly } from '@/object-record/record-field/utils/isFieldMetadataReadOnly';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
@ -180,6 +181,8 @@ export const RecordDetailRelationRecordsListItem = ({
[isExpanded],
);
const canEdit = !isFieldMetadataReadOnly(fieldDefinition.metadata);
return (
<>
<RecordValueSetterEffect recordId={relationRecord.id} />
@ -195,37 +198,39 @@ export const RecordDetailRelationRecordsListItem = ({
accent="tertiary"
/>
</StyledClickableZone>
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownId={dropdownScopeId}
dropdownPlacement="right-start"
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconDotsVertical}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconUnlink}
text="Detach"
onClick={handleDetach}
{canEdit && (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownId={dropdownScopeId}
dropdownPlacement="right-start"
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconDotsVertical}
accent="tertiary"
/>
{!isAccountOwnerRelation && (
}
dropdownComponents={
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconTrash}
text="Delete"
accent="danger"
onClick={handleDelete}
LeftIcon={IconUnlink}
text="Detach"
onClick={handleDetach}
/>
)}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: dropdownScopeId }}
/>
</DropdownScope>
{!isAccountOwnerRelation && (
<MenuItem
LeftIcon={IconTrash}
text="Delete"
accent="danger"
onClick={handleDelete}
/>
)}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: dropdownScopeId }}
/>
</DropdownScope>
)}
</StyledListItem>
<AnimatedEaseInOut isOpen={isExpanded}>
<PropertyBox>

View File

@ -12,6 +12,7 @@ import { usePersistField } from '@/object-record/record-field/hooks/usePersistFi
import { RelationFromManyFieldInputMultiRecordsEffect } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect';
import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldMetadataReadOnly } from '@/object-record/record-field/utils/isFieldMetadataReadOnly';
import { RecordDetailRelationRecordsList } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList';
import { RecordDetailSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailSection';
import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader';
@ -158,6 +159,8 @@ export const RecordDetailRelationSection = ({
recordId,
});
const canEdit = !isFieldMetadataReadOnly(fieldDefinition.metadata);
if (loading) return null;
return (
@ -178,49 +181,51 @@ export const RecordDetailRelationSection = ({
hideRightAdornmentOnMouseLeave={!isDropdownOpen && !isMobile}
areRecordsAvailable={relationRecords.length > 0}
rightAdornment={
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={isToOneObject ? IconPencil : IconPlus}
accent="tertiary"
/>
}
dropdownComponents={
<RelationPickerScope relationPickerScopeId={dropdownId}>
{isToOneObject ? (
<SingleEntitySelectMenuItemsWithSearch
EmptyIcon={IconForbid}
onEntitySelected={handleRelationPickerEntitySelected}
selectedRelationRecordIds={relationRecordIds}
relationObjectNameSingular={
relationObjectMetadataNameSingular
}
relationPickerScopeId={dropdownId}
onCreate={createNewRecordAndOpenRightDrawer}
/>
) : (
<>
<ObjectMetadataItemsRelationPickerEffect />
<RelationFromManyFieldInputMultiRecordsEffect />
<MultiRecordSelect
canEdit && (
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={isToOneObject ? IconPencil : IconPlus}
accent="tertiary"
/>
}
dropdownComponents={
<RelationPickerScope relationPickerScopeId={dropdownId}>
{isToOneObject ? (
<SingleEntitySelectMenuItemsWithSearch
EmptyIcon={IconForbid}
onEntitySelected={handleRelationPickerEntitySelected}
selectedRelationRecordIds={relationRecordIds}
relationObjectNameSingular={
relationObjectMetadataNameSingular
}
relationPickerScopeId={dropdownId}
onCreate={createNewRecordAndOpenRightDrawer}
onChange={updateRelation}
onSubmit={closeDropdown}
/>
</>
)}
</RelationPickerScope>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
) : (
<>
<ObjectMetadataItemsRelationPickerEffect />
<RelationFromManyFieldInputMultiRecordsEffect />
<MultiRecordSelect
onCreate={createNewRecordAndOpenRightDrawer}
onChange={updateRelation}
onSubmit={closeDropdown}
/>
</>
)}
</RelationPickerScope>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
)
}
/>
{showContent()}

View File

@ -70,7 +70,9 @@ export const RecordTable = ({
<RecordTableEmptyState />
) : (
<StyledTable className="entity-table-cell">
<RecordTableHeader />
<RecordTableHeader
objectMetadataNameSingular={objectNameSingular}
/>
<RecordTableBody />
</StyledTable>
)}

View File

@ -1,4 +1,3 @@
import { useObjectIsRemote } from '@/object-metadata/hooks/useObjectIsRemote';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableEmptyStateNoRecordAtAll } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateNoRecordAtAll';
@ -18,7 +17,7 @@ export const RecordTableEmptyState = () => {
const { totalCount } = useFindManyRecords({ objectNameSingular, limit: 1 });
const noRecordAtAll = totalCount === 0;
const isRemote = useObjectIsRemote(objectMetadataItem);
const isRemote = objectMetadataItem.isRemote;
const isSoftDeleteActive = useRecoilValue(isSoftDeleteActiveState);

View File

@ -8,7 +8,10 @@ import {
AnimatedPlaceholderEmptyTitle,
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
import { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetadataReadOnly';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { Button } from '@/ui/input/button/components/Button';
import { useContext } from 'react';
import { IconComponent } from 'twenty-ui';
type RecordTableEmptyStateDisplayProps = {
@ -28,6 +31,9 @@ export const RecordTableEmptyStateDisplay = ({
subTitle,
title,
}: RecordTableEmptyStateDisplayProps) => {
const { objectMetadataItem } = useContext(RecordTableContext);
const isReadOnly = isObjectMetadataReadOnly(objectMetadataItem);
return (
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type={animatedPlaceholderType} />
@ -37,12 +43,14 @@ export const RecordTableEmptyStateDisplay = ({
{subTitle}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={Icon}
title={buttonTitle}
variant={'secondary'}
onClick={onClick}
/>
{!isReadOnly && (
<Button
Icon={Icon}
title={buttonTitle}
variant={'secondary'}
onClick={onClick}
/>
)}
</AnimatedPlaceholderEmptyContainer>
);
};

View File

@ -73,7 +73,11 @@ const StyledTableHead = styled.thead<{
}
`;
export const RecordTableHeader = () => {
export const RecordTableHeader = ({
objectMetadataNameSingular,
}: {
objectMetadataNameSingular: string;
}) => {
const { visibleTableColumnsSelector } = useRecordTableStates();
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
@ -84,7 +88,11 @@ export const RecordTableHeader = () => {
<RecordTableHeaderDragDropColumn />
<RecordTableHeaderCheckboxColumn />
{visibleTableColumns.map((column) => (
<RecordTableHeaderCell key={column.fieldMetadataId} column={column} />
<RecordTableHeaderCell
key={column.fieldMetadataId}
column={column}
objectMetadataNameSingular={objectMetadataNameSingular}
/>
))}
<RecordTableHeaderLastColumn />
</tr>

View File

@ -3,6 +3,8 @@ import { useCallback, useMemo, useState } from 'react';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { IconPlus } from 'twenty-ui';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetadataReadOnly';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useCreateNewTableRecord } from '@/object-record/record-table/hooks/useCreateNewTableRecords';
@ -91,11 +93,17 @@ const StyledHeaderIcon = styled.div`
export const RecordTableHeaderCell = ({
column,
objectMetadataNameSingular,
}: {
column: ColumnDefinition<FieldMetadata>;
objectMetadataNameSingular: string;
}) => {
const { resizeFieldOffsetState, tableColumnsState } = useRecordTableStates();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: objectMetadataNameSingular,
});
const [resizeFieldOffset, setResizeFieldOffset] = useRecoilState(
resizeFieldOffsetState,
);
@ -190,6 +198,8 @@ export const RecordTableHeaderCell = ({
createNewTableRecord();
};
const isReadOnly = isObjectMetadataReadOnly(objectMetadataItem);
return (
<StyledColumnHeaderCell
key={column.fieldMetadataId}
@ -205,16 +215,18 @@ export const RecordTableHeaderCell = ({
>
<StyledColumnHeadContainer>
<RecordTableColumnHeadWithDropdown column={column} />
{(useIsMobile() || iconVisibility) && !!column.isLabelIdentifier && (
<StyledHeaderIcon>
<LightIconButton
Icon={IconPlus}
size="small"
accent="tertiary"
onClick={handlePlusButtonClick}
/>
</StyledHeaderIcon>
)}
{(useIsMobile() || iconVisibility) &&
!!column.isLabelIdentifier &&
!isReadOnly && (
<StyledHeaderIcon>
<LightIconButton
Icon={IconPlus}
size="small"
accent="tertiary"
onClick={handlePlusButtonClick}
/>
</StyledHeaderIcon>
)}
</StyledColumnHeadContainer>
{!disableColumnResize && (
<StyledResizeHandler

View File

@ -109,7 +109,7 @@ export class WorkflowWorkspaceEntity extends BaseWorkspaceEntity {
description: 'Workflow versions linked to the workflow.',
icon: 'IconVersions',
inverseSideTarget: () => WorkflowVersionWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
versions: Relation<WorkflowVersionWorkspaceEntity[]>;
@ -121,7 +121,7 @@ export class WorkflowWorkspaceEntity extends BaseWorkspaceEntity {
description: 'Workflow runs linked to the workflow.',
icon: 'IconVersions',
inverseSideTarget: () => WorkflowRunWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
runs: Relation<WorkflowRunWorkspaceEntity>;
@ -133,7 +133,7 @@ export class WorkflowWorkspaceEntity extends BaseWorkspaceEntity {
description: 'Workflow event listeners linked to the workflow.',
icon: 'IconVersions',
inverseSideTarget: () => WorkflowEventListenerWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
eventListeners: Relation<WorkflowEventListenerWorkspaceEntity[]>;