refactor(server): workspace doc query (#10042)

This commit is contained in:
forehalo
2025-02-12 08:13:07 +00:00
parent 9dcce43360
commit 53fdb1e8a5
17 changed files with 262 additions and 152 deletions

View File

@@ -117,6 +117,9 @@ export class DocGrantedUsersService extends Service {
this.grantedUsers$.next(
this.grantedUsers$.value.filter(user => user.user.id !== userId)
);
if (this.grantedUserCount$.value > 0) {
this.grantedUserCount$.next(this.grantedUserCount$.value - 1);
}
}
async updateUserRole(userId: string, role: DocRole) {
@@ -136,6 +139,14 @@ export class DocGrantedUsersService extends Service {
);
}
async updateDocDefaultRole(role: DocRole) {
return await this.store.updateDocDefaultRole({
docId: this.docService.doc.id,
workspaceId: this.workspaceService.workspace.id,
role,
});
}
override dispose(): void {
this.loadMore.unsubscribe();
}

View File

@@ -6,6 +6,8 @@ import {
grantDocUserRolesMutation,
type PaginationInput,
revokeDocUserRolesMutation,
type UpdateDocDefaultRoleInput,
updateDocDefaultRoleMutation,
updateDocUserRoleMutation,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
@@ -92,4 +94,18 @@ export class DocGrantedUsersStore extends Store {
return res.updateDocUserRole;
}
async updateDocDefaultRole(input: UpdateDocDefaultRoleInput) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: updateDocDefaultRoleMutation,
variables: {
input,
},
});
return res.updateDocDefaultRole;
}
}

View File

