mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-29 10:13:05 +03:00
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:
parent
66a483c332
commit
c055d167f2
@ -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',
|
||||
|
@ -1,5 +0,0 @@
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
|
||||
export const useObjectIsRemote = (objectMetadataItem: ObjectMetadataItem) => {
|
||||
return objectMetadataItem.isRemote ?? false;
|
||||
};
|
@ -31,4 +31,5 @@ export enum CoreObjectNameSingular {
|
||||
Workflow = 'workflow',
|
||||
MessageChannelMessageAssociation = 'messageChannelMessageAssociation',
|
||||
WorkflowVersion = 'workflowVersion',
|
||||
WorkflowRun = 'workflowRun',
|
||||
}
|
||||
|
@ -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);
|
@ -0,0 +1,7 @@
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
|
||||
export const isWorkflowSubObjectMetadata = (
|
||||
objectMetadataNameSingular?: string,
|
||||
) =>
|
||||
objectMetadataNameSingular === CoreObjectNameSingular.WorkflowVersion ||
|
||||
objectMetadataNameSingular === CoreObjectNameSingular.WorkflowRun;
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
@ -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')
|
||||
);
|
||||
};
|
@ -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 ? (
|
||||
{shouldDisplayAddButton &&
|
||||
(isTable ? (
|
||||
<PageAddButton onClick={handleAddButtonClick} />
|
||||
) : (
|
||||
<RecordIndexPageKanbanAddButton />
|
||||
)}
|
||||
))}
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
|
@ -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,6 +198,7 @@ export const RecordDetailRelationRecordsListItem = ({
|
||||
accent="tertiary"
|
||||
/>
|
||||
</StyledClickableZone>
|
||||
{canEdit && (
|
||||
<DropdownScope dropdownScopeId={dropdownScopeId}>
|
||||
<Dropdown
|
||||
dropdownId={dropdownScopeId}
|
||||
@ -226,6 +230,7 @@ export const RecordDetailRelationRecordsListItem = ({
|
||||
dropdownHotkeyScope={{ scope: dropdownScopeId }}
|
||||
/>
|
||||
</DropdownScope>
|
||||
)}
|
||||
</StyledListItem>
|
||||
<AnimatedEaseInOut isOpen={isExpanded}>
|
||||
<PropertyBox>
|
||||
|
@ -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,6 +181,7 @@ export const RecordDetailRelationSection = ({
|
||||
hideRightAdornmentOnMouseLeave={!isDropdownOpen && !isMobile}
|
||||
areRecordsAvailable={relationRecords.length > 0}
|
||||
rightAdornment={
|
||||
canEdit && (
|
||||
<DropdownScope dropdownScopeId={dropdownId}>
|
||||
<StyledAddDropdown
|
||||
dropdownId={dropdownId}
|
||||
@ -221,6 +225,7 @@ export const RecordDetailRelationSection = ({
|
||||
}}
|
||||
/>
|
||||
</DropdownScope>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{showContent()}
|
||||
|
@ -70,7 +70,9 @@ export const RecordTable = ({
|
||||
<RecordTableEmptyState />
|
||||
) : (
|
||||
<StyledTable className="entity-table-cell">
|
||||
<RecordTableHeader />
|
||||
<RecordTableHeader
|
||||
objectMetadataNameSingular={objectNameSingular}
|
||||
/>
|
||||
<RecordTableBody />
|
||||
</StyledTable>
|
||||
)}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
Icon={Icon}
|
||||
title={buttonTitle}
|
||||
variant={'secondary'}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
</AnimatedPlaceholderEmptyContainer>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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,7 +215,9 @@ export const RecordTableHeaderCell = ({
|
||||
>
|
||||
<StyledColumnHeadContainer>
|
||||
<RecordTableColumnHeadWithDropdown column={column} />
|
||||
{(useIsMobile() || iconVisibility) && !!column.isLabelIdentifier && (
|
||||
{(useIsMobile() || iconVisibility) &&
|
||||
!!column.isLabelIdentifier &&
|
||||
!isReadOnly && (
|
||||
<StyledHeaderIcon>
|
||||
<LightIconButton
|
||||
Icon={IconPlus}
|
||||
|
@ -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[]>;
|
||||
|
Loading…
Reference in New Issue
Block a user