feat(core): add sync paused dialog (#9135)

close AF-1932 AF-1954 AF-1953 AF-1955

Add a pop-up reminder when the workspace capacity exceeds the limit or the number of members exceeds the limit.
This commit is contained in:
JimmFly
2024-12-17 05:55:41 +00:00
parent ffa0231cf5
commit 95d1a4a27d
15 changed files with 275 additions and 37 deletions

View File

@@ -109,6 +109,7 @@ export const WorkspaceSelector = ({
showArrowDownIcon={showArrowDownIcon}
disable={disable}
hideCollaborationIcon={true}
hideTeamWorkspaceIcon={true}
data-testid="current-workspace-card"
/>
) : (

View File

@@ -122,6 +122,7 @@ const TeamCard = () => {
const expiration = teamSubscription?.canceledAt;
const nextBillingDate = teamSubscription?.nextBillAt;
const recurring = teamSubscription?.recurring;
const endDate = teamSubscription?.end;
const description = useMemo(() => {
if (recurring === SubscriptionRecurring.Yearly) {
@@ -138,22 +139,22 @@ const TeamCard = () => {
}, [recurring, t]);
const expirationDate = useMemo(() => {
if (expiration) {
if (expiration && endDate) {
return t[
'com.affine.settings.workspace.billing.team-workspace.not-renewed'
]({
date: new Date(expiration).toLocaleDateString(),
date: new Date(endDate).toLocaleDateString(),
});
}
if (nextBillingDate) {
if (nextBillingDate && endDate) {
return t[
'com.affine.settings.workspace.billing.team-workspace.next-billing-date'
]({
date: new Date(nextBillingDate).toLocaleDateString(),
date: new Date(endDate).toLocaleDateString(),
});
}
return '';
}, [expiration, nextBillingDate, t]);
}, [endDate, expiration, nextBillingDate, t]);
const amount = teamSubscription
? teamPrices && workspaceMemberCount

View File

@@ -66,6 +66,10 @@ export const CloudWorkspaceMembersPanel = ({
permissionService.permission.revalidate();
}, [permissionService]);
useEffect(() => {
membersService.members.revalidate();
}, [membersService]);
const workspaceQuotaService = useService(WorkspaceQuotaService);
useEffect(() => {
workspaceQuotaService.quota.revalidate();

View File

@@ -125,6 +125,7 @@ const MemberItem = ({
const show = isOwner && currentAccount.id !== member.id;
const handleOpenAssignModal = useCallback(() => {
setInputValue('');
setOpen(true);
}, []);

View File

@@ -174,7 +174,8 @@ export const MemberOptions = ({
onClick: handleDecline,
show:
(isAdmin || isOwner) &&
member.status === WorkspaceMemberStatus.UnderReview,
(member.status === WorkspaceMemberStatus.UnderReview ||
member.status === WorkspaceMemberStatus.NeedMoreSeatAndReview),
},
{
label: t['com.affine.payment.member.team.revoke'](),
@@ -207,7 +208,8 @@ export const MemberOptions = ({
onClick: handleChangeToAdmin,
show:
isOwner &&
member.permission === Permission.Write &&
member.permission !== Permission.Owner &&
member.permission !== Permission.Admin &&
member.status === WorkspaceMemberStatus.Accepted,
},
{

View File

@@ -100,7 +100,11 @@ export const UpgradeToTeam = ({ recurring }: { recurring: string | null }) => {
},
}}
>
<MenuTrigger className={styles.menuTrigger} tooltip={menuTriggerText}>
<MenuTrigger
className={styles.menuTrigger}
tooltip={menuTriggerText}
data-selected={!!selectedWorkspace}
>
{menuTriggerText}
</MenuTrigger>
</Menu>

View File

@@ -18,6 +18,11 @@ export const menuTrigger = style({
fontSize: cssVar('fontBase'),
fontWeight: 500,
color: cssVarV2('text/placeholder'),
selectors: {
'&[data-selected="true"]': {
color: cssVarV2('text/primary'),
},
},
});
export const upgradeButton = style({

View File

@@ -10,6 +10,7 @@ import { AIIsland } from '@affine/core/desktop/components/ai-island';
import { AppContainer } from '@affine/core/desktop/components/app-container';
import { WorkspaceDialogs } from '@affine/core/desktop/dialogs';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import { QuotaCheck } from '@affine/core/modules/quota';
import { WorkbenchService } from '@affine/core/modules/workbench';
import {
LiveData,
@@ -31,7 +32,10 @@ export const WorkspaceLayout = function WorkspaceLayout({
{currentWorkspace?.flavour === 'local' ? (
<LocalQuotaModal />
) : (
<CloudQuotaModal />
<>
<CloudQuotaModal />
<QuotaCheck workspaceMeta={currentWorkspace.meta} />
</>
)}
<AiLoginRequiredModal />
<WorkspaceSideEffects />

View File

@@ -80,9 +80,6 @@ export class WorkspacePermission extends Entity {
permission: Permission,
sendInviteMail?: boolean
) {
if (!this.isAdmin$.value && !this.isOwner$.value) {
throw new Error('User has no permission to invite members');
}
return await this.store.inviteMember(
this.workspaceService.workspace.id,
email,
@@ -92,9 +89,6 @@ export class WorkspacePermission extends Entity {
}
async inviteMembers(emails: string[], sendInviteMail?: boolean) {
if (!this.isAdmin$.value && !this.isOwner$.value) {
throw new Error('User has no permission to invite members');
}
return await this.store.inviteBatch(
this.workspaceService.workspace.id,
emails,
@@ -103,9 +97,6 @@ export class WorkspacePermission extends Entity {
}
async generateInviteLink(expireTime: WorkspaceInviteLinkExpireTime) {
if (!this.isAdmin$.value && !this.isOwner$.value) {
throw new Error('User has no permission to generate invite link');
}
return await this.store.generateInviteLink(
this.workspaceService.workspace.id,
expireTime
@@ -113,18 +104,12 @@ export class WorkspacePermission extends Entity {
}
async revokeInviteLink() {
if (!this.isAdmin$.value && !this.isOwner$.value) {
throw new Error('User has no permission to revoke invite link');
}
return await this.store.revokeInviteLink(
this.workspaceService.workspace.id
);
}
async revokeMember(userId: string) {
if (!this.isAdmin$.value && !this.isOwner$.value) {
throw new Error('User has no permission to revoke members');
}
return await this.store.revokeMemberPermission(
this.workspaceService.workspace.id,
userId
@@ -140,9 +125,6 @@ export class WorkspacePermission extends Entity {
}
async approveMember(userId: string) {
if (!this.isAdmin$.value && !this.isOwner$.value) {
throw new Error('User has no permission to accept invite');
}
return await this.store.approveMember(
this.workspaceService.workspace.id,
userId
@@ -150,9 +132,6 @@ export class WorkspacePermission extends Entity {
}
async adjustMemberPermission(userId: string, permission: Permission) {
if (!this.isAdmin$.value) {
throw new Error('User has no permission to adjust member permissions');
}
return await this.store.adjustMemberPermission(
this.workspaceService.workspace.id,
userId,

View File

@@ -1,4 +1,5 @@
export { WorkspaceQuotaService } from './services/quota';
export { QuotaCheck } from './views/quota-check';
import {
type Framework,

View File

@@ -0,0 +1,192 @@
import { useConfirmModal } from '@affine/component';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { type I18nString, useI18n } from '@affine/i18n';
import {
useLiveData,
useService,
type WorkspaceMetadata,
WorkspacesService,
} from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import { WorkspaceQuotaService } from '../services/quota';
import * as styles from './styles.css';
interface Message {
title: I18nString;
description: I18nString;
confirmText: I18nString;
tips?: I18nString[];
cancelText?: I18nString;
}
/**
*
* Notification that the cloud workspace quota has exceeded the limit
*
*/
export const QuotaCheck = ({
workspaceMeta,
}: {
workspaceMeta: WorkspaceMetadata;
}) => {
const { openConfirmModal } = useConfirmModal();
const workspacesService = useService(WorkspacesService);
const workspaceQuota = useService(WorkspaceQuotaService).quota;
const workspaceProfile = workspacesService.getProfile(workspaceMeta);
const quota = useLiveData(workspaceQuota.quota$);
const usedPercent = useLiveData(workspaceQuota.percent$);
const isOwner = useLiveData(workspaceProfile.profile$)?.isOwner;
const globalDialogService = useService(GlobalDialogService);
const t = useI18n();
const onConfirm = useCallback(() => {
if (!isOwner) {
return;
}
globalDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan',
});
}, [globalDialogService, isOwner]);
useEffect(() => {
workspaceQuota?.revalidate();
}, [workspaceQuota]);
useEffect(() => {
if (workspaceMeta.flavour === 'local' || !quota) {
return;
}
const memberOverflow = quota.memberCount > quota.memberLimit;
// remember to use real percent
const storageOverflow = usedPercent && usedPercent >= 100;
const message = getSyncPausedMessage(
!!isOwner,
memberOverflow,
!!storageOverflow
);
if (memberOverflow || storageOverflow) {
openConfirmModal({
title: t.t(message.title),
description: t.t(message.description),
confirmText: t.t(message.confirmText),
cancelText: message.cancelText ? t.t(message.cancelText) : undefined,
children: <Tips tips={message.tips} />,
childrenContentClassName: styles.modalChildren,
onConfirm: onConfirm,
confirmButtonOptions: {
variant: 'primary',
},
cancelButtonOptions: {
style: {
visibility: message.cancelText ? 'visible' : 'hidden',
},
},
});
return;
} else {
return;
}
}, [
isOwner,
onConfirm,
openConfirmModal,
quota,
t,
usedPercent,
workspaceMeta.flavour,
]);
return null;
};
const messages: Record<
'owner' | 'member',
Record<'both' | 'storage' | 'member', Message>
> = {
owner: {
both: {
title: 'com.affine.payment.sync-paused.owner.title',
description: 'com.affine.payment.sync-paused.owner.both.description',
tips: [
'com.affine.payment.sync-paused.owner.both.tips-1',
'com.affine.payment.sync-paused.owner.both.tips-2',
],
cancelText: 'Cancel',
confirmText: 'com.affine.payment.upgrade',
},
storage: {
title: 'com.affine.payment.sync-paused.owner.title',
description: 'com.affine.payment.sync-paused.owner.storage.description',
tips: [
'com.affine.payment.sync-paused.owner.storage.tips-1',
'com.affine.payment.sync-paused.owner.storage.tips-2',
],
cancelText: 'Cancel',
confirmText: 'com.affine.payment.upgrade',
},
member: {
title: 'com.affine.payment.sync-paused.owner.title',
description: 'com.affine.payment.sync-paused.owner.member.description',
tips: [
'com.affine.payment.sync-paused.owner.member.tips-1',
'com.affine.payment.sync-paused.owner.member.tips-2',
],
cancelText: 'Cancel',
confirmText: 'com.affine.payment.upgrade',
},
},
member: {
both: {
title: 'com.affine.payment.sync-paused.member.title',
description: 'com.affine.payment.sync-paused.member.both.description',
confirmText: 'com.affine.payment.sync-paused.member.member.confirm',
},
storage: {
title: 'com.affine.payment.sync-paused.member.title',
description: 'com.affine.payment.sync-paused.member.storage.description',
confirmText: 'com.affine.payment.sync-paused.member.member.confirm',
},
member: {
title: 'com.affine.payment.sync-paused.member.title',
description: 'com.affine.payment.sync-paused.member.member.description',
confirmText: 'com.affine.payment.sync-paused.member.member.confirm',
},
},
};
function getSyncPausedMessage(
isOwner: boolean,
isMemberOverflow: boolean,
isStorageOverflow: boolean
): Message {
const userType = isOwner ? 'owner' : 'member';
const condition =
isStorageOverflow && isMemberOverflow
? 'both'
: isStorageOverflow
? 'storage'
: isMemberOverflow
? 'member'
: 'both';
return messages[userType][condition];
}
const Tips = ({ tips }: { tips?: I18nString[] }) => {
const t = useI18n();
if (!tips || tips.length < 1) {
return null;
}
return (
<div className={styles.tipsStyle}>
{tips.map(tip => (
<div key={tip.toString()} className={styles.tipStyle}>
<div className={styles.bullet} />
{t.t(tip)}
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const tipsStyle = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const tipStyle = style({
display: 'flex',
flexWrap: 'nowrap',
});
export const bullet = style({
backgroundColor: cssVarV2('icon/activated'),
width: '6px',
height: '6px',
borderRadius: '50%',
marginTop: '8px',
marginLeft: '4px',
marginRight: '12px',
});
export const modalChildren = style({
paddingLeft: '0',
});