@@ -1,7 +1,4 @@
import type {
GetWorkspacePublicPageByIdQuery,
PublicDocMode,
} from '@affine/graphql';
import type { GetWorkspacePageByIdQuery, PublicDocMode } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
@@ -20,14 +17,11 @@ import type { DocService } from '../../doc';
import type { WorkspaceService } from '../../workspace';
import type { ShareStore } from '../stores/share';
type ShareInfoType = GetWorkspacePublicPageByIdQuery['workspace']['publicDoc'];
type ShareInfoType = GetWorkspacePageByIdQuery['workspace']['doc'];
export class ShareInfo extends Entity {
info$ = new LiveData<ShareInfoType | undefined | null>(null);
isShared$ = this.info$.map(info =>
// null means not loaded yet, undefined means not shared
info !== null ? info !== undefined : null
);
isShared$ = this.info$.map(info => info?.public);
sharedMode$ = this.info$.map(info => (info !== null ? info?.mode : null));
error$ = new LiveData<any>(null);

View File

@@ -1,6 +1,6 @@
import type { PublicDocMode } from '@affine/graphql';
import {
getWorkspacePublicPageByIdQuery,
getWorkspacePageByIdQuery,
publishPageMutation,
revokePublicPageMutation,
} from '@affine/graphql';
@@ -22,7 +22,7 @@ export class ShareStore extends Store {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getWorkspacePublicPageByIdQuery,
query: getWorkspacePageByIdQuery,
variables: {
pageId: docId,
workspaceId,
@@ -31,7 +31,7 @@ export class ShareStore extends Store {
signal,
},
});
return data.workspace.publicDoc ?? undefined;
return data.workspace.doc ?? undefined;
}
async enableSharePage(

View File

@@ -1,12 +1,16 @@
import { Menu, MenuItem, MenuTrigger } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocGrantedUsersService } from '@affine/core/modules/permissions';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { DocRole } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useCallback, useMemo, useState } from 'react';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { PlanTag } from '../plan-tag';
import * as styles from './styles.css';
const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
const getRoleName = (t: ReturnType<typeof useI18n>, role?: DocRole) => {
switch (role) {
case DocRole.Manager:
return t['com.affine.share-menu.option.permission.can-manage']();
@@ -18,7 +22,7 @@ const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
return '';
}
};
// TODO(@JimmFly): impl the real permission
export const MembersPermission = ({
openPaywallModal,
hittingPaywall,
@@ -29,32 +33,44 @@ export const MembersPermission = ({
disabled?: boolean;
}) => {
const t = useI18n();
const [docRole, setDocRole] = useState<DocRole>(DocRole.Manager);
const currentRoleName = useMemo(() => getRoleName(docRole, t), [docRole, t]);
const shareInfoService = useService(ShareInfoService);
const docGrantedUsersService = useService(DocGrantedUsersService);
const docDefaultRole = useLiveData(
shareInfoService.shareInfo.info$
)?.defaultRole;
const currentRoleName = useMemo(
() => getRoleName(t, docDefaultRole),
[docDefaultRole, t]
);
const changePermission = useCallback((newPermission: DocRole) => {
setDocRole(newPermission);
}, []);
const changePermission = useCallback(
async (docRole: DocRole) => {
await docGrantedUsersService.updateDocDefaultRole(docRole);
shareInfoService.shareInfo.revalidate();
},
[docGrantedUsersService, shareInfoService.shareInfo]
);
const selectManage = useCallback(() => {
changePermission(DocRole.Manager);
const selectManage = useAsyncCallback(async () => {
await changePermission(DocRole.Manager);
}, [changePermission]);
const selectEdit = useCallback(() => {
const selectEdit = useAsyncCallback(async () => {
if (hittingPaywall) {
openPaywallModal?.();
return;
}
changePermission(DocRole.Editor);
await changePermission(DocRole.Editor);
}, [changePermission, hittingPaywall, openPaywallModal]);
const selectRead = useCallback(() => {
const selectRead = useAsyncCallback(async () => {
if (hittingPaywall) {
openPaywallModal?.();
return;
}
changePermission(DocRole.Reader);
await changePermission(DocRole.Reader);
}, [changePermission, hittingPaywall, openPaywallModal]);
return (
<div className={styles.rowContainerStyle}>
<div className={styles.labelStyle}>
@@ -69,7 +85,7 @@ export const MembersPermission = ({
<>
<MenuItem
onSelect={selectManage}
selected={docRole === DocRole.Manager}
selected={docDefaultRole === DocRole.Manager}
>
<div className={styles.publicItemRowStyle}>
{t['com.affine.share-menu.option.permission.can-manage']()}
@@ -77,7 +93,7 @@ export const MembersPermission = ({
</MenuItem>
<MenuItem
onSelect={selectEdit}
selected={docRole === DocRole.Editor}
selected={docDefaultRole === DocRole.Editor}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
@@ -88,7 +104,7 @@ export const MembersPermission = ({
</MenuItem>
<MenuItem
onSelect={selectRead}
selected={docRole === DocRole.Reader}
selected={docDefaultRole === DocRole.Reader}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>

View File

@@ -124,7 +124,10 @@ export const sentEmail = style({
gap: '8px',
alignItems: 'center',
fontSize: cssVar('fontSm'),
cursor: 'pointer',
// TODO(@JimmFly): remove this when we have a sent email feature
cursor: 'not-allowed',
color: cssVarV2('text/disable'),
});
export const checkbox = style({

View File

@@ -24,6 +24,7 @@ import { useI18n } from '@affine/i18n';
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { debounce } from 'lodash-es';
import {
type CompositionEventHandler,
useCallback,
@@ -70,7 +71,6 @@ export const InviteMemberEditor = ({
);
const memberSearchService = useService(MemberSearchService);
const searchText = useLiveData(memberSearchService.searchText$);
useEffect(() => {
// reset the search text when the component is mounted
@@ -78,20 +78,25 @@ export const InviteMemberEditor = ({
memberSearchService.loadMore();
}, [memberSearchService]);
const debouncedSearch = useMemo(
() => debounce((value: string) => memberSearchService.search(value), 300),
[memberSearchService]
);
const inputRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useState(false);
const [composing, setComposing] = useState(false);
const [searchText, setSearchText] = useState('');
const handleValueChange = useCallback(
(value: string) => {
setSearchText(value);
if (!composing) {
memberSearchService.search(value);
debouncedSearch(value);
}
},
[composing, memberSearchService]
[composing, debouncedSearch]
);
const [shouldSendEmail, setShouldSendEmail] = useState(false);
const workspaceDialogService = useService(WorkspaceDialogService);
const onInvite = useAsyncCallback(async () => {
@@ -122,15 +127,11 @@ export const InviteMemberEditor = ({
useCallback(
e => {
setComposing(false);
memberSearchService.search(e.currentTarget.value);
debouncedSearch(e.currentTarget.value);
},
[memberSearchService]
[debouncedSearch]
);
const onCheckboxChange = useCallback(() => {
setShouldSendEmail(prev => !prev);
}, []);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
@@ -220,13 +221,14 @@ export const InviteMemberEditor = ({
/>
)}
</div>
<div className={styles.sentEmail} onClick={onCheckboxChange}>
<div className={styles.sentEmail}>
<Checkbox
className={styles.checkbox}
checked={shouldSendEmail}
disabled // not supported yet
checked={false}
disabled // TODO(@JimmFly): implement this
/>
{t['com.affine.share-menu.invite-editor.sent-email']()}
{` (coming soon)`}
</div>
<Result onClickMember={handleClickMember} />
</div>

View File

@@ -8,6 +8,7 @@ import {
Tooltip,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud';
import { DocService } from '@affine/core/modules/doc';
import {
DocGrantedUsersService,
@@ -17,6 +18,7 @@ import {
import { DocRole, UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useMemo } from 'react';
import { PlanTag } from '../plan-tag';
@@ -32,6 +34,10 @@ export const MemberItem = ({
openPaywallModal: () => void;
}) => {
const user = grantedUser.user;
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
const disableManage =
account?.id === user.id || grantedUser.role === DocRole.Owner;
const role = useMemo(() => {
switch (grantedUser.role) {
@@ -78,30 +84,33 @@ export const MemberItem = ({
</Tooltip>
</div>
</div>
<Menu
items={
<Options
userId={user.id}
memberRole={grantedUser.role}
hittingPaywall={hittingPaywall}
openPaywallModal={openPaywallModal}
/>
}
contentOptions={{
align: 'start',
}}
>
<MenuTrigger
variant="plain"
className={styles.menuTriggerStyle}
contentStyle={{
width: '100%',
{disableManage ? (
<div className={clsx(styles.memberRoleStyle, 'disable')}>{role}</div>
) : (
<Menu
items={
<Options
userId={user.id}
memberRole={grantedUser.role}
hittingPaywall={hittingPaywall}
openPaywallModal={openPaywallModal}
/>
}
contentOptions={{
align: 'start',
}}
>
{role}
</MenuTrigger>
</Menu>
<MenuTrigger
variant="plain"
className={styles.menuTriggerStyle}
contentStyle={{
width: '100%',
}}
>
{role}
</MenuTrigger>
</Menu>
)}
</div>
);
};
@@ -184,6 +193,7 @@ const Options = ({
const removeMember = useAsyncCallback(async () => {
try {
await docGrantedUsersService.revokeUsersRole(userId);
docGrantedUsersService.loadMore();
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({

View File

@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
export const result = style({
minHeight: '164px',
minHeight: '200px',
maxHeight: '342px',
});