mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 18:16:15 +08:00
feat(core): guard service (#9816)
This commit is contained in:
@@ -11,13 +11,13 @@ export class PermissionModule {}
|
||||
export { PermissionService } from './service';
|
||||
export {
|
||||
DOC_ACTIONS,
|
||||
type DocActionPermissions,
|
||||
type DocAction,
|
||||
DocRole,
|
||||
fixupDocRole,
|
||||
mapDocRoleToPermissions,
|
||||
mapWorkspaceRoleToPermissions,
|
||||
PublicDocMode,
|
||||
WORKSPACE_ACTIONS,
|
||||
type WorkspaceActionPermissions,
|
||||
type WorkspaceAction,
|
||||
WorkspaceRole,
|
||||
} from './types';
|
||||
|
||||
@@ -146,12 +146,6 @@ type ResourceActionName<T extends keyof typeof Actions> =
|
||||
export type WorkspaceAction = ResourceActionName<'Workspace'>;
|
||||
export type DocAction = ResourceActionName<'Doc'>;
|
||||
export type Action = WorkspaceAction | DocAction;
|
||||
export type WorkspaceActionPermissions = {
|
||||
[key in WorkspaceAction]: boolean;
|
||||
};
|
||||
export type DocActionPermissions = {
|
||||
[key in DocAction]: boolean;
|
||||
};
|
||||
|
||||
const cache = new WeakMap<object, any>();
|
||||
const buildPathReader = (
|
||||
|
||||
@@ -31,7 +31,7 @@ import { Models } from '../../../models';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import {
|
||||
DOC_ACTIONS,
|
||||
type DocActionPermissions,
|
||||
DocAction,
|
||||
DocRole,
|
||||
fixupDocRole,
|
||||
mapDocRoleToPermissions,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { PublicUserType } from '../../user';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { WorkspaceType } from '../types';
|
||||
import { DotToUnderline, mapPermissionToGraphqlPermissions } from './workspace';
|
||||
|
||||
registerEnumType(PublicDocMode, {
|
||||
name: 'PublicDocMode',
|
||||
@@ -128,10 +129,12 @@ class GrantedDocUserType {
|
||||
@ObjectType()
|
||||
class PaginatedGrantedDocUserType extends Paginated(GrantedDocUserType) {}
|
||||
|
||||
const DocPermissions = registerObjectType<DocActionPermissions>(
|
||||
const DocPermissions = registerObjectType<
|
||||
Record<DotToUnderline<DocAction>, boolean>
|
||||
>(
|
||||
Object.fromEntries(
|
||||
DOC_ACTIONS.map(action => [
|
||||
action,
|
||||
action.replaceAll('.', '_'),
|
||||
{
|
||||
type: () => Boolean,
|
||||
options: {
|
||||
@@ -143,15 +146,6 @@ const DocPermissions = registerObjectType<DocActionPermissions>(
|
||||
{ name: 'DocPermissions' }
|
||||
);
|
||||
|
||||
@ObjectType()
|
||||
export class DocRolePermissions {
|
||||
@Field(() => DocRole)
|
||||
role!: DocRole;
|
||||
|
||||
@Field(() => DocPermissions)
|
||||
permissions!: DocActionPermissions;
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceDocResolver {
|
||||
private readonly logger = new Logger(WorkspaceDocResolver.name);
|
||||
@@ -365,7 +359,7 @@ export class DocResolver {
|
||||
async permissions(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Parent() doc: DocType
|
||||
): Promise<DocRolePermissions> {
|
||||
): Promise<InstanceType<typeof DocPermissions>> {
|
||||
const [permission, workspacePermission] = await this.prisma.$transaction(
|
||||
tx =>
|
||||
Promise.all([
|
||||
@@ -385,12 +379,11 @@ export class DocResolver {
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
role: permission?.type ?? DocRole.External,
|
||||
permissions: mapDocRoleToPermissions(
|
||||
return mapPermissionToGraphqlPermissions(
|
||||
mapDocRoleToPermissions(
|
||||
fixupDocRole(workspacePermission?.type, permission?.type)
|
||||
),
|
||||
};
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => PaginatedGrantedDocUserType, {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
mapWorkspaceRoleToPermissions,
|
||||
PermissionService,
|
||||
WORKSPACE_ACTIONS,
|
||||
type WorkspaceActionPermissions,
|
||||
WorkspaceAction,
|
||||
WorkspaceRole,
|
||||
} from '../../permission';
|
||||
import { QuotaService, WorkspaceQuotaType } from '../../quota';
|
||||
@@ -51,6 +51,22 @@ import {
|
||||
} from '../types';
|
||||
import { WorkspaceService } from './service';
|
||||
|
||||
export type DotToUnderline<T extends string> =
|
||||
T extends `${infer Prefix}.${infer Suffix}`
|
||||
? `${Prefix}_${DotToUnderline<Suffix>}`
|
||||
: T;
|
||||
|
||||
export function mapPermissionToGraphqlPermissions<A extends string>(
|
||||
permission: Record<A, boolean>
|
||||
): Record<DotToUnderline<A>, boolean> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(permission).map(([key, value]) => [
|
||||
key.replaceAll('.', '_'),
|
||||
value,
|
||||
])
|
||||
) as Record<DotToUnderline<A>, boolean>;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class EditorType implements Partial<Editor> {
|
||||
@Field()
|
||||
@@ -75,10 +91,12 @@ class WorkspacePageMeta {
|
||||
updatedBy!: EditorType | null;
|
||||
}
|
||||
|
||||
const WorkspacePermissions = registerObjectType<WorkspaceActionPermissions>(
|
||||
const WorkspacePermissions = registerObjectType<
|
||||
Record<DotToUnderline<WorkspaceAction>, boolean>
|
||||
>(
|
||||
Object.fromEntries(
|
||||
WORKSPACE_ACTIONS.map(action => [
|
||||
action,
|
||||
action.replaceAll('.', '_'),
|
||||
{
|
||||
type: () => Boolean,
|
||||
options: {
|
||||
@@ -96,7 +114,7 @@ export class WorkspaceRolePermissions {
|
||||
role!: WorkspaceRole;
|
||||
|
||||
@Field(() => WorkspacePermissions)
|
||||
permissions!: WorkspaceActionPermissions;
|
||||
permissions!: Record<DotToUnderline<WorkspaceAction>, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,7 +360,7 @@ export class WorkspaceResolver {
|
||||
async workspaceRolePermissions(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
) {
|
||||
): Promise<WorkspaceRolePermissions> {
|
||||
const workspace = await this.prisma.workspaceUserPermission.findFirst({
|
||||
where: { workspaceId: id, userId: user.id },
|
||||
});
|
||||
@@ -351,7 +369,9 @@ export class WorkspaceResolver {
|
||||
}
|
||||
return {
|
||||
role: workspace.type,
|
||||
permissions: mapWorkspaceRoleToPermissions(workspace.type),
|
||||
permissions: mapPermissionToGraphqlPermissions(
|
||||
mapWorkspaceRoleToPermissions(workspace.type)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ summary {
|
||||
}
|
||||
|
||||
a,
|
||||
button {
|
||||
button:not([disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ export const button = style({
|
||||
outline: 0,
|
||||
borderRadius: 8,
|
||||
transition: 'all .3s',
|
||||
cursor: 'pointer',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
|
||||
// hover layer
|
||||
@@ -169,6 +168,10 @@ export const button = style({
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
'&:not([data-disabled])': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
|
||||
// default keyboard focus style
|
||||
'&:focus-visible::after': {
|
||||
content: '""',
|
||||
|
||||
@@ -316,7 +316,11 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
||||
|
||||
// external data is only available in drop event thus
|
||||
// this is the only case for getAdaptedEventArgs
|
||||
const args = getAdaptedEventArgs(_args, options.fromExternalData, true);
|
||||
const args = {
|
||||
...getAdaptedEventArgs(_args, options.fromExternalData, true),
|
||||
treeInstruction: extractInstruction(_args.self.data),
|
||||
closestEdge: extractClosestEdge(_args.self.data),
|
||||
};
|
||||
if (
|
||||
isExternalDrag(_args) &&
|
||||
options.fromExternalData &&
|
||||
@@ -329,11 +333,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
||||
}
|
||||
|
||||
if (args.location.current.dropTargets[0]?.element === element) {
|
||||
options.onDrop?.({
|
||||
...args,
|
||||
treeInstruction: extractInstruction(args.self.data),
|
||||
closestEdge: extractClosestEdge(args.self.data),
|
||||
} as DropTargetDropEvent<D>);
|
||||
options.onDrop?.(args as DropTargetDropEvent<D>);
|
||||
}
|
||||
},
|
||||
getData: (args: DropTargetGetFeedback<D>) => {
|
||||
|
||||
@@ -51,7 +51,7 @@ export const menuItem = style({
|
||||
'&.block': {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
'&[data-disabled], &.disabled': {
|
||||
vars: {
|
||||
[iconColor]: cssVarV2('icon/disable'),
|
||||
[labelColor]: cssVarV2('text/secondary'),
|
||||
|
||||
@@ -16,13 +16,15 @@ export const useMenuItem = <T extends MenuItemProps>({
|
||||
checked,
|
||||
selected,
|
||||
block,
|
||||
disabled,
|
||||
...otherProps
|
||||
}: T) => {
|
||||
const className = clsx(
|
||||
styles.menuItem,
|
||||
{
|
||||
danger: type === 'danger',
|
||||
warning: type === 'warning',
|
||||
danger: disabled ? false : type === 'danger',
|
||||
warning: disabled ? false : type === 'warning',
|
||||
disabled,
|
||||
checked,
|
||||
selected,
|
||||
block,
|
||||
|
||||
@@ -133,10 +133,12 @@ export const propertyValueContainer = style({
|
||||
'&[data-readonly="false"][data-hoverable="true"]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&[data-readonly="false"][data-hoverable="true"]:is(:hover, :focus-within)':
|
||||
{
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'&[data-readonly="true"][data-hoverable="true"]': {
|
||||
cursor: 'default',
|
||||
},
|
||||
'&[data-hoverable="true"]:is(:hover, :focus-within)': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ export const RadioGroup = memo(function RadioGroup({
|
||||
indicatorStyle,
|
||||
iconMode,
|
||||
onChange,
|
||||
disabled,
|
||||
}: RadioProps) {
|
||||
const animationTImerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const finalItems = useMemo(() => {
|
||||
@@ -158,6 +159,7 @@ export const RadioGroup = memo(function RadioGroup({
|
||||
className={clsx(styles.radioButtonGroup, className)}
|
||||
style={finalStyle}
|
||||
data-icon-mode={iconMode}
|
||||
disabled={disabled}
|
||||
>
|
||||
{finalItems.map(({ customRender, ...item }, index) => {
|
||||
const testId = item.testId ? { 'data-testid': item.testId } : {};
|
||||
@@ -179,6 +181,7 @@ export const RadioGroup = memo(function RadioGroup({
|
||||
style={style}
|
||||
{...testId}
|
||||
{...item.attrs}
|
||||
disabled={disabled}
|
||||
>
|
||||
<RadixRadioGroup.Indicator
|
||||
forceMount
|
||||
|
||||
@@ -25,7 +25,7 @@ export const radioButton = style({
|
||||
'&[data-state="checked"]': {
|
||||
color: cssVarV2('switch/fontColor/primary'),
|
||||
},
|
||||
'&[data-state="unchecked"]:hover': {
|
||||
'&[data-state="unchecked"]:hover:not([disabled])': {
|
||||
background: cssVarV2('switch/buttonBackground/hover'),
|
||||
},
|
||||
'[data-icon-mode=true] &': {
|
||||
|
||||
@@ -199,6 +199,9 @@ const REPLACE_SELECTION = {
|
||||
icon: ReplaceIcon,
|
||||
title: 'Replace selection',
|
||||
showWhen: (host: EditorHost) => {
|
||||
if (host.std.store.readonly$.value) {
|
||||
return false;
|
||||
}
|
||||
const textSelection = host.selection.find(TextSelection);
|
||||
const blockSelections = host.selection.filter(BlockSelection);
|
||||
if (
|
||||
@@ -252,7 +255,12 @@ const REPLACE_SELECTION = {
|
||||
const INSERT_BELOW = {
|
||||
icon: InsertBelowIcon,
|
||||
title: 'Insert below',
|
||||
showWhen: () => true,
|
||||
showWhen: (host: EditorHost) => {
|
||||
if (host.std.store.readonly$.value) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
toast: 'Successfully inserted',
|
||||
handler: async (
|
||||
host: EditorHost,
|
||||
@@ -282,7 +290,12 @@ const SAVE_CHAT_TO_BLOCK_ACTION: ChatAction = {
|
||||
icon: BlockIcon,
|
||||
title: 'Save chat to block',
|
||||
toast: 'Successfully saved chat to a block',
|
||||
showWhen: () => true,
|
||||
showWhen: (host: EditorHost) => {
|
||||
if (host.std.store.readonly$.value) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
handler: async (
|
||||
host: EditorHost,
|
||||
_,
|
||||
@@ -378,7 +391,12 @@ const SAVE_CHAT_TO_BLOCK_ACTION: ChatAction = {
|
||||
const ADD_TO_EDGELESS_AS_NOTE = {
|
||||
icon: CreateIcon,
|
||||
title: 'Add to edgeless as note',
|
||||
showWhen: () => true,
|
||||
showWhen: (host: EditorHost) => {
|
||||
if (host.std.store.readonly$.value) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
toast: 'New note created',
|
||||
handler: async (host: EditorHost, content: string) => {
|
||||
reportResponse('result:add-note');
|
||||
@@ -451,7 +469,12 @@ const CREATE_AS_DOC = {
|
||||
const CREATE_AS_LINKED_DOC = {
|
||||
icon: CreateIcon,
|
||||
title: 'Create as a linked doc',
|
||||
showWhen: () => true,
|
||||
showWhen: (host: EditorHost) => {
|
||||
if (host.std.store.readonly$.value) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
toast: 'New doc created',
|
||||
handler: async (host: EditorHost, content: string) => {
|
||||
reportResponse('result:add-page');
|
||||
|
||||
@@ -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>
|
||||
|
||||
+5
-3
@@ -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}
|
||||
|
||||
+3
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+92
-77
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+13
-9
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
+29
-4
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -7,6 +7,9 @@ export const tagsInlineEditor = style({
|
||||
'&[data-empty=true]': {
|
||||
color: cssVar('placeholderColor'),
|
||||
},
|
||||
'&[data-readonly="true"]': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -47,10 +47,7 @@ export const DocInfoDialog = ({
|
||||
className={styles.titleContainer}
|
||||
data-testid="info-modal-title"
|
||||
>
|
||||
<BlocksuiteHeaderTitle
|
||||
docId={docId}
|
||||
className={styles.titleStyle}
|
||||
/>
|
||||
<BlocksuiteHeaderTitle className={styles.titleStyle} />
|
||||
</div>
|
||||
<InfoTable docId={docId} onClose={() => close()} />
|
||||
</Scrollable.Viewport>
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
DatabaseValueCell,
|
||||
} from '@affine/core/modules/doc-info/types';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
@@ -33,10 +34,15 @@ export const InfoTable = ({
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const { docsSearchService, docsService } = useServices({
|
||||
const { docsSearchService, docsService, guardService } = useServices({
|
||||
DocsSearchService,
|
||||
DocsService,
|
||||
GuardService,
|
||||
});
|
||||
const canEditPropertyInfo = useLiveData(
|
||||
guardService.can$('Workspace_Properties_Update')
|
||||
);
|
||||
const canEditProperty = useLiveData(guardService.can$('Doc_Update', docId));
|
||||
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
|
||||
const properties = useLiveData(docsService.propertyList.sortedProperties$);
|
||||
const links = useLiveData(
|
||||
@@ -133,6 +139,8 @@ export const InfoTable = ({
|
||||
<DocPropertyRow
|
||||
key={property.id}
|
||||
propertyInfo={property}
|
||||
readonly={!canEditProperty}
|
||||
propertyInfoReadonly={!canEditPropertyInfo}
|
||||
defaultOpenEditMenu={newPropertyId === property.id}
|
||||
onChange={value => onPropertyChange(property, value)}
|
||||
onPropertyInfoChange={(...args) =>
|
||||
@@ -140,22 +148,33 @@ export const InfoTable = ({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Menu
|
||||
items={<CreatePropertyMenuItems onCreated={onPropertyAdded} />}
|
||||
contentOptions={{
|
||||
onClick(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!canEditPropertyInfo ? (
|
||||
<Button
|
||||
disabled
|
||||
variant="plain"
|
||||
prefix={<PlusIcon />}
|
||||
className={styles.addPropertyButton}
|
||||
>
|
||||
{t['com.affine.page-properties.add-property']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
) : (
|
||||
<Menu
|
||||
items={<CreatePropertyMenuItems onCreated={onPropertyAdded} />}
|
||||
contentOptions={{
|
||||
onClick(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="plain"
|
||||
prefix={<PlusIcon />}
|
||||
className={styles.addPropertyButton}
|
||||
>
|
||||
{t['com.affine.page-properties.add-property']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
)}
|
||||
</PropertyCollapsibleContent>
|
||||
</PropertyCollapsibleSection>
|
||||
<Divider size="thinner" />
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
VirtualizedPageList,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -26,8 +27,11 @@ import { AllPageHeader } from './all-page-header';
|
||||
export const AllPage = () => {
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const globalContext = useService(GlobalContextService).globalContext;
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
||||
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
|
||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
|
||||
const [filters, setFilters] = useState<Filter[]>([]);
|
||||
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
|
||||
@@ -65,6 +69,7 @@ export const AllPage = () => {
|
||||
<FilterContainer filters={filters} onChangeFilters={setFilters} />
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<VirtualizedPageList
|
||||
disableMultiDelete={!isAdmin && !isOwner}
|
||||
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
|
||||
filters={filters}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { VirtualizedPageList } from '@affine/core/components/page-list';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
@@ -30,6 +31,9 @@ export const CollectionDetail = ({
|
||||
const { workspaceDialogService } = useServices({
|
||||
WorkspaceDialogService,
|
||||
});
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
|
||||
|
||||
const handleEditCollection = useCallback(() => {
|
||||
@@ -50,6 +54,7 @@ export const CollectionDetail = ({
|
||||
<VirtualizedPageList
|
||||
collection={collection}
|
||||
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
|
||||
disableMultiDelete={!isAdmin && !isOwner}
|
||||
/>
|
||||
</ViewBody>
|
||||
</>
|
||||
|
||||
+1
-4
@@ -158,10 +158,7 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
|
||||
<ViewTitle title={title} />
|
||||
<ViewIcon icon={currentMode ?? 'page'} />
|
||||
<EditorModeSwitch />
|
||||
<BlocksuiteHeaderTitle
|
||||
docId={page.id}
|
||||
inputHandleRef={titleInputHandleRef}
|
||||
/>
|
||||
<BlocksuiteHeaderTitle inputHandleRef={titleInputHandleRef} />
|
||||
<TemplateMark />
|
||||
<div className={styles.iconButtonContainer}>
|
||||
{hideCollect ? null : (
|
||||
|
||||
@@ -6,12 +6,12 @@ import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
||||
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
|
||||
import { DocPropertySidebar } from '@affine/core/components/doc-properties/sidebar';
|
||||
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
||||
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
||||
import { DocService } from '@affine/core/modules/doc';
|
||||
import { EditorService } from '@affine/core/modules/editor';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { RecentDocsService } from '@affine/core/modules/quicksearch';
|
||||
import { ViewService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
@@ -73,6 +73,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
workspaceService,
|
||||
globalContextService,
|
||||
featureFlagService,
|
||||
guardService,
|
||||
} = useServices({
|
||||
WorkbenchService,
|
||||
ViewService,
|
||||
@@ -81,6 +82,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
WorkspaceService,
|
||||
GlobalContextService,
|
||||
FeatureFlagService,
|
||||
GuardService,
|
||||
});
|
||||
const workbench = workbenchService.workbench;
|
||||
const editor = editorService.editor;
|
||||
@@ -98,7 +100,6 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
const isSideBarOpen = useLiveData(workbench.sidebarOpen$);
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const chatPanelRef = useRef<ChatPanel | null>(null);
|
||||
const { setDocReadonly } = useDocMetaHelper();
|
||||
|
||||
const peekView = useService(PeekViewService).peekView;
|
||||
|
||||
@@ -149,12 +150,6 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
return;
|
||||
}, [doc, globalContext, isActiveView, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if ('isMobile' in environment && environment.isMobile) {
|
||||
setDocReadonly(doc.id, true);
|
||||
}
|
||||
}, [doc.id, setDocReadonly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActiveView) {
|
||||
globalContext.isTrashDoc.set(!!isInTrash);
|
||||
@@ -273,6 +268,10 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const canEdit = useLiveData(guardService.can$('Doc_Update', doc.id));
|
||||
|
||||
const readonly = !canEdit || isInTrash;
|
||||
|
||||
return (
|
||||
<FrameworkScope scope={editor.scope}>
|
||||
<ViewHeader>
|
||||
@@ -302,7 +301,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
styles.editorContainer
|
||||
)}
|
||||
>
|
||||
<PageDetailEditor onLoad={onLoad} />
|
||||
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar
|
||||
className={clsx({
|
||||
|
||||
+31
-14
@@ -8,6 +8,7 @@ import {
|
||||
Scrollable,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
|
||||
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
|
||||
import { MoveToTrash } from '@affine/core/components/page-list';
|
||||
import {
|
||||
@@ -362,22 +363,38 @@ const ConflictList = ({
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<CalendarXmarkIcon />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleRemoveJournalMark(docRecord.id);
|
||||
}}
|
||||
data-testid="journal-conflict-remove-mark"
|
||||
<DocPermissionGuard
|
||||
docId={docRecord.id}
|
||||
permission="Doc_Update"
|
||||
>
|
||||
{t[
|
||||
'com.affine.page-properties.property.journal-remove'
|
||||
]()}
|
||||
</MenuItem>
|
||||
{canEdit => (
|
||||
<MenuItem
|
||||
prefixIcon={<CalendarXmarkIcon />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleRemoveJournalMark(docRecord.id);
|
||||
}}
|
||||
data-testid="journal-conflict-remove-mark"
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t[
|
||||
'com.affine.page-properties.property.journal-remove'
|
||||
]()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
<MenuSeparator />
|
||||
<MoveToTrash
|
||||
onSelect={() => handleOpenTrashModal(docRecord)}
|
||||
/>
|
||||
<DocPermissionGuard
|
||||
docId={docRecord.id}
|
||||
permission="Doc_Trash"
|
||||
>
|
||||
{canTrash => (
|
||||
<MoveToTrash
|
||||
onSelect={() => handleOpenTrashModal(docRecord)}
|
||||
disabled={!canTrash}
|
||||
/>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { DocMode } from '@blocksuite/affine/blocks';
|
||||
import * as styles from './share-header.css';
|
||||
|
||||
export function ShareHeader({
|
||||
pageId,
|
||||
publishMode,
|
||||
isTemplate,
|
||||
templateName,
|
||||
@@ -21,7 +20,7 @@ export function ShareHeader({
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<EditorModeSwitch />
|
||||
<BlocksuiteHeaderTitle docId={pageId} />
|
||||
<BlocksuiteHeaderTitle />
|
||||
<div className={styles.spacer} />
|
||||
<ShareHeaderRightItem
|
||||
publishMode={publishMode}
|
||||
|
||||
@@ -260,7 +260,7 @@ const SharePageInner = ({
|
||||
styles.editorContainer
|
||||
)}
|
||||
>
|
||||
<PageDetailEditor onLoad={onEditorLoad} />
|
||||
<PageDetailEditor onLoad={onEditorLoad} readonly />
|
||||
{publishMode === 'page' && !BUILD_CONFIG.isElectron ? (
|
||||
<ShareFooter />
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
VirtualizedPageList,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import {
|
||||
useIsActiveView,
|
||||
@@ -26,6 +27,9 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
|
||||
const globalContext = useService(GlobalContextService).globalContext;
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
|
||||
const tagList = useService(TagService).tagList;
|
||||
const currentTag = useLiveData(tagList.tagByTagId$(tagId));
|
||||
@@ -72,6 +76,7 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
|
||||
<VirtualizedPageList
|
||||
tag={currentTag}
|
||||
listItem={filteredPageMetas}
|
||||
disableMultiDelete={!isAdmin && !isOwner}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
} from '@affine/core/components/page-list';
|
||||
import { Header } from '@affine/core/components/pure/header';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { assertExists } from '@blocksuite/affine/global/utils';
|
||||
import { DeleteIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
@@ -39,6 +40,9 @@ const TrashHeader = () => {
|
||||
export const TrashPage = () => {
|
||||
const globalContextService = useService(GlobalContextService);
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
const docCollection = currentWorkspace.docCollection;
|
||||
assertExists(docCollection);
|
||||
|
||||
@@ -71,7 +75,10 @@ export const TrashPage = () => {
|
||||
<ViewBody>
|
||||
<div className={styles.body}>
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<VirtualizedTrashList />
|
||||
<VirtualizedTrashList
|
||||
disableMultiDelete={!isAdmin && !isOwner}
|
||||
disableMultiRestore={!isAdmin && !isOwner}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList type="trash" />
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { LiveData, useLiveData, useServices } from '@toeverything/infra';
|
||||
@@ -30,12 +31,17 @@ export const DocInfoSheet = ({
|
||||
docId: string;
|
||||
defaultOpenProperty?: DefaultOpenProperty;
|
||||
}) => {
|
||||
const { docsSearchService, docsService } = useServices({
|
||||
const { docsSearchService, docsService, guardService } = useServices({
|
||||
DocsSearchService,
|
||||
DocsService,
|
||||
GuardService,
|
||||
});
|
||||
const t = useI18n();
|
||||
|
||||
const canEditPropertyInfo = useLiveData(
|
||||
guardService.can$('Workspace_Properties_Update')
|
||||
);
|
||||
const canEditProperty = useLiveData(guardService.can$('Doc_Update', docId));
|
||||
const links = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||
@@ -102,16 +108,11 @@ export const DocInfoSheet = ({
|
||||
key={property.id}
|
||||
propertyInfo={property}
|
||||
defaultOpenEditMenu={newPropertyId === property.id}
|
||||
propertyInfoReadonly={!canEditPropertyInfo}
|
||||
readonly={!canEditProperty}
|
||||
/>
|
||||
))}
|
||||
<Menu
|
||||
items={<CreatePropertyMenuItems onCreated={onPropertyAdded} />}
|
||||
contentOptions={{
|
||||
onClick(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!canEditPropertyInfo ? (
|
||||
<Button
|
||||
variant="plain"
|
||||
prefix={<PlusIcon />}
|
||||
@@ -119,7 +120,26 @@ export const DocInfoSheet = ({
|
||||
>
|
||||
{t['com.affine.page-properties.add-property']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
) : (
|
||||
<Menu
|
||||
items={
|
||||
<CreatePropertyMenuItems onCreated={onPropertyAdded} />
|
||||
}
|
||||
contentOptions={{
|
||||
onClick(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="plain"
|
||||
prefix={<PlusIcon />}
|
||||
className={styles.addPropertyButton}
|
||||
>
|
||||
{t['com.affine.page-properties.add-property']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
)}
|
||||
</PropertyCollapsibleContent>
|
||||
</PropertyCollapsibleSection>
|
||||
<Divider size="thinner" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Loading } from '@affine/component';
|
||||
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
@@ -136,10 +137,16 @@ export const ExplorerDocNode = ({
|
||||
<ExplorerDocNode key={child.docId} docId={child.docId} isLinked />
|
||||
))}
|
||||
|
||||
<AddItemPlaceholder
|
||||
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||
onClick={handleAddLinkedPage}
|
||||
/>
|
||||
<DocPermissionGuard docId={docId} permission="Doc_Update">
|
||||
{canEdit =>
|
||||
canEdit ? (
|
||||
<AddItemPlaceholder
|
||||
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||
onClick={handleAddLinkedPage}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</DocPermissionGuard>
|
||||
</ExplorerTreeNode>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuSub,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
InformationIcon,
|
||||
LinkedPageIcon,
|
||||
OpenInNewIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
@@ -121,7 +119,7 @@ export const useExplorerDocNodeOperations = (
|
||||
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
|
||||
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
|
||||
options.openNodeCollapsed();
|
||||
}, [createPage, docsService, docId, options]);
|
||||
}, [createPage, docId, docsService, options]);
|
||||
|
||||
const handleToggleFavoriteDoc = useCallback(() => {
|
||||
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
|
||||
@@ -175,7 +173,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
handleAddLinkedPage,
|
||||
handleDuplicate,
|
||||
handleToggleFavoriteDoc,
|
||||
handleOpenInSplitView,
|
||||
handleOpenInNewTab,
|
||||
handleMoveToTrash,
|
||||
handleRename,
|
||||
@@ -187,21 +184,19 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
index: 0,
|
||||
inline: true,
|
||||
view: (
|
||||
<IconButton
|
||||
size="16"
|
||||
icon={<PlusIcon />}
|
||||
tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||
onClick={handleAddLinkedPage}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 10,
|
||||
view: <DocRenameSubMenu onConfirm={handleRename} initialName={title} />,
|
||||
view: (
|
||||
<DocPermissionGuard docId={docId} permission="Doc_Update">
|
||||
{canEdit => (
|
||||
<DocRenameSubMenu
|
||||
onConfirm={handleRename}
|
||||
initialName={title}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 11,
|
||||
@@ -229,12 +224,17 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
{
|
||||
index: 97,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<LinkedPageIcon />}
|
||||
onClick={handleAddLinkedPage}
|
||||
>
|
||||
{t['com.affine.page-operation.add-linked-page']()}
|
||||
</MenuItem>
|
||||
<DocPermissionGuard docId={docId} permission="Doc_Update">
|
||||
{canEdit => (
|
||||
<MenuItem
|
||||
prefixIcon={<LinkedPageIcon />}
|
||||
onClick={handleAddLinkedPage}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t['com.affine.page-operation.add-linked-page']()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -253,21 +253,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 100,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<SplitViewIcon />}
|
||||
onClick={handleOpenInSplitView}
|
||||
>
|
||||
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
index: 199,
|
||||
view: (
|
||||
@@ -288,13 +273,18 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
{
|
||||
index: 10000,
|
||||
view: (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<DeleteIcon />}
|
||||
onClick={handleMoveToTrash}
|
||||
>
|
||||
{t['com.affine.moveToTrash.title']()}
|
||||
</MenuItem>
|
||||
<DocPermissionGuard docId={docId} permission="Doc_Trash">
|
||||
{canMoveToTrash => (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<DeleteIcon />}
|
||||
onClick={handleMoveToTrash}
|
||||
disabled={!canMoveToTrash}
|
||||
>
|
||||
{t['com.affine.moveToTrash.title']()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -305,7 +295,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
handleDuplicate,
|
||||
handleMoveToTrash,
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleRename,
|
||||
handleToggleFavoriteDoc,
|
||||
t,
|
||||
|
||||
@@ -14,6 +14,7 @@ export const RenameSubMenu = ({
|
||||
children,
|
||||
menuProps,
|
||||
onConfirm,
|
||||
disabled,
|
||||
...props
|
||||
}: RenameSubMenuProps) => {
|
||||
const t = useI18n();
|
||||
@@ -33,6 +34,7 @@ export const RenameSubMenu = ({
|
||||
triggerOptions={{
|
||||
prefixIcon: icon ?? <EditIcon />,
|
||||
suffixIcon: null,
|
||||
disabled,
|
||||
...triggerOptions,
|
||||
}}
|
||||
items={
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface RenameSubMenuProps
|
||||
/** MenuItem.text */
|
||||
text?: string;
|
||||
menuProps?: Partial<MenuSubProps>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RenameDialogProps
|
||||
|
||||
+23
-10
@@ -5,6 +5,7 @@ import {
|
||||
MobileMenuSub,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
|
||||
import { MoveToTrash } from '@affine/core/components/page-list';
|
||||
import {
|
||||
type DocRecord,
|
||||
@@ -58,16 +59,28 @@ export const ResolveConflictOperations = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileMenuItem
|
||||
prefixIcon={<CalendarXmarkIcon />}
|
||||
onClick={() => {
|
||||
handleRemoveJournalMark(docRecord.id);
|
||||
}}
|
||||
data-testid="journal-conflict-remove-mark"
|
||||
>
|
||||
{t['com.affine.page-properties.property.journal-remove']()}
|
||||
</MobileMenuItem>
|
||||
<MoveToTrash onSelect={() => handleOpenTrashModal(docRecord)} />
|
||||
<DocPermissionGuard docId={docRecord.id} permission="Doc_Update">
|
||||
{canEdit => (
|
||||
<MobileMenuItem
|
||||
prefixIcon={<CalendarXmarkIcon />}
|
||||
onClick={() => {
|
||||
handleRemoveJournalMark(docRecord.id);
|
||||
}}
|
||||
data-testid="journal-conflict-remove-mark"
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t['com.affine.page-properties.property.journal-remove']()}
|
||||
</MobileMenuItem>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
<DocPermissionGuard docId={docRecord.id} permission="Doc_Trash">
|
||||
{canTrash => (
|
||||
<MoveToTrash
|
||||
onSelect={() => handleOpenTrashModal(docRecord)}
|
||||
disabled={!canTrash}
|
||||
/>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useThemeColorV2 } from '@affine/component';
|
||||
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||
import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor';
|
||||
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
||||
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
|
||||
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
|
||||
@@ -17,6 +16,7 @@ import { EditorService } from '@affine/core/modules/editor';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { JournalService } from '@affine/core/modules/journal';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { ViewService } from '@affine/core/modules/workbench/services/view';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
@@ -56,6 +56,7 @@ const DetailPageImpl = () => {
|
||||
globalContextService,
|
||||
featureFlagService,
|
||||
aIButtonService,
|
||||
guardService,
|
||||
} = useServices({
|
||||
WorkbenchService,
|
||||
ViewService,
|
||||
@@ -65,6 +66,7 @@ const DetailPageImpl = () => {
|
||||
GlobalContextService,
|
||||
FeatureFlagService,
|
||||
AIButtonService,
|
||||
GuardService,
|
||||
});
|
||||
const editor = editorService.editor;
|
||||
const workspace = workspaceService.workspace;
|
||||
@@ -84,7 +86,6 @@ const DetailPageImpl = () => {
|
||||
featureFlagService.flags.enable_mobile_keyboard_toolbar.value;
|
||||
const enableEdgelessEditing =
|
||||
featureFlagService.flags.enable_mobile_edgeless_editing.value;
|
||||
const { setDocReadonly } = useDocMetaHelper();
|
||||
|
||||
// TODO(@eyhn): remove jotai here
|
||||
const [_, setActiveBlockSuiteEditor] = useActiveBlocksuiteEditor();
|
||||
@@ -111,19 +112,6 @@ const DetailPageImpl = () => {
|
||||
};
|
||||
}, [doc, globalContext, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setDocReadonly(
|
||||
doc.id,
|
||||
!enableKeyboardToolbar || (mode === 'edgeless' && !enableEdgelessEditing)
|
||||
);
|
||||
}, [
|
||||
enableKeyboardToolbar,
|
||||
doc.id,
|
||||
setDocReadonly,
|
||||
mode,
|
||||
enableEdgelessEditing,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
aIButtonService.presentAIButton(true);
|
||||
|
||||
@@ -202,6 +190,14 @@ const DetailPageImpl = () => {
|
||||
[docCollection.id, editor, jumpToPageBlock, openPage, server]
|
||||
);
|
||||
|
||||
const canEdit = useLiveData(guardService.can$('Doc_Update', doc.id));
|
||||
|
||||
const readonly =
|
||||
!canEdit ||
|
||||
isInTrash ||
|
||||
!enableKeyboardToolbar ||
|
||||
(mode === 'edgeless' && !enableEdgelessEditing);
|
||||
|
||||
return (
|
||||
<FrameworkScope scope={editor.scope}>
|
||||
<div className={styles.mainContainer}>
|
||||
@@ -216,7 +212,7 @@ const DetailPageImpl = () => {
|
||||
>
|
||||
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
|
||||
<AffineErrorBoundary key={doc.id} className={styles.errorBoundary}>
|
||||
<PageDetailEditor onLoad={onLoad} />
|
||||
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
|
||||
</AffineErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DocInfoSheet } from '@affine/core/mobile/components';
|
||||
import { MobileTocMenu } from '@affine/core/mobile/components/toc-menu';
|
||||
import { DocService } from '@affine/core/modules/doc';
|
||||
import { EditorService } from '@affine/core/modules/editor';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { ViewService } from '@affine/core/modules/workbench/services/view';
|
||||
import { preventDefault } from '@affine/core/utils';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
@@ -34,6 +35,8 @@ export const PageHeaderMenuButton = () => {
|
||||
const t = useI18n();
|
||||
|
||||
const docId = useService(DocService).doc.id;
|
||||
const guardService = useService(GuardService);
|
||||
const canEdit = useLiveData(guardService.can$('Doc_Update', docId));
|
||||
|
||||
const editorService = useService(EditorService);
|
||||
const editorContainer = useLiveData(editorService.editor.editorContainer$);
|
||||
@@ -94,6 +97,7 @@ export const PageHeaderMenuButton = () => {
|
||||
prefixIcon={primaryMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
|
||||
data-testid="editor-option-menu-mode-switch"
|
||||
onSelect={handleSwitchMode}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{primaryMode === 'page'
|
||||
? t['com.affine.editorDefaultMode.edgeless']()
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
@@ -50,12 +51,14 @@ export const ExplorerDocNode = ({
|
||||
globalContextService,
|
||||
docDisplayMetaService,
|
||||
featureFlagService,
|
||||
guardService,
|
||||
} = useServices({
|
||||
DocsSearchService,
|
||||
DocsService,
|
||||
GlobalContextService,
|
||||
DocDisplayMetaService,
|
||||
FeatureFlagService,
|
||||
GuardService,
|
||||
});
|
||||
|
||||
const active =
|
||||
@@ -134,6 +137,11 @@ export const ExplorerDocNode = ({
|
||||
async (data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (data.treeInstruction?.type === 'make-child') {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
const canEdit = await guardService.can('Doc_Update', docId);
|
||||
if (!canEdit) {
|
||||
toast(t['com.affine.no-permission']());
|
||||
return;
|
||||
}
|
||||
await docsService.addLinkedDoc(docId, data.source.data.entity.id);
|
||||
track.$.navigationPanel.docs.linkDoc({
|
||||
control: 'drag',
|
||||
@@ -148,7 +156,7 @@ export const ExplorerDocNode = ({
|
||||
onDrop?.(data);
|
||||
}
|
||||
},
|
||||
[docId, docsService, onDrop, t]
|
||||
[docId, docsService, guardService, onDrop, t]
|
||||
);
|
||||
|
||||
const handleDropEffectOnDoc = useCallback<ExplorerTreeNodeDropEffect>(
|
||||
@@ -168,6 +176,11 @@ export const ExplorerDocNode = ({
|
||||
const handleDropOnPlaceholder = useAsyncCallback(
|
||||
async (data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
const canEdit = await guardService.can('Doc_Update', docId);
|
||||
if (!canEdit) {
|
||||
toast(t['com.affine.no-permission']());
|
||||
return;
|
||||
}
|
||||
// TODO(eyhn): timeout&error handling
|
||||
await docsService.addLinkedDoc(docId, data.source.data.entity.id);
|
||||
track.$.navigationPanel.docs.linkDoc({
|
||||
@@ -180,7 +193,7 @@ export const ExplorerDocNode = ({
|
||||
toast(t['com.affine.rootAppSidebar.doc.link-doc-only']());
|
||||
}
|
||||
},
|
||||
[docId, docsService, t]
|
||||
[docId, docsService, guardService, t]
|
||||
);
|
||||
|
||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||
@@ -242,6 +255,10 @@ export const ExplorerDocNode = ({
|
||||
)
|
||||
}
|
||||
reorderable={reorderable}
|
||||
renameableGuard={{
|
||||
docId,
|
||||
action: 'Doc_Update',
|
||||
}}
|
||||
onRename={handleRename}
|
||||
childrenPlaceholder={
|
||||
searching ? null : <Empty onDrop={handleDropOnPlaceholder} />
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import {
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
toast,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { CompatibleFavoriteItemsAdapter } 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 { useI18n } from '@affine/i18n';
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
InformationIcon,
|
||||
LinkedPageIcon,
|
||||
OpenInNewIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
@@ -42,11 +42,13 @@ export const useExplorerDocNodeOperations = (
|
||||
workspaceService,
|
||||
docsService,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
guardService,
|
||||
} = useServices({
|
||||
DocsService,
|
||||
WorkbenchService,
|
||||
WorkspaceService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
GuardService,
|
||||
});
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
@@ -115,13 +117,18 @@ export const useExplorerDocNodeOperations = (
|
||||
}, [docId, workbenchService.workbench]);
|
||||
|
||||
const handleAddLinkedPage = useAsyncCallback(async () => {
|
||||
const canEdit = await guardService.can('Doc_Update', docId);
|
||||
if (!canEdit) {
|
||||
toast(t['com.affine.no-permission']());
|
||||
return;
|
||||
}
|
||||
const newDoc = createPage();
|
||||
// TODO: handle timeout & error
|
||||
await docsService.addLinkedDoc(docId, newDoc.id);
|
||||
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
|
||||
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
|
||||
options.openNodeCollapsed();
|
||||
}, [createPage, docsService, docId, options]);
|
||||
}, [createPage, guardService, docId, docsService, options, t]);
|
||||
|
||||
const handleToggleFavoriteDoc = useCallback(() => {
|
||||
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
|
||||
@@ -132,18 +139,6 @@ export const useExplorerDocNodeOperations = (
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
index: 0,
|
||||
inline: true,
|
||||
view: (
|
||||
<IconButton
|
||||
size="16"
|
||||
icon={<PlusIcon />}
|
||||
tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||
onClick={handleAddLinkedPage}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 50,
|
||||
view: (
|
||||
@@ -158,12 +153,17 @@ export const useExplorerDocNodeOperations = (
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<LinkedPageIcon />}
|
||||
onClick={handleAddLinkedPage}
|
||||
>
|
||||
{t['com.affine.page-operation.add-linked-page']()}
|
||||
</MenuItem>
|
||||
<DocPermissionGuard docId={docId} permission="Doc_Update">
|
||||
{canEdit => (
|
||||
<MenuItem
|
||||
prefixIcon={<LinkedPageIcon />}
|
||||
onClick={handleAddLinkedPage}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t['com.affine.page-operation.add-linked-page']()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -217,17 +217,23 @@ export const useExplorerDocNodeOperations = (
|
||||
{
|
||||
index: 10000,
|
||||
view: (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<DeleteIcon />}
|
||||
onClick={handleMoveToTrash}
|
||||
>
|
||||
{t['com.affine.moveToTrash.title']()}
|
||||
</MenuItem>
|
||||
<DocPermissionGuard docId={docId} permission="Doc_Trash">
|
||||
{canMoveToTrash => (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<DeleteIcon />}
|
||||
onClick={handleMoveToTrash}
|
||||
disabled={!canMoveToTrash}
|
||||
>
|
||||
{t['com.affine.moveToTrash.title']()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
docId,
|
||||
favorite,
|
||||
handleAddLinkedPage,
|
||||
handleDuplicate,
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import { RenameModal } from '@affine/component/rename-modal';
|
||||
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
|
||||
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
|
||||
import type { DocPermissionActions } from '@affine/core/modules/permissions';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { extractEmojiIcon } from '@affine/core/utils';
|
||||
@@ -81,6 +83,7 @@ export interface BaseExplorerTreeNodeProps {
|
||||
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
|
||||
renameable?: boolean;
|
||||
onRename?: (newName: string) => void;
|
||||
renameableGuard?: { docId: string; action: DocPermissionActions };
|
||||
defaultRenaming?: boolean;
|
||||
|
||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||
@@ -127,6 +130,7 @@ export const ExplorerTreeNode = ({
|
||||
active,
|
||||
defaultRenaming,
|
||||
renameable,
|
||||
renameableGuard,
|
||||
onRename,
|
||||
disabled,
|
||||
collapsed,
|
||||
@@ -279,7 +283,24 @@ export const ExplorerTreeNode = ({
|
||||
renameable
|
||||
? {
|
||||
index: 0,
|
||||
view: (
|
||||
view: renameableGuard ? (
|
||||
<DocPermissionGuard
|
||||
permission={renameableGuard.action}
|
||||
docId={renameableGuard.docId}
|
||||
>
|
||||
{can => (
|
||||
<MenuItem
|
||||
key={'explorer-tree-rename'}
|
||||
type={'default'}
|
||||
prefixIcon={<EditIcon />}
|
||||
onClick={() => setRenaming(true)}
|
||||
disabled={!can}
|
||||
>
|
||||
{t['com.affine.menu.rename']()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</DocPermissionGuard>
|
||||
) : (
|
||||
<MenuItem
|
||||
key={'explorer-tree-rename'}
|
||||
type={'default'}
|
||||
@@ -293,7 +314,7 @@ export const ExplorerTreeNode = ({
|
||||
: null,
|
||||
] as (NodeOperation | null)[]
|
||||
).filter((t): t is NodeOperation => t !== null),
|
||||
[renameable, t]
|
||||
[renameable, renameableGuard, t]
|
||||
);
|
||||
|
||||
const { menuOperations, inlineOperations } = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapWithTrailing,
|
||||
@@ -12,18 +10,18 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { WorkspacePermissionStore } from '../stores/permission';
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-permission');
|
||||
|
||||
export class WorkspacePermission extends Entity {
|
||||
isOwner$ = new LiveData<boolean | null>(null);
|
||||
isAdmin$ = new LiveData<boolean | null>(null);
|
||||
isTeam$ = new LiveData<boolean | null>(null);
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
private readonly cache$ = LiveData.from(
|
||||
this.store.watchWorkspacePermissionCache(),
|
||||
undefined
|
||||
);
|
||||
isOwner$ = this.cache$.map(cache => cache?.isOwner ?? null);
|
||||
isAdmin$ = this.cache$.map(cache => cache?.isAdmin ?? null);
|
||||
isTeam$ = this.cache$.map(cache => cache?.isTeam ?? null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
@@ -51,27 +49,30 @@ export class WorkspacePermission extends Entity {
|
||||
}
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(({ isOwner, isAdmin, isTeam }) => {
|
||||
this.isAdmin$.next(isAdmin);
|
||||
this.isOwner$.next(isOwner);
|
||||
this.isTeam$.next(isTeam);
|
||||
this.store.setWorkspacePermissionCache({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
isTeam,
|
||||
});
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to fetch isOwner', error);
|
||||
}),
|
||||
onStart(() => this.isLoading$.setValue(true)),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
onStart(() => this.isRevalidating$.setValue(true)),
|
||||
onComplete(() => this.isRevalidating$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
async waitForRevalidation(signal?: AbortSignal) {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(
|
||||
isRevalidating => !isRevalidating,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@ export {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
} from './services/doc-granted-users';
|
||||
export { GuardService } from './services/guard';
|
||||
export { MemberSearchService } from './services/member-search';
|
||||
export { WorkspaceMembersService } from './services/members';
|
||||
export { WorkspacePermissionService } from './services/permission';
|
||||
export {
|
||||
type DocPermissionActions,
|
||||
type WorkspacePermissionActions,
|
||||
} from './stores/guard';
|
||||
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceServerService } from '../cloud';
|
||||
import { DocScope, DocService } from '../doc';
|
||||
import {
|
||||
WorkspaceLocalState,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
WorkspacesService,
|
||||
@@ -19,10 +25,12 @@ import {
|
||||
import { WorkspaceMembers } from './entities/members';
|
||||
import { WorkspacePermission } from './entities/permission';
|
||||
import { DocGrantedUsersService } from './services/doc-granted-users';
|
||||
import { GuardService } from './services/guard';
|
||||
import { MemberSearchService } from './services/member-search';
|
||||
import { WorkspaceMembersService } from './services/members';
|
||||
import { WorkspacePermissionService } from './services/permission';
|
||||
import { DocGrantedUsersStore } from './stores/doc-granted-users';
|
||||
import { GuardStore } from './stores/guard';
|
||||
import { MemberSearchStore } from './stores/member-search';
|
||||
import { WorkspaceMembersStore } from './stores/members';
|
||||
import { WorkspacePermissionStore } from './stores/permission';
|
||||
@@ -35,13 +43,22 @@ export function configurePermissionsModule(framework: Framework) {
|
||||
WorkspacesService,
|
||||
WorkspacePermissionStore,
|
||||
])
|
||||
.store(WorkspacePermissionStore, [WorkspaceServerService])
|
||||
.store(WorkspacePermissionStore, [
|
||||
WorkspaceServerService,
|
||||
WorkspaceLocalState,
|
||||
])
|
||||
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
|
||||
.service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService])
|
||||
.store(WorkspaceMembersStore, [WorkspaceServerService])
|
||||
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService])
|
||||
.service(MemberSearchService, [MemberSearchStore, WorkspaceService])
|
||||
.store(MemberSearchStore, [WorkspaceServerService]);
|
||||
.store(MemberSearchStore, [WorkspaceServerService])
|
||||
.service(GuardService, [
|
||||
GuardStore,
|
||||
WorkspaceService,
|
||||
WorkspacePermissionService,
|
||||
])
|
||||
.store(GuardStore, [WorkspaceService, WorkspaceServerService]);
|
||||
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
exhaustMapWithTrailing,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import {
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
exhaustMap,
|
||||
groupBy,
|
||||
map,
|
||||
mergeMap,
|
||||
Observable,
|
||||
} from 'rxjs';
|
||||
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type {
|
||||
DocPermissionActions,
|
||||
GuardStore,
|
||||
WorkspacePermissionActions,
|
||||
} from '../stores/guard';
|
||||
import type { WorkspacePermissionService } from './permission';
|
||||
|
||||
export class GuardService extends Service {
|
||||
constructor(
|
||||
private readonly guardStore: GuardStore,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspacePermissionService: WorkspacePermissionService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly workspacePermissions$ = new LiveData<
|
||||
Partial<Record<WorkspacePermissionActions, boolean>>
|
||||
>({});
|
||||
|
||||
private readonly docPermissions$ = new LiveData<
|
||||
Record<string, Partial<Record<DocPermissionActions, boolean>>>
|
||||
>({});
|
||||
|
||||
private readonly isAdmin$ = LiveData.computed(get => {
|
||||
const isOwner = get(this.workspacePermissionService.permission.isOwner$);
|
||||
const isAdmin = get(this.workspacePermissionService.permission.isAdmin$);
|
||||
if (isOwner === null && isAdmin === null) {
|
||||
return null;
|
||||
}
|
||||
return isOwner || isAdmin;
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```ts
|
||||
* guardService.can$('Workspace_Properties_Update');
|
||||
* guardService.can$('Doc_Update', docId);
|
||||
* ```
|
||||
*/
|
||||
can$<T extends WorkspacePermissionActions | DocPermissionActions>(
|
||||
action: T,
|
||||
...args: T extends DocPermissionActions ? [string] : []
|
||||
): LiveData<boolean> {
|
||||
const docId = args[0];
|
||||
return LiveData.from(
|
||||
new Observable(subscriber => {
|
||||
// revalidate permission
|
||||
if (docId) {
|
||||
this.revalidateDocPermission(docId);
|
||||
} else {
|
||||
this.revalidateWorkspacePermission();
|
||||
}
|
||||
// revalidate workspace permission if it's not initialized
|
||||
if (this.isAdmin$.value === null) {
|
||||
this.workspacePermissionService.permission.revalidate();
|
||||
}
|
||||
|
||||
let prev = false;
|
||||
|
||||
const subscription = combineLatest([
|
||||
(docId
|
||||
? this.docPermissions$.pipe(
|
||||
map(permissions => permissions[docId] ?? false)
|
||||
)
|
||||
: this.workspacePermissions$) as Observable<
|
||||
Record<string, boolean>
|
||||
>,
|
||||
this.isAdmin$,
|
||||
]).subscribe(([permissions, isAdmin]) => {
|
||||
if (isAdmin) {
|
||||
return subscriber.next(true);
|
||||
}
|
||||
const current = permissions[action] ?? false;
|
||||
if (current !== prev) {
|
||||
prev = current;
|
||||
subscriber.next(current);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
async can<T extends WorkspacePermissionActions | DocPermissionActions>(
|
||||
action: T,
|
||||
...args: T extends DocPermissionActions ? [string] : []
|
||||
): Promise<boolean> {
|
||||
const docId = args[0];
|
||||
|
||||
if (this.isAdmin$.value === null) {
|
||||
await this.workspacePermissionService.permission.waitForRevalidation();
|
||||
}
|
||||
|
||||
if (this.isAdmin$.value === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const permissions = await (docId
|
||||
? this.loadDocPermission(docId)
|
||||
: this.loadWorkspacePermission());
|
||||
|
||||
return permissions[action as keyof typeof permissions] ?? false;
|
||||
}
|
||||
|
||||
private readonly revalidateWorkspacePermission = effect(
|
||||
exhaustMapWithTrailing(() =>
|
||||
fromPromise(() => this.guardStore.getWorkspacePermissions()).pipe(
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
private readonly revalidateDocPermission = effect(
|
||||
groupBy((docId: string) => docId),
|
||||
mergeMap(doc$ =>
|
||||
doc$.pipe(
|
||||
exhaustMap((docId: string) =>
|
||||
fromPromise(() => this.loadDocPermission(docId)).pipe(
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
private readonly loadWorkspacePermission = async () => {
|
||||
if (this.workspaceService.workspace.flavour === 'local') {
|
||||
return {} as Record<WorkspacePermissionActions, boolean>;
|
||||
}
|
||||
const permissions = await this.guardStore.getWorkspacePermissions();
|
||||
this.workspacePermissions$.next(permissions);
|
||||
return permissions;
|
||||
};
|
||||
|
||||
private readonly loadDocPermission = async (docId: string) => {
|
||||
if (this.workspaceService.workspace.flavour === 'local') {
|
||||
return {} as Record<DocPermissionActions, boolean>;
|
||||
}
|
||||
const permissions = await this.guardStore.getDocPermissions(docId);
|
||||
this.docPermissions$.next({
|
||||
...this.docPermissions$.value,
|
||||
[docId]: permissions,
|
||||
});
|
||||
return permissions;
|
||||
};
|
||||
|
||||
override dispose() {
|
||||
this.revalidateWorkspacePermission.unsubscribe();
|
||||
this.revalidateDocPermission.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
type GetDocRolePermissionsQuery,
|
||||
getDocRolePermissionsQuery,
|
||||
getWorkspaceRolePermissionsQuery,
|
||||
type WorkspacePermissions,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceServerService } from '../../cloud';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
export type WorkspacePermissionActions = keyof Omit<
|
||||
WorkspacePermissions,
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export type DocPermissionActions = keyof Omit<
|
||||
GetDocRolePermissionsQuery['workspace']['doc']['permissions'],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export class GuardStore extends Store {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceServerService: WorkspaceServerService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getWorkspacePermissions(): Promise<
|
||||
Record<WorkspacePermissionActions, boolean>
|
||||
> {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No server');
|
||||
}
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getWorkspaceRolePermissionsQuery,
|
||||
variables: {
|
||||
id: this.workspaceService.workspace.id,
|
||||
},
|
||||
});
|
||||
return data.workspaceRolePermissions.permissions;
|
||||
}
|
||||
|
||||
async getDocPermissions(
|
||||
docId: string
|
||||
): Promise<Record<DocPermissionActions, boolean>> {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No server');
|
||||
}
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getDocRolePermissionsQuery,
|
||||
variables: {
|
||||
workspaceId: this.workspaceService.workspace.id,
|
||||
docId,
|
||||
},
|
||||
});
|
||||
return data.workspace.doc.permissions;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,13 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import { getWorkspaceInfoQuery, leaveWorkspaceMutation } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceLocalState } from '../../workspace';
|
||||
|
||||
export class WorkspacePermissionStore extends Store {
|
||||
constructor(private readonly workspaceServerService: WorkspaceServerService) {
|
||||
constructor(
|
||||
private readonly workspaceServerService: WorkspaceServerService,
|
||||
private readonly workspaceLocalState: WorkspaceLocalState
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -36,4 +41,20 @@ export class WorkspacePermissionStore extends Store {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watchWorkspacePermissionCache() {
|
||||
return this.workspaceLocalState.watch<{
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
isTeam: boolean;
|
||||
}>('permission');
|
||||
}
|
||||
|
||||
setWorkspacePermissionCache(permission: {
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
isTeam: boolean;
|
||||
}) {
|
||||
this.workspaceLocalState.set('permission', permission);
|
||||
}
|
||||
}
|
||||
|
||||
+35
-30
@@ -22,9 +22,11 @@ const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
|
||||
export const MembersPermission = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
disabled,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [docRole, setDocRole] = useState<DocRole>(DocRole.Manager);
|
||||
@@ -63,38 +65,40 @@ export const MembersPermission = ({
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
onSelect={selectManage}
|
||||
selected={docRole === DocRole.Manager}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-manage']()}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={selectEdit}
|
||||
selected={docRole === DocRole.Editor}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div className={styles.tagContainerStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-edit']()}
|
||||
<PlanTag />
|
||||
disabled ? null : (
|
||||
<>
|
||||
<MenuItem
|
||||
onSelect={selectManage}
|
||||
selected={docRole === DocRole.Manager}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-manage']()}
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={selectRead}
|
||||
selected={docRole === DocRole.Reader}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div className={styles.tagContainerStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-read']()}
|
||||
<PlanTag />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={selectEdit}
|
||||
selected={docRole === DocRole.Editor}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div className={styles.tagContainerStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-edit']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={selectRead}
|
||||
selected={docRole === DocRole.Reader}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div className={styles.tagContainerStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-read']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
@@ -103,6 +107,7 @@ export const MembersPermission = ({
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{currentRoleName}
|
||||
</MenuTrigger>
|
||||
|
||||
+27
-22
@@ -15,7 +15,7 @@ import { useEffect } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const PublicDoc = () => {
|
||||
export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
|
||||
const t = useI18n();
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
@@ -98,27 +98,31 @@ export const PublicDoc = () => {
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<LockIcon />}
|
||||
onSelect={onDisablePublic}
|
||||
selected={!isSharedPage}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>{t['com.affine.share-menu.option.link.no-access']()}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<ViewIcon />}
|
||||
onSelect={onClickAnyoneReadOnlyShare}
|
||||
data-testid="share-link-menu-enable-share"
|
||||
selected={!!isSharedPage}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>{t['com.affine.share-menu.option.link.readonly']()}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
disabled ? null : (
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<LockIcon />}
|
||||
onSelect={onDisablePublic}
|
||||
selected={!isSharedPage}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>
|
||||
{t['com.affine.share-menu.option.link.no-access']()}
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<ViewIcon />}
|
||||
onSelect={onClickAnyoneReadOnlyShare}
|
||||
data-testid="share-link-menu-enable-share"
|
||||
selected={!!isSharedPage}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>{t['com.affine.share-menu.option.link.readonly']()}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
@@ -128,6 +132,7 @@ export const PublicDoc = () => {
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isSharedPage
|
||||
? t['com.affine.share-menu.option.link.readonly']()
|
||||
|
||||
+31
-22
@@ -8,13 +8,15 @@ import {
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { DocService } from '@affine/core/modules/doc';
|
||||
import {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
GuardService,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import { DocRole, UserFriendlyError } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PlanTag } from '../plan-tag';
|
||||
@@ -77,7 +79,6 @@ export const MemberItem = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<Menu
|
||||
items={
|
||||
<Options
|
||||
@@ -118,6 +119,15 @@ const Options = ({
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
const docService = useService(DocService);
|
||||
const guardService = useService(GuardService);
|
||||
|
||||
const canTransferOwner = useLiveData(
|
||||
guardService.can$('Doc_TransferOwner', docService.doc.id)
|
||||
);
|
||||
const canManageUsers = useLiveData(
|
||||
guardService.can$('Doc_Users_Manage', docService.doc.id)
|
||||
);
|
||||
|
||||
const changeToManager = useAsyncCallback(async () => {
|
||||
try {
|
||||
@@ -188,20 +198,17 @@ const Options = ({
|
||||
label: t['com.affine.share-menu.option.permission.can-manage'](),
|
||||
onClick: changeToManager,
|
||||
role: DocRole.Manager,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
},
|
||||
{
|
||||
label: t['com.affine.share-menu.option.permission.can-edit'](),
|
||||
onClick: changeToEditor,
|
||||
role: DocRole.Editor,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
showPlanTag: true,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.share-menu.option.permission.can-read'](),
|
||||
onClick: changeToReader,
|
||||
role: DocRole.Reader,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
showPlanTag: true,
|
||||
},
|
||||
];
|
||||
@@ -209,26 +216,28 @@ const Options = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{operationButtonInfo.map(item =>
|
||||
item.show ? (
|
||||
<MenuItem
|
||||
key={item.label}
|
||||
onSelect={item.onClick}
|
||||
selected={memberRole === item.role}
|
||||
>
|
||||
<div className={styles.planTagContainer}>
|
||||
{item.label} {item.showPlanTag ? <PlanTag /> : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
) : null
|
||||
)}
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<MenuItem onSelect={changeToOwner}>
|
||||
{operationButtonInfo.map(item => (
|
||||
<MenuItem
|
||||
key={item.label}
|
||||
onSelect={item.onClick}
|
||||
selected={memberRole === item.role}
|
||||
disabled={!canManageUsers}
|
||||
>
|
||||
<div className={styles.planTagContainer}>
|
||||
{item.label} {item.showPlanTag ? <PlanTag /> : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem onSelect={changeToOwner} disabled={!canTransferOwner}>
|
||||
{t['com.affine.share-menu.member-management.set-as-owner']()}
|
||||
</MenuItem>
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={removeMember} type="danger" className={styles.remove}>
|
||||
<MenuItem
|
||||
onSelect={removeMember}
|
||||
type="danger"
|
||||
className={styles.remove}
|
||||
disabled={!canManageUsers}
|
||||
>
|
||||
{t['com.affine.share-menu.member-management.remove']()}
|
||||
</MenuItem>
|
||||
</>
|
||||
|
||||
+16
-4
@@ -1,7 +1,9 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { DocService } from '@affine/core/modules/doc';
|
||||
import {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
GuardService,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
|
||||
@@ -30,6 +32,12 @@ export const MemberManagement = ({
|
||||
const grantedUserCount = useLiveData(
|
||||
docGrantedUsersService.grantedUserCount$
|
||||
);
|
||||
const docService = useService(DocService);
|
||||
const guardService = useService(GuardService);
|
||||
|
||||
const canManageUsers = useLiveData(
|
||||
guardService.can$('Doc_Users_Manage', docService.doc.id)
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
@@ -62,11 +70,15 @@ export const MemberManagement = ({
|
||||
) : (
|
||||
<Skeleton className={styles.scrollableRootStyle} />
|
||||
)}
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<div className={styles.footerStyle}>
|
||||
<span className={styles.addCollaboratorsStyle} onClick={onClickInvite}>
|
||||
{t['com.affine.share-menu.member-management.add-collaborators']()}
|
||||
</span>
|
||||
{canManageUsers ? (
|
||||
<span
|
||||
className={styles.addCollaboratorsStyle}
|
||||
onClick={onClickInvite}
|
||||
>
|
||||
{t['com.affine.share-menu.member-management.add-collaborators']()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { ServerService } from '@affine/core/modules/cloud';
|
||||
import { DocService } from '@affine/core/modules/doc';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
@@ -61,6 +63,16 @@ export const AFFiNESharePage = (
|
||||
} = props;
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const serverService = useService(ServerService);
|
||||
const docService = useService(DocService);
|
||||
const guardService = useService(GuardService);
|
||||
|
||||
const canManageUsers = useLiveData(
|
||||
guardService.can$('Doc_Users_Manage', docService.doc.id)
|
||||
);
|
||||
|
||||
const canPublish = useLiveData(
|
||||
guardService.can$('Doc_Publish', docService.doc.id)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
@@ -85,7 +97,7 @@ export const AFFiNESharePage = (
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.columnContainerStyle}>
|
||||
<InviteInput onFocus={props.onClickInvite} />
|
||||
{canManageUsers && <InviteInput onFocus={props.onClickInvite} />}
|
||||
<MembersRow onClick={props.onClickMembers} />
|
||||
<div className={styles.generalAccessStyle}>
|
||||
{t['com.affine.share-menu.generalAccess']()}
|
||||
@@ -93,8 +105,9 @@ export const AFFiNESharePage = (
|
||||
<MembersPermission
|
||||
openPaywallModal={props.openPaywallModal}
|
||||
hittingPaywall={!!props.hittingPaywall}
|
||||
disabled={!canManageUsers}
|
||||
/>
|
||||
<PublicDoc />
|
||||
<PublicDoc disabled={!canPublish} />
|
||||
</div>
|
||||
<CopyLinkButton workspaceId={workspaceId} />
|
||||
</div>
|
||||
|
||||
@@ -19,11 +19,18 @@ export class UserFriendlyError
|
||||
readonly status = this.response.status;
|
||||
readonly code = this.response.code;
|
||||
readonly type = this.response.type;
|
||||
override readonly name = this.response.name;
|
||||
readonly rawName = this.response.name;
|
||||
override readonly message = this.response.message;
|
||||
readonly data = this.response.data;
|
||||
readonly stacktrace = this.response.stacktrace;
|
||||
|
||||
override get name() {
|
||||
if (this.rawName in ErrorNames) {
|
||||
return this.rawName;
|
||||
}
|
||||
return ErrorNames.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
static fromAnyError(response: any) {
|
||||
if (response instanceof GraphQLError) {
|
||||
return new UserFriendlyError(response.extensions);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
query getDocRolePermissions($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
permissions {
|
||||
Doc_Copy
|
||||
Doc_Delete
|
||||
Doc_Duplicate
|
||||
Doc_Properties_Read
|
||||
Doc_Properties_Update
|
||||
Doc_Publish
|
||||
Doc_Read
|
||||
Doc_Restore
|
||||
Doc_TransferOwner
|
||||
Doc_Trash
|
||||
Doc_Update
|
||||
Doc_Users_Manage
|
||||
Doc_Users_Read
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,35 @@ mutation deleteWorkspace($id: String!) {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getDocRolePermissionsQuery = {
|
||||
id: 'getDocRolePermissionsQuery' as const,
|
||||
operationName: 'getDocRolePermissions',
|
||||
definitionName: 'workspace',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query getDocRolePermissions($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
permissions {
|
||||
Doc_Copy
|
||||
Doc_Delete
|
||||
Doc_Duplicate
|
||||
Doc_Properties_Read
|
||||
Doc_Properties_Update
|
||||
Doc_Publish
|
||||
Doc_Read
|
||||
Doc_Restore
|
||||
Doc_TransferOwner
|
||||
Doc_Trash
|
||||
Doc_Update
|
||||
Doc_Users_Manage
|
||||
Doc_Users_Read
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const forkCopilotSessionMutation = {
|
||||
id: 'forkCopilotSessionMutation' as const,
|
||||
operationName: 'forkCopilotSession',
|
||||
@@ -1551,6 +1580,33 @@ query workspaceQuota($id: String!) {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getWorkspaceRolePermissionsQuery = {
|
||||
id: 'getWorkspaceRolePermissionsQuery' as const,
|
||||
operationName: 'getWorkspaceRolePermissions',
|
||||
definitionName: 'workspaceRolePermissions',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query getWorkspaceRolePermissions($id: String!) {
|
||||
workspaceRolePermissions(id: $id) {
|
||||
permissions {
|
||||
Workspace_CreateDoc
|
||||
Workspace_Delete
|
||||
Workspace_Organize_Read
|
||||
Workspace_Properties_Create
|
||||
Workspace_Properties_Delete
|
||||
Workspace_Properties_Read
|
||||
Workspace_Properties_Update
|
||||
Workspace_Settings_Read
|
||||
Workspace_Settings_Update
|
||||
Workspace_Sync
|
||||
Workspace_TransferOwner
|
||||
Workspace_Users_Manage
|
||||
Workspace_Users_Read
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const approveWorkspaceTeamMemberMutation = {
|
||||
id: 'approveWorkspaceTeamMemberMutation' as const,
|
||||
operationName: 'approveWorkspaceTeamMember',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
query getWorkspaceRolePermissions($id: String!) {
|
||||
workspaceRolePermissions(id: $id) {
|
||||
permissions {
|
||||
Workspace_CreateDoc
|
||||
Workspace_Delete
|
||||
Workspace_Organize_Read
|
||||
Workspace_Properties_Create
|
||||
Workspace_Properties_Delete
|
||||
Workspace_Properties_Read
|
||||
Workspace_Properties_Update
|
||||
Workspace_Settings_Read
|
||||
Workspace_Settings_Update
|
||||
Workspace_Sync
|
||||
Workspace_TransferOwner
|
||||
Workspace_Users_Manage
|
||||
Workspace_Users_Read
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1958,6 +1958,37 @@ export type DeleteWorkspaceMutation = {
|
||||
deleteWorkspace: boolean;
|
||||
};
|
||||
|
||||
export type GetDocRolePermissionsQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetDocRolePermissionsQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
doc: {
|
||||
__typename?: 'DocType';
|
||||
permissions: {
|
||||
__typename?: 'DocPermissions';
|
||||
Doc_Copy: boolean;
|
||||
Doc_Delete: boolean;
|
||||
Doc_Duplicate: boolean;
|
||||
Doc_Properties_Read: boolean;
|
||||
Doc_Properties_Update: boolean;
|
||||
Doc_Publish: boolean;
|
||||
Doc_Read: boolean;
|
||||
Doc_Restore: boolean;
|
||||
Doc_TransferOwner: boolean;
|
||||
Doc_Trash: boolean;
|
||||
Doc_Update: boolean;
|
||||
Doc_Users_Manage: boolean;
|
||||
Doc_Users_Read: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ForkCopilotSessionMutationVariables = Exact<{
|
||||
options: ForkChatSessionInput;
|
||||
}>;
|
||||
@@ -3100,6 +3131,33 @@ export type WorkspaceQuotaQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GetWorkspaceRolePermissionsQueryVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetWorkspaceRolePermissionsQuery = {
|
||||
__typename?: 'Query';
|
||||
workspaceRolePermissions: {
|
||||
__typename?: 'WorkspaceRolePermissions';
|
||||
permissions: {
|
||||
__typename?: 'WorkspacePermissions';
|
||||
Workspace_CreateDoc: boolean;
|
||||
Workspace_Delete: boolean;
|
||||
Workspace_Organize_Read: boolean;
|
||||
Workspace_Properties_Create: boolean;
|
||||
Workspace_Properties_Delete: boolean;
|
||||
Workspace_Properties_Read: boolean;
|
||||
Workspace_Properties_Update: boolean;
|
||||
Workspace_Settings_Read: boolean;
|
||||
Workspace_Settings_Update: boolean;
|
||||
Workspace_Sync: boolean;
|
||||
Workspace_TransferOwner: boolean;
|
||||
Workspace_Users_Manage: boolean;
|
||||
Workspace_Users_Read: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ApproveWorkspaceTeamMemberMutationVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
userId: Scalars['String']['input'];
|
||||
@@ -3137,6 +3195,11 @@ export type Queries =
|
||||
variables: CopilotQuotaQueryVariables;
|
||||
response: CopilotQuotaQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getDocRolePermissionsQuery';
|
||||
variables: GetDocRolePermissionsQueryVariables;
|
||||
response: GetDocRolePermissionsQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getCopilotHistoriesQuery';
|
||||
variables: GetCopilotHistoriesQueryVariables;
|
||||
@@ -3331,6 +3394,11 @@ export type Queries =
|
||||
name: 'workspaceQuotaQuery';
|
||||
variables: WorkspaceQuotaQueryVariables;
|
||||
response: WorkspaceQuotaQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getWorkspaceRolePermissionsQuery';
|
||||
variables: GetWorkspaceRolePermissionsQueryVariables;
|
||||
response: GetWorkspaceRolePermissionsQuery;
|
||||
};
|
||||
|
||||
export type Mutations =
|
||||
|
||||
@@ -1730,6 +1730,7 @@
|
||||
"com.affine.settings.workspace.template.page-desc": "New docs will use the specified template, ignoring default settings.",
|
||||
"com.affine.settings.workspace.template.page-select": "Template for new doc",
|
||||
"com.affine.settings.workspace.template.remove": "Remove template",
|
||||
"com.affine.no-permission": "You don't have permission to do this",
|
||||
"com.affine.settings.workspace.storage.unused-blobs": "Unused blobs",
|
||||
"com.affine.settings.workspace.storage.unused-blobs.empty": "No unused blobs",
|
||||
"com.affine.settings.workspace.storage.unused-blobs.selected": "Selected",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||
import {
|
||||
clickNewPageButton,
|
||||
waitForEditorLoad,
|
||||
waitForEmptyEditor,
|
||||
} from '@affine-test/kit/utils/page-logic';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openHomePage(page);
|
||||
await waitForEditorLoad(page);
|
||||
await clickNewPageButton(page);
|
||||
await waitForEmptyEditor(page);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user