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
No known key found for this signature in database
GPG Key ID: 126E0320FEB0D05C
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',
});

View File

@ -5,20 +5,20 @@
"de": 26,
"el-GR": 0,
"en": 100,
"es-AR": 13,
"es-AR": 12,
"es-CL": 14,
"es": 12,
"fr": 61,
"fr": 60,
"hi": 2,
"it-IT": 1,
"it": 1,
"ja": 91,
"ko": 73,
"ja": 90,
"ko": 72,
"pl": 0,
"pt-BR": 78,
"ru": 67,
"ru": 66,
"sv-SE": 4,
"ur": 2,
"zh-Hans": 91,
"zh-Hant": 91
"zh-Hant": 90
}

View File

@ -1600,5 +1600,20 @@
"com.affine.upgrade-to-team-page.no-workspace-available": "No workspace available",
"com.affine.workspace.storage": "Workspace storage",
"com.affine.cmdk.affine.category.affine.journal": "Journal",
"com.affine.cmdk.affine.category.affine.date-picker": "Select a specific date"
"com.affine.cmdk.affine.category.affine.date-picker": "Select a specific date",
"com.affine.payment.sync-paused.owner.title": "[Action Required] Workspace sync paused",
"com.affine.payment.sync-paused.owner.both.description": "Your workspace has exceeded both storage and member limits, causing synchronization to pause. To resume syncing, please either:",
"com.affine.payment.sync-paused.owner.both.tips-1": "Reduce storage usage and remove some team members",
"com.affine.payment.sync-paused.owner.both.tips-2": "Upgrade your plan for increased capacity",
"com.affine.payment.sync-paused.owner.storage.description": "Your workspace has exceeded its storage limit and synchronization has been paused. To resume syncing, please either:",
"com.affine.payment.sync-paused.owner.storage.tips-1": "Remove unnecessary files or content to reduce storage usage",
"com.affine.payment.sync-paused.owner.storage.tips-2": "Upgrade your plan for increased storage capacity",
"com.affine.payment.sync-paused.owner.member.description": "Your workspace has reached its maximum member capacity and synchronization has been paused. To resume syncing, you can either",
"com.affine.payment.sync-paused.owner.member.tips-1": "Remove some team members from the workspace",
"com.affine.payment.sync-paused.owner.member.tips-2": "Upgrade your plan to accommodate more members",
"com.affine.payment.sync-paused.member.title": "Workspace sync paused",
"com.affine.payment.sync-paused.member.both.description": "This workspace has exceeded both storage and member limits, causing synchronization to pause. Please contact your workspace owner to address these limits and resume syncing.",
"com.affine.payment.sync-paused.member.storage.description": "This workspace has exceeded its storage limit and synchronization has been paused. Please contact your workspace owner to either reduce storage usage or upgrade the plan to resume syncing.",
"com.affine.payment.sync-paused.member.member.description": "This workspace has reached its maximum member capacity and synchronization has been paused. Please contact your workspace owner to either adjust team membership or upgrade the plan to resume syncing.",
"com.affine.payment.sync-paused.member.member.confirm": "Got It"
}

View File

@ -62,6 +62,8 @@ test('should have pagination in member list', async ({ page }) => {
await page.waitForTimeout(1000);
await page.getByTestId('confirm-modal-cancel').click();
const firstPageMemberItemCount = await page
.locator('[data-testid="member-item"]')
.count();