feat(core): guard service (#9816)

This commit is contained in:
EYHN
2025-02-10 07:26:38 +08:00
committed by GitHub
parent 879157b938
commit 92f4f0c2d9
89 changed files with 1520 additions and 522 deletions

View File

@@ -5,7 +5,10 @@ import { Modal, useConfirmModal } from '@affine/component/ui/modal';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { EditorService } from '@affine/core/modules/editor';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import {
GuardService,
WorkspacePermissionService,
} from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { i18nTime, Trans, useI18n } from '@affine/i18n';
@@ -409,6 +412,8 @@ const PageHistoryManager = ({
const workspaceId = docCollection.id;
const [activeVersion, setActiveVersion] = useState<string>();
const guardService = useService(GuardService);
const pageDocId = useMemo(() => {
return docCollection.getDoc(pageId)?.spaceDoc.guid ?? pageId;
}, [pageId, docCollection]);
@@ -440,6 +445,7 @@ const PageHistoryManager = ({
const i18n = useI18n();
const title = useLiveData(docDisplayMetaService.title$(pageId));
const canEdit = useLiveData(guardService.can$('Doc_Update', pageDocId));
const onConfirmRestore = useCallback(() => {
openConfirmModal({
@@ -499,7 +505,7 @@ const PageHistoryManager = ({
<Button
variant="primary"
onClick={onConfirmRestore}
disabled={isMutating || !activeVersion}
disabled={isMutating || !activeVersion || !canEdit}
>
{t['com.affine.history.restore-current-version']()}
</Button>

View File

@@ -31,6 +31,7 @@ interface BlocksuiteEditorContainerProps {
page: Store;
mode: DocMode;
shared?: boolean;
readonly?: boolean;
className?: string;
defaultOpenProperty?: DefaultOpenProperty;
style?: React.CSSProperties;
@@ -40,7 +41,7 @@ export const BlocksuiteEditorContainer = forwardRef<
AffineEditorContainer,
BlocksuiteEditorContainerProps
>(function AffineEditorContainer(
{ page, mode, className, style, shared, defaultOpenProperty },
{ page, mode, className, style, shared, readonly, defaultOpenProperty },
ref
) {
const rootRef = useRef<HTMLDivElement>(null);
@@ -119,7 +120,7 @@ export const BlocksuiteEditorContainer = forwardRef<
]);
const handleClickPageModeBlank = useCallback(() => {
if (shared || page.readonly) return;
if (shared || readonly || page.readonly) return;
const std = affineEditorContainerProxy.host?.std;
if (!std) {
return;
@@ -141,7 +142,7 @@ export const BlocksuiteEditorContainer = forwardRef<
}
std.command.exec(appendParagraphCommand);
}, [affineEditorContainerProxy, page, shared]);
}, [affineEditorContainerProxy.host?.std, page, readonly, shared]);
return (
<div
@@ -161,6 +162,7 @@ export const BlocksuiteEditorContainer = forwardRef<
shared={shared}
page={page}
ref={docRef}
readonly={readonly}
titleRef={docTitleRef}
onClickBlank={handleClickPageModeBlank}
defaultOpenProperty={defaultOpenProperty}

View File

@@ -22,6 +22,7 @@ export type EditorProps = {
page: Store;
mode: DocMode;
shared?: boolean;
readonly?: boolean;
defaultOpenProperty?: DefaultOpenProperty;
// on Editor ready
onEditorReady?: (editor: AffineEditorContainer) => (() => void) | void;
@@ -34,6 +35,7 @@ const BlockSuiteEditorImpl = ({
page,
className,
shared,
readonly,
style,
onEditorReady,
defaultOpenProperty,
@@ -111,6 +113,7 @@ const BlockSuiteEditorImpl = ({
mode={mode}
page={page}
shared={shared}
readonly={readonly}
defaultOpenProperty={defaultOpenProperty}
ref={editorRef}
className={className}

View File

@@ -89,6 +89,7 @@ const adapted = {
interface BlocksuiteEditorProps {
page: Store;
readonly?: boolean;
shared?: boolean;
defaultOpenProperty?: DefaultOpenProperty;
}
@@ -220,6 +221,7 @@ export const BlocksuiteDocEditor = forwardRef<
onClickBlank,
titleRef: externalTitleRef,
defaultOpenProperty,
readonly,
},
ref
) {
@@ -334,7 +336,7 @@ export const BlocksuiteDocEditor = forwardRef<
data-testid="page-editor-blank"
onClick={onClickBlank}
></div>
<StarterBar doc={page} />
{!readonly && <StarterBar doc={page} />}
{!shared && displayBiDirectionalLink ? (
<BiDirectionalLinkPanel />
) : null}

View File

@@ -1,4 +1,4 @@
import { notify, useConfirmModal } from '@affine/component';
import { notify, toast, useConfirmModal } from '@affine/component';
import {
Menu,
MenuItem,
@@ -9,13 +9,13 @@ import { PageHistoryModal } from '@affine/core/components/affine/page-history-mo
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { useExportPage } from '@affine/core/components/hooks/affine/use-export-page';
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { Export, MoveToTrash } from '@affine/core/components/page-list';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/workspace/detail-page/use-header-responsive';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { EditorService } from '@affine/core/modules/editor';
import { OpenInAppService } from '@affine/core/modules/open-in-app/services';
import { GuardService } from '@affine/core/modules/permissions';
import { ShareMenuContent } from '@affine/core/modules/share-menu';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
@@ -34,7 +34,6 @@ import {
LocalWorkspaceIcon,
OpenInNewIcon,
PageIcon,
SaveIcon,
ShareIcon,
SplitViewIcon,
TocIcon,
@@ -56,24 +55,95 @@ type PageMenuProps = {
isJournal?: boolean;
containerWidth: number;
};
// fixme: refactor this file
export const PageHeaderMenuButton = ({
rename,
page,
isJournal,
containerWidth,
}: PageMenuProps) => {
const workspace = useService(WorkspaceService).workspace;
const editorService = useService(EditorService);
const isInTrash = useLiveData(
editorService.editor.doc.meta$.map(meta => meta.trash)
);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [openHistoryTipsModal, setOpenHistoryTipsModal] = useState(false);
const handleMenuOpenChange = useCallback((open: boolean) => {
if (open) {
track.$.header.docOptions.open();
}
}, []);
const openHistoryModal = useCallback(() => {
track.$.header.history.open();
if (workspace.flavour === 'affine-cloud') {
return setHistoryModalOpen(true);
}
return setOpenHistoryTipsModal(true);
}, [setOpenHistoryTipsModal, workspace.flavour]);
if (isInTrash) {
return null;
}
return (
<>
<Menu
items={
<PageHeaderMenuItem
page={page}
containerWidth={containerWidth}
rename={rename}
isJournal={isJournal}
openHistoryModal={openHistoryModal}
/>
}
contentOptions={{
align: 'center',
}}
rootOptions={{
onOpenChange: handleMenuOpenChange,
}}
>
<HeaderDropDownButton />
</Menu>
{workspace.flavour !== 'local' ? (
<PageHistoryModal
docCollection={workspace.docCollection}
open={historyModalOpen}
pageId={page.id}
onOpenChange={setHistoryModalOpen}
/>
) : null}
<HistoryTipsModal
open={openHistoryTipsModal}
setOpen={setOpenHistoryTipsModal}
/>
</>
);
};
// fixme: refactor this file
const PageHeaderMenuItem = ({
rename,
page,
isJournal,
containerWidth,
openHistoryModal,
}: PageMenuProps & {
openHistoryModal: () => void;
}) => {
const pageId = page?.id;
const t = useI18n();
const { hideShare } = useDetailPageHeaderResponsive(containerWidth);
const confirmEnableCloud = useEnableCloud();
const workspace = useService(WorkspaceService).workspace;
const guardService = useService(GuardService);
const editorService = useService(EditorService);
const isInTrash = useLiveData(
editorService.editor.doc.meta$.map(meta => meta.trash)
);
const currentMode = useLiveData(editorService.editor.mode$);
const primaryMode = useLiveData(editorService.editor.doc.primaryMode$);
@@ -84,9 +154,6 @@ export const PageHeaderMenuButton = ({
const { duplicate } = useBlockSuiteMetaHelper();
const [isEditing, setEditing] = useState(!page.readonly);
const { setDocReadonly } = useDocMetaHelper();
const view = useService(ViewService).view;
const openSidePanel = useCallback(
@@ -105,17 +172,6 @@ export const PageHeaderMenuButton = ({
openSidePanel('outline');
}, [openSidePanel]);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [openHistoryTipsModal, setOpenHistoryTipsModal] = useState(false);
const openHistoryModal = useCallback(() => {
track.$.header.history.open();
if (workspace.flavour === 'affine-cloud') {
return setHistoryModalOpen(true);
}
return setOpenHistoryTipsModal(true);
}, [setOpenHistoryTipsModal, workspace.flavour]);
const workspaceDialogService = useService(WorkspaceDialogService);
const openInfoModal = useCallback(() => {
track.$.header.pageInfo.open();
@@ -148,11 +204,16 @@ export const PageHeaderMenuButton = ({
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
onConfirm: async () => {
const canTrash = await guardService.can('Doc_Trash', pageId);
if (!canTrash) {
toast(t['com.affine.no-permission']());
return;
}
editorService.editor.doc.moveToTrash();
},
});
}, [editorService.editor.doc, openConfirmModal, t]);
}, [editorService.editor.doc, guardService, openConfirmModal, pageId, t]);
const handleRename = useCallback(() => {
rename?.();
@@ -178,12 +239,6 @@ export const PageHeaderMenuButton = ({
});
}, [primaryMode, editorService, t]);
const handleMenuOpenChange = useCallback((open: boolean) => {
if (open) {
track.$.header.docOptions.open();
}
}, []);
const exportHandler = useExportPage();
const handleDuplicate = useCallback(() => {
@@ -238,21 +293,6 @@ export const PageHeaderMenuButton = ({
toggleFavorite();
}, [toggleFavorite]);
const handleToggleEdit = useCallback(() => {
setDocReadonly(page.id, !page.readonly);
setEditing(!isEditing);
}, [isEditing, page.id, page.readonly, setDocReadonly]);
const isMobile = environment.isMobile;
const mobileEditMenuItem = (
<MenuItem
prefixIcon={isEditing ? <SaveIcon /> : <EditIcon />}
onSelect={handleToggleEdit}
>
{t[isEditing ? 'Save' : 'Edit']()}
</MenuItem>
);
const showResponsiveMenu = hideShare;
const ResponsiveMenuItems = (
<>
@@ -293,15 +333,18 @@ export const PageHeaderMenuButton = ({
openInAppService?.showOpenInAppPage();
}, [openInAppService]);
const EditMenu = (
const canEdit = useLiveData(guardService.can$('Doc_Update', pageId));
const canMoveToTrash = useLiveData(guardService.can$('Doc_Trash', pageId));
return (
<>
{showResponsiveMenu ? ResponsiveMenuItems : null}
{isMobile && mobileEditMenuItem}
{!isJournal && (
<MenuItem
prefixIcon={<EditIcon />}
data-testid="editor-option-menu-rename"
onSelect={handleRename}
disabled={!canEdit}
>
{t['Rename']()}
</MenuItem>
@@ -310,6 +353,7 @@ export const PageHeaderMenuButton = ({
prefixIcon={primaryMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
data-testid="editor-option-menu-edgeless"
onSelect={handleSwitchMode}
disabled={!canEdit}
>
{primaryMode === 'page'
? t['com.affine.editorDefaultMode.edgeless']()
@@ -396,6 +440,7 @@ export const PageHeaderMenuButton = ({
<MoveToTrash
data-testid="editor-option-menu-delete"
onSelect={handleOpenTrashModal}
disabled={!canMoveToTrash}
/>
{BUILD_CONFIG.isWeb && workspace.flavour === 'affine-cloud' ? (
<MenuItem
@@ -408,34 +453,4 @@ export const PageHeaderMenuButton = ({
) : null}
</>
);
if (isInTrash) {
return null;
}
return (
<>
<Menu
items={EditMenu}
contentOptions={{
align: 'center',
}}
rootOptions={{
onOpenChange: handleMenuOpenChange,
}}
>
<HeaderDropDownButton />
</Menu>
{workspace.flavour !== 'local' ? (
<PageHistoryModal
docCollection={workspace.docCollection}
open={historyModalOpen}
pageId={pageId}
onOpenChange={setHistoryModalOpen}
/>
) : null}
<HistoryTipsModal
open={openHistoryTipsModal}
setOpen={setOpenHistoryTipsModal}
/>
</>
);
};

View File

@@ -1,7 +1,8 @@
import type { InlineEditProps } from '@affine/component';
import { InlineEdit } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocsService } from '@affine/core/modules/doc';
import { DocService, DocsService } from '@affine/core/modules/doc';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
@@ -11,7 +12,6 @@ import type { HTMLAttributes } from 'react';
import * as styles from './style.css';
export interface BlockSuiteHeaderTitleProps {
docId: string;
/** if set, title cannot be edited */
inputHandleRef?: InlineEditProps['handleRef'];
className?: string;
@@ -21,20 +21,24 @@ const inputAttrs = {
'data-testid': 'title-content',
} as HTMLAttributes<HTMLInputElement>;
export const BlocksuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => {
const { inputHandleRef, docId } = props;
const { inputHandleRef } = props;
const workspaceService = useService(WorkspaceService);
const isSharedMode = workspaceService.workspace.openOptions.isSharedMode;
const docsService = useService(DocsService);
const docRecord = useLiveData(docsService.list.doc$(docId));
const docTitle = useLiveData(docRecord?.title$);
const guardService = useService(GuardService);
const docService = useService(DocService);
const docTitle = useLiveData(docService.doc.record.title$);
const onChange = useAsyncCallback(
async (v: string) => {
await docsService.changeDocTitle(docId, v);
await docsService.changeDocTitle(docService.doc.id, v);
track.$.header.actions.renameDoc();
},
[docId, docsService]
[docService.doc.id, docsService]
);
const canEdit = useLiveData(
guardService.can$('Doc_Update', docService.doc.id)
);
return (
@@ -42,7 +46,7 @@ export const BlocksuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => {
className={clsx(styles.title, props.className)}
value={docTitle}
onChange={onChange}
editable={!isSharedMode}
editable={!isSharedMode && canEdit}
exitible={true}
placeholder="Untitled"
data-testid="title-edit-button"

View File

@@ -51,8 +51,14 @@ export const iconSelectorButton = style({
border: `1px solid ${cssVar('borderColor')}`,
background: cssVar('backgroundSecondaryColor'),
cursor: 'pointer',
':hover': {
backgroundColor: cssVar('backgroundTertiaryColor'),
selectors: {
'&:hover:not([data-readonly=true])': {
backgroundColor: cssVar('backgroundTertiaryColor'),
},
'&[data-readonly=true]': {
cursor: 'default',
},
},
});

View File

@@ -55,11 +55,20 @@ const IconsSelectorPanel = ({
export const DocPropertyIconSelector = ({
propertyInfo,
readonly,
onSelectedChange,
}: {
propertyInfo: DocCustomPropertyInfo;
readonly?: boolean;
onSelectedChange: (icon: DocPropertyIconName) => void;
}) => {
if (readonly) {
return (
<div className={styles.iconSelectorButton} data-readonly={readonly}>
<DocPropertyIcon propertyInfo={propertyInfo} />
</div>
);
}
return (
<Menu
items={

View File

@@ -8,6 +8,7 @@ import {
} from '@affine/component';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { DocsService } from '@affine/core/modules/doc';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
@@ -37,9 +38,13 @@ const PropertyItem = ({
) => void;
}) => {
const t = useI18n();
const guardService = useService(GuardService);
const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService);
const [moreMenuOpen, setMoreMenuOpen] = useState(defaultOpenEditMenu);
const canEditPropertyInfo = useLiveData(
guardService.can$('Workspace_Properties_Update')
);
const typeInfo = isSupportedDocPropertyType(propertyInfo.type)
? DocPropertyTypes[propertyInfo.type]
@@ -51,6 +56,7 @@ const PropertyItem = ({
const { dragRef } = useDraggable<AffineDNDData>(
() => ({
canDrag: canEditPropertyInfo,
data: {
entity: {
type: 'custom-property',
@@ -62,13 +68,14 @@ const PropertyItem = ({
},
},
}),
[propertyInfo, workspaceService]
[propertyInfo, workspaceService, canEditPropertyInfo]
);
const { dropTargetRef, closestEdge } = useDropTarget<AffineDNDData>(
() => ({
canDrop(data) {
return (
canEditPropertyInfo &&
data.source.data.entity?.type === 'custom-property' &&
data.source.data.from?.at === 'doc-property:manager' &&
data.source.data.from?.workspaceId ===
@@ -97,7 +104,7 @@ const PropertyItem = ({
});
},
}),
[docsService, propertyInfo, workspaceService]
[docsService, propertyInfo, workspaceService, canEditPropertyInfo]
);
return (
@@ -139,6 +146,7 @@ const PropertyItem = ({
<EditDocPropertyMenuItems
propertyId={propertyInfo.id}
onPropertyInfoChange={onPropertyInfoChange}
readonly={!canEditPropertyInfo}
/>
}
>

View File

@@ -28,8 +28,10 @@ import * as styles from './edit-doc-property.css';
export const EditDocPropertyMenuItems = ({
propertyId,
onPropertyInfoChange,
readonly,
}: {
propertyId: string;
readonly?: boolean;
onPropertyInfoChange?: (
field: keyof DocCustomPropertyInfo,
value: string
@@ -142,9 +144,10 @@ export const EditDocPropertyMenuItems = ({
>
<DocPropertyIconSelector
propertyInfo={propertyInfo}
readonly={readonly}
onSelectedChange={handleIconChange}
/>
{typeInfo?.renameable === false ? (
{typeInfo?.renameable === false || readonly ? (
<span className={styles.propertyName}>{name}</span>
) : (
<Input
@@ -180,6 +183,7 @@ export const EditDocPropertyMenuItems = ({
propertyInfo.show !== 'always-hide'
}
data-property-visibility="always-show"
disabled={readonly}
>
{t['com.affine.page-properties.property.always-show']()}
</MenuItem>
@@ -188,6 +192,7 @@ export const EditDocPropertyMenuItems = ({
onClick={handleClickHideWhenEmpty}
selected={propertyInfo.show === 'hide-when-empty'}
data-property-visibility="hide-when-empty"
disabled={readonly}
>
{t['com.affine.page-properties.property.hide-when-empty']()}
</MenuItem>
@@ -196,6 +201,7 @@ export const EditDocPropertyMenuItems = ({
onClick={handleClickAlwaysHide}
selected={propertyInfo.show === 'always-hide'}
data-property-visibility="always-hide"
disabled={readonly}
>
{t['com.affine.page-properties.property.always-hide']()}
</MenuItem>
@@ -203,6 +209,7 @@ export const EditDocPropertyMenuItems = ({
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
disabled={readonly}
onClick={() => {
confirmModal.openConfirmModal({
title:

View File

@@ -1,6 +1,7 @@
import { Divider, IconButton, Tooltip } from '@affine/component';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { DocsService } from '@affine/core/modules/doc';
import { GuardService } from '@affine/core/modules/permissions';
import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
@@ -28,9 +29,12 @@ export const DocPropertySidebar = () => {
const [newPropertyId, setNewPropertyId] = useState<string>();
const docsService = useService(DocsService);
const guardService = useService(GuardService);
const propertyList = docsService.propertyList;
const properties = useLiveData(propertyList.properties$);
const canEditPropertyInfo = useLiveData(
guardService.can$('Workspace_Properties_Update')
);
const onAddProperty = useCallback(
(option: { type: string; name: string }) => {
if (!isSupportedDocPropertyType(option.type)) {
@@ -104,12 +108,15 @@ export const DocPropertySidebar = () => {
<div
className={styles.itemContainer}
onClick={() => {
if (!canEditPropertyInfo) {
return;
}
onAddProperty({
type: key,
name,
});
}}
data-disabled={isUniqueExist}
data-disabled={isUniqueExist || !canEditPropertyInfo}
>
<Icon className={styles.itemIcon} />
<span className={styles.itemName}>{t.t(value.name)}</span>

View File

@@ -16,6 +16,7 @@ import type {
DatabaseValueCell,
} from '@affine/core/modules/doc-info/types';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GuardService } from '@affine/core/modules/permissions';
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
@@ -111,6 +112,8 @@ interface DocPropertyRowProps {
propertyInfo: DocCustomPropertyInfo;
showAll?: boolean;
defaultOpenEditMenu?: boolean;
propertyInfoReadonly?: boolean;
readonly?: boolean;
onChange?: (value: unknown) => void;
onPropertyInfoChange?: (
field: keyof DocCustomPropertyInfo,
@@ -122,6 +125,8 @@ export const DocPropertyRow = ({
propertyInfo,
defaultOpenEditMenu,
onChange,
propertyInfoReadonly,
readonly,
onPropertyInfoChange,
}: DocPropertyRowProps) => {
const t = useI18n();
@@ -160,6 +165,7 @@ export const DocPropertyRow = ({
const docId = docService.doc.id;
const { dragRef } = useDraggable<AffineDNDData>(
() => ({
canDrag: !propertyInfoReadonly,
data: {
entity: {
type: 'custom-property',
@@ -171,7 +177,7 @@ export const DocPropertyRow = ({
},
},
}),
[docId, propertyInfo.id]
[docId, propertyInfo.id, propertyInfoReadonly]
);
const { dropTargetRef, closestEdge } = useDropTarget<AffineDNDData>(
() => ({
@@ -180,6 +186,7 @@ export const DocPropertyRow = ({
},
canDrop: data => {
return (
!propertyInfoReadonly &&
data.source.data.entity?.type === 'custom-property' &&
data.source.data.entity.id !== propertyInfo.id &&
data.source.data.from?.at === 'doc-property:table' &&
@@ -204,7 +211,7 @@ export const DocPropertyRow = ({
});
},
}),
[docId, docsService.propertyList, propertyInfo.id]
[docId, docsService.propertyList, propertyInfo.id, propertyInfoReadonly]
);
if (!ValueRenderer || typeof ValueRenderer !== 'function') return null;
@@ -221,6 +228,8 @@ export const DocPropertyRow = ({
dropIndicatorEdge={closestEdge}
hideEmpty={hideEmpty}
hide={hide}
data-property-info-readonly={propertyInfoReadonly}
data-readonly={readonly}
data-testid="doc-property-row"
data-info-id={propertyInfo.id}
>
@@ -235,6 +244,7 @@ export const DocPropertyRow = ({
<EditDocPropertyMenuItems
propertyId={propertyInfo.id}
onPropertyInfoChange={onPropertyInfoChange}
readonly={propertyInfoReadonly}
/>
}
data-testid="doc-property-name"
@@ -243,6 +253,7 @@ export const DocPropertyRow = ({
propertyInfo={propertyInfo}
onChange={handleChange}
value={customPropertyValue}
readonly={readonly}
/>
</PropertyRoot>
);
@@ -284,11 +295,20 @@ const DocWorkspacePropertiesTableBody = forwardRef<
const docsService = useService(DocsService);
const workbenchService = useService(WorkbenchService);
const viewService = useServiceOptional(ViewService);
const docService = useService(DocService);
const guardService = useService(GuardService);
const properties = useLiveData(docsService.propertyList.sortedProperties$);
const [addMoreCollapsed, setAddMoreCollapsed] = useState(true);
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const canEditProperty = useLiveData(
guardService.can$('Doc_Update', docService.doc.id)
);
const canEditPropertyInfo = useLiveData(
guardService.can$('Workspace_Properties_Update')
);
const handlePropertyAdded = useCallback(
(property: DocCustomPropertyInfo) => {
setNewPropertyId(property.id);
@@ -340,34 +360,48 @@ const DocWorkspacePropertiesTableBody = forwardRef<
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
onChange={value => onChange?.(property, value)}
readonly={!canEditProperty}
propertyInfoReadonly={!canEditPropertyInfo}
onPropertyInfoChange={(...args) =>
onPropertyInfoChange?.(property, ...args)
}
/>
))}
<div className={styles.actionContainer}>
<Menu
items={
<CreatePropertyMenuItems
at="after"
onCreated={handlePropertyAdded}
/>
}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
{!canEditPropertyInfo ? (
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.propertyActionButton}
data-testid="add-property-button"
disabled={!canEditPropertyInfo}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
) : (
<Menu
items={
<CreatePropertyMenuItems
at="after"
onCreated={handlePropertyAdded}
/>
}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.propertyActionButton}
data-testid="add-property-button"
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
)}
{viewService ? (
<Button
variant="plain"

View File

@@ -4,14 +4,21 @@ import { useCallback } from 'react';
import * as styles from './checkbox.css';
import type { PropertyValueProps } from './types';
export const CheckboxValue = ({ value, onChange }: PropertyValueProps) => {
export const CheckboxValue = ({
value,
onChange,
readonly,
}: PropertyValueProps) => {
const parsedValue = value === 'true' ? true : false;
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (readonly) {
return;
}
onChange(parsedValue ? 'false' : 'true');
},
[onChange, parsedValue]
[onChange, parsedValue, readonly]
);
return (
<PropertyValue onClick={handleClick} className={styles.container}>
@@ -19,6 +26,7 @@ export const CheckboxValue = ({ value, onChange }: PropertyValueProps) => {
className={styles.checkboxProperty}
checked={parsedValue}
onChange={() => {}}
disabled={readonly}
/>
</PropertyValue>
);

View File

@@ -77,14 +77,14 @@ export const CreatedByValue = () => {
if (!isCloud) {
return (
<PropertyValue>
<PropertyValue readonly>
<LocalUserValue />
</PropertyValue>
);
}
return (
<PropertyValue>
<PropertyValue readonly>
<CloudUserAvatar type="CreatedBy" />
</PropertyValue>
);
@@ -96,14 +96,14 @@ export const UpdatedByValue = () => {
if (!isCloud) {
return (
<PropertyValue>
<PropertyValue readonly>
<LocalUserValue />
</PropertyValue>
);
}
return (
<PropertyValue>
<PropertyValue readonly>
<CloudUserAvatar type="UpdatedBy" />
</PropertyValue>
);

View File

@@ -23,9 +23,25 @@ const useParsedDate = (value: string) => {
};
};
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
export const DateValue = ({
value,
onChange,
readonly,
}: PropertyValueProps) => {
const { parsedValue, displayValue } = useParsedDate(value);
if (readonly) {
return (
<PropertyValue
className={parsedValue ? '' : styles.empty}
isEmpty={!parsedValue}
readonly
>
{displayValue}
</PropertyValue>
);
}
return (
<Menu
contentOptions={{

View File

@@ -13,7 +13,10 @@ import { useCallback, useMemo } from 'react';
import * as styles from './doc-primary-mode.css';
import type { PropertyValueProps } from './types';
export const DocPrimaryModeValue = ({ onChange }: PropertyValueProps) => {
export const DocPrimaryModeValue = ({
onChange,
readonly,
}: PropertyValueProps) => {
const t = useI18n();
const doc = useService(DocService).doc;
@@ -51,13 +54,18 @@ export const DocPrimaryModeValue = ({ onChange }: PropertyValueProps) => {
[doc, t, onChange]
);
return (
<PropertyValue className={styles.container} hoverable={false}>
<PropertyValue
className={styles.container}
hoverable={false}
readonly={readonly}
>
<RadioGroup
width={BUILD_CONFIG.isMobileEdition ? '100%' : 194}
itemHeight={24}
value={primaryMode}
onChange={handleChange}
items={DocModeItems}
disabled={readonly}
className={styles.radioGroup}
/>
</PropertyValue>

View File

@@ -23,7 +23,10 @@ const getThemeOptions = (t: ReturnType<typeof useI18n>) =>
},
] satisfies RadioItem[];
export const EdgelessThemeValue = ({ onChange }: PropertyValueProps) => {
export const EdgelessThemeValue = ({
onChange,
readonly,
}: PropertyValueProps) => {
const t = useI18n();
const doc = useService(DocService).doc;
const edgelessTheme = useLiveData(doc.properties$).edgelessColorTheme;
@@ -38,13 +41,18 @@ export const EdgelessThemeValue = ({ onChange }: PropertyValueProps) => {
const themeItems = useMemo<RadioItem[]>(() => getThemeOptions(t), [t]);
return (
<PropertyValue className={styles.container} hoverable={false}>
<PropertyValue
className={styles.container}
hoverable={false}
readonly={readonly}
>
<RadioGroup
width={BUILD_CONFIG.isMobileEdition ? '100%' : 194}
itemHeight={24}
value={edgelessTheme || 'system'}
onChange={handleChange}
items={themeItems}
disabled={readonly}
/>
</PropertyValue>
);

View File

@@ -25,8 +25,11 @@ export const date = style({
padding: '0 4px',
borderRadius: 4,
whiteSpace: 'nowrap',
':hover': {
background: cssVarV2('layer/background/hoverOverlay'),
selectors: {
'&:hover:not([data-disabled])': {
background: cssVarV2('layer/background/hoverOverlay'),
},
},
});

View File

@@ -17,7 +17,7 @@ import * as styles from './journal.css';
import type { PropertyValueProps } from './types';
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
export const JournalValue = ({ onChange }: PropertyValueProps) => {
export const JournalValue = ({ readonly }: PropertyValueProps) => {
const t = useI18n();
const journalService = useService(JournalService);
@@ -53,9 +53,8 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
const date = dayjs(day).format('YYYY-MM-DD');
setSelectedDate(date);
journalService.setJournalDate(doc.id, date);
onChange?.(date, true);
},
[journalService, doc.id, onChange]
[journalService, doc.id]
);
const handleCheck = useCallback(
@@ -63,12 +62,11 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
if (!v) {
journalService.removeJournalDate(doc.id);
setShowDatePicker(false);
onChange?.(null, true);
} else {
handleDateSelect(selectedDate);
}
},
[onChange, journalService, doc.id, handleDateSelect, selectedDate]
[journalService, doc.id, handleDateSelect, selectedDate]
);
const workbench = useService(WorkbenchService).workbench;
@@ -88,11 +86,12 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
const toggle = useCallback(
(e: React.MouseEvent) => {
if (readonly) return;
if (propertyRef.current?.contains(e.target as Node)) {
handleCheck(null, !checked);
}
},
[checked, handleCheck]
[checked, handleCheck, readonly]
);
return (
@@ -100,9 +99,14 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
ref={propertyRef}
className={styles.property}
onClick={toggle}
readonly={readonly}
>
<div className={styles.root}>
<Checkbox className={styles.checkbox} checked={checked} />
<Checkbox
className={styles.checkbox}
checked={checked}
disabled={readonly}
/>
{checked ? (
<Menu
contentOptions={{
@@ -113,7 +117,7 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
}}
rootOptions={{
modal: true,
open: showDatePicker,
open: !readonly && showDatePicker,
onOpenChange: setShowDatePicker,
}}
items={
@@ -132,6 +136,7 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
onClick={e => {
e.stopPropagation();
}}
data-disabled={readonly ? 'true' : undefined}
>
{displayDate}
</div>

View File

@@ -10,7 +10,11 @@ import {
import * as styles from './number.css';
import type { PropertyValueProps } from './types';
export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
export const NumberValue = ({
value,
onChange,
readonly,
}: PropertyValueProps) => {
const parsedValue = isNaN(Number(value)) ? null : value;
const [tempValue, setTempValue] = useState(parsedValue);
const handleBlur = useCallback(
@@ -33,6 +37,7 @@ export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
<PropertyValue
className={styles.numberPropertyValueContainer}
isEmpty={!parsedValue}
readonly={readonly}
>
<input
className={styles.numberPropertyValueInput}
@@ -45,6 +50,7 @@ export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
disabled={readonly}
/>
</PropertyValue>
);

View File

@@ -8,7 +8,7 @@ import { useCallback, useMemo } from 'react';
import { container } from './page-width.css';
import type { PageLayoutMode, PropertyValueProps } from './types';
export const PageWidthValue = ({ onChange }: PropertyValueProps) => {
export const PageWidthValue = ({ readonly }: PropertyValueProps) => {
const t = useI18n();
const editorSetting = useService(EditorSettingService).editorSetting;
const defaultPageWidth = useLiveData(editorSetting.settings$).fullWidthLayout;
@@ -45,18 +45,18 @@ export const PageWidthValue = ({ onChange }: PropertyValueProps) => {
const handleChange = useCallback(
(value: PageLayoutMode) => {
doc.record.setProperty('pageWidth', value);
onChange?.(value, true);
},
[doc, onChange]
[doc]
);
return (
<PropertyValue className={container} hoverable={false}>
<PropertyValue className={container} hoverable={false} readonly={readonly}>
<RadioGroup
width={BUILD_CONFIG.isMobileEdition ? '100%' : 194}
itemHeight={24}
value={radioValue}
onChange={handleChange}
items={radioItems}
disabled={readonly}
/>
</PropertyValue>
);

View File

@@ -8,7 +8,7 @@ import { TagsInlineEditor } from '../tags-inline-editor';
import * as styles from './tags.css';
import type { PropertyValueProps } from './types';
export const TagsValue = ({ onChange }: PropertyValueProps) => {
export const TagsValue = ({ readonly }: PropertyValueProps) => {
const t = useI18n();
const doc = useService(DocService).doc;
@@ -22,6 +22,7 @@ export const TagsValue = ({ onChange }: PropertyValueProps) => {
className={styles.container}
isEmpty={empty}
data-testid="property-tags-value"
readonly={readonly}
>
<TagsInlineEditor
className={styles.tagInlineEditor}
@@ -29,7 +30,8 @@ export const TagsValue = ({ onChange }: PropertyValueProps) => {
'com.affine.page-properties.property-value-placeholder'
]()}
pageId={doc.id}
onChange={value => onChange(value, true)}
onChange={() => {}}
readonly={readonly}
/>
</PropertyValue>
);

View File

@@ -6,9 +6,7 @@ import { type ChangeEvent, useCallback } from 'react';
import * as styles from './template.css';
import type { PropertyValueProps } from './types';
export const TemplateValue = ({
onChange: propOnChange,
}: PropertyValueProps) => {
export const TemplateValue = ({ readonly }: PropertyValueProps) => {
const docService = useService(DocService);
const isTemplate = useLiveData(
@@ -17,24 +15,26 @@ export const TemplateValue = ({
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (readonly) return;
const value = e.target.checked;
docService.doc.record.setProperty('isTemplate', value);
propOnChange?.(value, true);
},
[docService.doc.record, propOnChange]
[docService.doc.record, readonly]
);
const toggle = useCallback(() => {
if (readonly) return;
docService.doc.record.setProperty('isTemplate', !isTemplate);
}, [docService.doc.record, isTemplate]);
}, [docService.doc.record, isTemplate, readonly]);
return (
<PropertyValue className={styles.property} onClick={toggle}>
<PropertyValue className={styles.property} onClick={toggle} readonly>
<Checkbox
data-testid="toggle-template-checkbox"
checked={!!isTemplate}
onChange={onChange}
className={styles.checkbox}
disabled={readonly}
/>
</PropertyValue>
);

View File

@@ -13,7 +13,11 @@ import { ConfigModal } from '../../mobile';
import * as styles from './text.css';
import type { PropertyValueProps } from './types';
const DesktopTextValue = ({ value, onChange }: PropertyValueProps) => {
const DesktopTextValue = ({
value,
onChange,
readonly,
}: PropertyValueProps) => {
const [tempValue, setTempValue] = useState<string>(value);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -50,6 +54,7 @@ const DesktopTextValue = ({ value, onChange }: PropertyValueProps) => {
className={styles.textPropertyValueContainer}
onClick={handleClick}
isEmpty={!value}
readonly={readonly}
>
<textarea
ref={ref}
@@ -62,6 +67,7 @@ const DesktopTextValue = ({ value, onChange }: PropertyValueProps) => {
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
disabled={readonly}
/>
<div className={styles.textInvisible}>
{tempValue}

View File

@@ -3,6 +3,7 @@ import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
export interface PropertyValueProps {
propertyInfo?: DocCustomPropertyInfo;
value: any;
readonly?: boolean;
onChange: (value: any, skipCommit?: boolean) => void; // if skipCommit is true, the change will be handled in the component itself
}

View File

@@ -0,0 +1,24 @@
import {
type DocPermissionActions,
GuardService,
} from '@affine/core/modules/permissions';
import { useLiveData, useService } from '@toeverything/infra';
import type React from 'react';
export const DocPermissionGuard = ({
docId,
children,
permission,
}: {
docId: string;
permission: DocPermissionActions;
children: (can: boolean) => React.ReactNode;
}) => {
const guardService = useService(GuardService);
const can = useLiveData(guardService.can$(permission, docId));
if (typeof children === 'function') {
return children(can);
}
throw new Error('children must be a function');
};

View File

@@ -12,8 +12,7 @@ import { useNavigateHelper } from '../use-navigate-helper';
export function useBlockSuiteMetaHelper() {
const workspace = useService(WorkspaceService).workspace;
const { setDocMeta, getDocMeta, setDocTitle, setDocReadonly } =
useDocMetaHelper();
const { setDocMeta, getDocMeta, setDocTitle } = useDocMetaHelper();
const { createDoc } = useDocCollectionHelper(workspace.docCollection);
const { openPage } = useNavigateHelper();
const docRecordList = useService(DocsService).list;
@@ -25,10 +24,9 @@ export function useBlockSuiteMetaHelper() {
const docRecord = docRecordList.doc$(docId).value;
if (docRecord) {
docRecord.moveToTrash();
setDocReadonly(docId, true);
}
},
[docRecordList, setDocReadonly]
[docRecordList]
);
const restoreFromTrash = useCallback(
@@ -36,10 +34,9 @@ export function useBlockSuiteMetaHelper() {
const docRecord = docRecordList.doc$(docId).value;
if (docRecord) {
docRecord.restoreFromTrash();
setDocReadonly(docId, false);
}
},
[docRecordList, setDocReadonly]
[docRecordList]
);
const permanentlyDeletePage = useCallback(

View File

@@ -9,7 +9,9 @@ import type { Editor } from '@affine/core/modules/editor';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { OpenInAppService } from '@affine/core/modules/open-in-app';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
@@ -35,6 +37,7 @@ export function useRegisterBlocksuiteEditorCommands(
active: boolean
) {
const doc = useService(DocService).doc;
const guardService = useService(GuardService);
const docId = doc.id;
const mode = useLiveData(editor.mode$);
const t = useI18n();
@@ -74,11 +77,22 @@ export function useRegisterBlocksuiteEditorCommands(
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
onConfirm: () => {
doc.moveToTrash();
onConfirm: async () => {
try {
const canTrash = await guardService.can('Doc_Trash', docId);
if (!canTrash) {
toast(t['com.affine.no-permission']());
return;
}
doc.moveToTrash();
} catch (error) {
console.error(error);
const userFriendlyError = UserFriendlyError.fromAnyError(error);
toast(t[`error.${userFriendlyError.name}`](userFriendlyError.data));
}
},
});
}, [doc, openConfirmModal, t]);
}, [doc, docId, guardService, openConfirmModal, t]);
const isCloudWorkspace = workspace.flavour !== 'local';
@@ -190,6 +204,11 @@ export function useRegisterBlocksuiteEditorCommands(
? t['com.affine.cmdk.affine.current-page-width-layout.standard']()
: t['com.affine.cmdk.affine.current-page-width-layout.full-width'](),
async run() {
const canEdit = await guardService.can('Doc_Update', docId);
if (!canEdit) {
toast(t['com.affine.no-permission']());
return;
}
doc.record.setProperty(
'pageWidth',
checked ? 'standard' : 'fullWidth'
@@ -306,7 +325,12 @@ export function useRegisterBlocksuiteEditorCommands(
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.cmdk.affine.editor.restore-from-trash'](),
run() {
async run() {
const canRestore = await guardService.can('Doc_Restore', docId);
if (!canRestore) {
toast(t['com.affine.no-permission']());
return;
}
track.$.cmdk.editor.restoreDoc();
doc.restoreFromTrash();
@@ -383,5 +407,6 @@ export function useRegisterBlocksuiteEditorCommands(
checked,
openInAppService,
active,
guardService,
]);
}

View File

@@ -1,5 +1,4 @@
import { DocsService } from '@affine/core/modules/doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { DocMeta, Workspace } from '@blocksuite/affine/store';
import { useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
@@ -26,7 +25,6 @@ export function useBlockSuiteDocMeta(docCollection: Workspace) {
}
export function useDocMetaHelper() {
const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService);
const setDocTitle = useAsyncCallback(
@@ -53,23 +51,13 @@ export function useDocMetaHelper() {
},
[docsService]
);
const setDocReadonly = useCallback(
(docId: string, readonly: boolean) => {
const doc = workspaceService.workspace.docCollection.getDoc(docId);
if (doc) {
doc.readonly = readonly;
}
},
[workspaceService]
);
return useMemo(
() => ({
setDocTitle,
setDocMeta,
getDocMeta,
setDocReadonly,
}),
[getDocMeta, setDocMeta, setDocReadonly, setDocTitle]
[getDocMeta, setDocMeta, setDocTitle]
);
}

View File

@@ -3,6 +3,7 @@ import './page-detail-editor.css';
import type { AffineEditorContainer } from '@blocksuite/affine/presets';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useEffect } from 'react';
import { DocService } from '../modules/doc';
import { EditorService } from '../modules/editor';
@@ -24,9 +25,13 @@ export type OnLoadEditor = (
export interface PageDetailEditorProps {
onLoad?: OnLoadEditor;
readonly?: boolean;
}
export const PageDetailEditor = ({ onLoad }: PageDetailEditorProps) => {
export const PageDetailEditor = ({
onLoad,
readonly,
}: PageDetailEditorProps) => {
const editor = useService(EditorService).editor;
const mode = useLiveData(editor.mode$);
const defaultOpenProperty = useLiveData(editor.defaultOpenProperty$);
@@ -47,6 +52,10 @@ export const PageDetailEditor = ({ onLoad }: PageDetailEditorProps) => {
? pageWidth === 'fullWidth'
: settings.fullWidthLayout;
useEffect(() => {
editor.doc.blockSuiteDoc.readonly = readonly ?? false;
}, [editor, readonly]);
return (
<CustomEditorWrapper>
<Editor
@@ -58,6 +67,7 @@ export const PageDetailEditor = ({ onLoad }: PageDetailEditorProps) => {
defaultOpenProperty={defaultOpenProperty}
page={editor.doc.blockSuiteDoc}
shared={isSharedMode}
readonly={readonly}
onEditorReady={onLoad}
/>
</CustomEditorWrapper>

View File

@@ -26,7 +26,7 @@ export const ListFloatingToolbar = ({
<FloatingToolbar className={styles.floatingToolbar} open={open}>
<FloatingToolbar.Item>{content}</FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
<FloatingToolbar.Separator />
{(!!onRestore || !!onDelete) && <FloatingToolbar.Separator />}
{!!onRestore && (
<FloatingToolbar.Button
onClick={onRestore}

View File

@@ -1,11 +1,19 @@
import { Checkbox, Tooltip, useDraggable } from '@affine/component';
import { type Doc, DocsService } from '@affine/core/modules/doc';
import { TagService } from '@affine/core/modules/tag';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { stopPropagation } from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import type { ForwardedRef, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useEffect, useMemo } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
import {
@@ -142,7 +150,7 @@ const PageListOperationsCell = ({
) : null;
};
export const PageListItem = (props: PageListItemProps) => {
const PagelistItemInner = (props: PageListItemProps) => {
const [displayProperties] = useAllDocDisplayProperties();
const pageTitleElement = useMemo(() => {
return (
@@ -249,6 +257,29 @@ export const PageListItem = (props: PageListItemProps) => {
);
};
export const PageListItem = (props: PageListItemProps) => {
const docsService = useService(DocsService);
const [doc, setDoc] = useState<Doc | null>(null);
useLayoutEffect(() => {
const { doc, release } = docsService.open(props.pageId);
setDoc(doc);
return () => {
release();
};
}, [props.pageId, docsService.list, docsService]);
if (!doc) {
return null;
}
return (
<FrameworkScope scope={doc.scope}>
<PagelistItemInner {...props} />
</FrameworkScope>
);
};
type PageListWrapperProps = PropsWithChildren<
Pick<PageListItemProps, 'to' | 'pageId' | 'onClick' | 'draggable'> & {
isDragging: boolean;

View File

@@ -56,12 +56,14 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
filters,
listItem,
setHideHeaderCreateNewPage,
disableMultiDelete,
}: {
tag?: Tag;
collection?: Collection;
filters?: Filter[];
listItem?: DocMeta[];
setHideHeaderCreateNewPage?: (hide: boolean) => void;
disableMultiDelete?: boolean;
}) {
const t = useI18n();
const listRef = useRef<ItemListHandle>(null);
@@ -186,7 +188,7 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
/>
<ListFloatingToolbar
open={showFloatingToolbar}
onDelete={handleMultiDelete}
onDelete={disableMultiDelete ? undefined : handleMultiDelete}
onClose={hideFloatingToolbar}
content={
<Trans

View File

@@ -14,6 +14,7 @@ import {
CompatibleFavoriteItemsAdapter,
FavoriteService,
} from '@affine/core/modules/favorite';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
@@ -57,7 +58,7 @@ export interface PageOperationCellProps {
onRemoveFromAllowList?: () => void;
}
export const PageOperationCell = ({
const PageOperationCellMenuItem = ({
isInAllowList,
page,
onRemoveFromAllowList,
@@ -67,12 +68,15 @@ export const PageOperationCell = ({
workspaceService,
compatibleFavoriteItemsAdapter: favAdapter,
workbenchService,
guardService,
} = useServices({
WorkspaceService,
CompatibleFavoriteItemsAdapter,
WorkbenchService,
GuardService,
});
const canMoveToTrash = useLiveData(guardService.can$('Doc_Trash', page.id));
const currentWorkspace = workspaceService.workspace;
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const workbench = workbenchService.workbench;
@@ -159,7 +163,7 @@ export const PageOperationCell = ({
}
}, [onRemoveFromAllowList]);
const OperationMenu = (
return (
<>
{page.isPublic && (
<DisablePublicSharing
@@ -199,9 +203,36 @@ export const PageOperationCell = ({
{t['com.affine.header.option.duplicate']()}
</MenuItem>
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
<MoveToTrash
data-testid="move-to-trash"
onSelect={onRemoveToTrash}
disabled={!canMoveToTrash}
/>
</>
);
};
export const PageOperationCell = ({
isInAllowList,
page,
onRemoveFromAllowList,
}: PageOperationCellProps) => {
const t = useI18n();
const { compatibleFavoriteItemsAdapter: favAdapter } = useServices({
CompatibleFavoriteItemsAdapter,
});
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const onToggleFavoritePage = useCallback(() => {
const status = favAdapter.isFavorite(page.id, 'doc');
favAdapter.toggle(page.id, 'doc');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [page.id, favAdapter, t]);
return (
<>
<ColWrapper
@@ -214,7 +245,13 @@ export const PageOperationCell = ({
</ColWrapper>
<ColWrapper alignment="start">
<Menu
items={OperationMenu}
items={
<PageOperationCellMenuItem
page={page}
isInAllowList={isInAllowList}
onRemoveFromAllowList={onRemoveFromAllowList}
/>
}
contentOptions={{
align: 'end',
}}

View File

@@ -1,6 +1,7 @@
import { toast, useConfirmModal } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
@@ -16,8 +17,15 @@ import type { ItemListHandle, ListItem } from './types';
import { useFilteredPageMetas } from './use-filtered-page-metas';
import { VirtualizedList } from './virtualized-list';
export const VirtualizedTrashList = () => {
export const VirtualizedTrashList = ({
disableMultiDelete,
disableMultiRestore,
}: {
disableMultiDelete?: boolean;
disableMultiRestore?: boolean;
}) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const guardService = useService(GuardService);
const docCollection = currentWorkspace.docCollection;
const { restoreFromTrash, permanentlyDeletePage } = useBlockSuiteMetaHelper();
const pageMetas = useBlockSuiteDocMeta(docCollection);
@@ -82,16 +90,38 @@ export const VirtualizedTrashList = () => {
(item: ListItem) => {
const page = item as DocMeta;
const onRestorePage = () => {
restoreFromTrash(page.id);
toast(
t['com.affine.toastMessage.restored']({
title: page.title || 'Untitled',
guardService
.can('Doc_Delete', page.id)
.then(can => {
if (can) {
restoreFromTrash(page.id);
toast(
t['com.affine.toastMessage.restored']({
title: page.title || 'Untitled',
})
);
} else {
toast(t['com.affine.no-permission']());
}
})
);
.catch(e => {
console.error(e);
});
};
const onPermanentlyDeletePage = () => {
permanentlyDeletePage(page.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
guardService
.can('Doc_Delete', page.id)
.then(can => {
if (can) {
permanentlyDeletePage(page.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
} else {
toast(t['com.affine.no-permission']());
}
})
.catch(e => {
console.error(e);
});
};
return (
@@ -102,7 +132,7 @@ export const VirtualizedTrashList = () => {
);
},
[permanentlyDeletePage, restoreFromTrash, t]
[guardService, permanentlyDeletePage, restoreFromTrash, t]
);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
@@ -128,9 +158,9 @@ export const VirtualizedTrashList = () => {
/>
<ListFloatingToolbar
open={showFloatingToolbar}
onDelete={onConfirmPermanentlyDelete}
onDelete={disableMultiDelete ? undefined : onConfirmPermanentlyDelete}
onClose={hideFloatingToolbar}
onRestore={handleMultiRestore}
onRestore={disableMultiRestore ? undefined : handleMultiRestore}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"

View File

@@ -1,12 +1,15 @@
import {
AnimatedDeleteIcon,
toast,
useConfirmModal,
useDropTarget,
} from '@affine/component';
import { MenuLinkItem } from '@affine/core/modules/app-sidebar/views';
import { DocsService } from '@affine/core/modules/doc';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { GuardService } from '@affine/core/modules/permissions';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
@@ -16,6 +19,7 @@ export const TrashButton = () => {
const { openConfirmModal } = useConfirmModal();
const globalContextService = useService(GlobalContextService);
const trashActive = useLiveData(globalContextService.globalContext.isTrash.$);
const guardService = useService(GuardService);
const { dropTargetRef, draggedOver } = useDropTarget<AffineDNDData>(
() => ({
@@ -41,15 +45,32 @@ export const TrashButton = () => {
confirmButtonOptions: {
variant: 'error',
},
onConfirm() {
docRecord.moveToTrash();
async onConfirm() {
try {
const canTrash = await guardService.can(
'Doc_Trash',
docRecord.id
);
if (!canTrash) {
toast(t['com.affine.no-permission']());
return;
}
docRecord.moveToTrash();
} catch (error) {
console.error(error);
const userFriendlyError =
UserFriendlyError.fromAnyError(error);
toast(
t[`error.${userFriendlyError.name}`](userFriendlyError.data)
);
}
},
});
}
}
},
}),
[docsService.list, openConfirmModal, t]
[docsService.list, guardService, openConfirmModal, t]
);
return (

View File

@@ -7,6 +7,9 @@ export const tagsInlineEditor = style({
'&[data-empty=true]': {
color: cssVar('placeholderColor'),
},
'&[data-readonly="true"]': {
pointerEvents: 'none',
},
},
});