feat(core): add workspace quota panel for team workspace (#9085)

close AF-1917 AF-1685 AF-1730
This commit is contained in:
JimmFly
2024-12-10 12:32:01 +00:00
parent f63dacd553
commit 216e09e1af
8 changed files with 224 additions and 31 deletions
@@ -103,7 +103,7 @@ const getSignUpText = (
case SubscriptionPlan.Free: case SubscriptionPlan.Free:
return t['com.affine.payment.sign-up-free'](); return t['com.affine.payment.sign-up-free']();
case SubscriptionPlan.Team: case SubscriptionPlan.Team:
return t['com.affine.payment.start-free-trial'](); return t['com.affine.payment.upgrade']();
default: default:
return t['com.affine.payment.buy-pro'](); return t['com.affine.payment.buy-pro']();
} }
@@ -263,7 +263,7 @@ const UpgradeToTeam = () => {
variant="primary" variant="primary"
data-event-args-url={url} data-event-args-url={url}
> >
{t['com.affine.payment.start-free-trial']()} {t['com.affine.payment.upgrade']()}
</Button> </Button>
</a> </a>
); );
@@ -4,9 +4,11 @@ import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useSystemOnline } from '@affine/core/components/hooks/use-system-online'; import { useSystemOnline } from '@affine/core/components/hooks/use-system-online';
import { DesktopApiService } from '@affine/core/modules/desktop-api'; import { DesktopApiService } from '@affine/core/modules/desktop-api';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import { import {
useLiveData,
useService, useService,
type Workspace, type Workspace,
type WorkspaceMetadata, type WorkspaceMetadata,
@@ -23,6 +25,13 @@ export const DesktopExportPanel = ({
workspace, workspace,
}: ExportPanelProps) => { }: ExportPanelProps) => {
const workspaceId = workspaceMetadata.id; const workspaceId = workspaceMetadata.id;
const workspacePermissionService = useService(
WorkspacePermissionService
).permission;
const isTeam = useLiveData(workspacePermissionService.isTeam$);
const isOwner = useLiveData(workspacePermissionService.isOwner$);
const isAdmin = useLiveData(workspacePermissionService.isAdmin$);
const t = useI18n(); const t = useI18n();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const isOnline = useSystemOnline(); const isOnline = useSystemOnline();
@@ -55,6 +64,10 @@ export const DesktopExportPanel = ({
} }
}, [desktopApi, isOnline, saving, t, workspace, workspaceId]); }, [desktopApi, isOnline, saving, t, workspace, workspaceId]);
if (isTeam && !isOwner && !isAdmin) {
return null;
}
return ( return (
<SettingRow name={t['Export']()} desc={t['Export Description']()}> <SettingRow name={t['Export']()} desc={t['Export Description']()}>
<Button <Button
@@ -20,6 +20,7 @@ import { MembersPanel } from './members';
import { ProfilePanel } from './profile'; import { ProfilePanel } from './profile';
import { SharingPanel } from './sharing'; import { SharingPanel } from './sharing';
import type { WorkspaceSettingDetailProps } from './types'; import type { WorkspaceSettingDetailProps } from './types';
import { WorkspaceQuotaPanel } from './workspace-quota';
export const WorkspaceSettingDetail = ({ export const WorkspaceSettingDetail = ({
workspaceMetadata, workspaceMetadata,
@@ -69,6 +70,7 @@ export const WorkspaceSettingDetail = ({
</SettingWrapper> </SettingWrapper>
<SettingWrapper title={t['com.affine.brand.affineCloud']()}> <SettingWrapper title={t['com.affine.brand.affineCloud']()}>
<EnableCloudPanel onCloseSetting={onCloseSetting} /> <EnableCloudPanel onCloseSetting={onCloseSetting} />
<WorkspaceQuotaPanel />
<MembersPanel onChangeSettingState={onChangeSettingState} /> <MembersPanel onChangeSettingState={onChangeSettingState} />
</SettingWrapper> </SettingWrapper>
<AiSetting /> <AiSetting />
@@ -146,7 +146,7 @@ export const CloudWorkspaceMembersPanel = ({
return ( return (
<span> <span>
{t['com.affine.payment.member.description2']()} {t['com.affine.payment.member.description2']()}
{hasPaymentFeature ? ( {hasPaymentFeature && isOwner ? (
<div <div
className={styles.goUpgradeWrapper} className={styles.goUpgradeWrapper}
onClick={handleUpgradeConfirm} onClick={handleUpgradeConfirm}
@@ -158,7 +158,14 @@ export const CloudWorkspaceMembersPanel = ({
) : null} ) : null}
</span> </span>
); );
}, [handleUpgradeConfirm, hasPaymentFeature, isTeam, t, workspaceQuota]); }, [
handleUpgradeConfirm,
hasPaymentFeature,
isOwner,
isTeam,
t,
workspaceQuota,
]);
const title = useMemo(() => { const title = useMemo(() => {
if (isTeam) { if (isTeam) {
@@ -1,6 +1,6 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; import { globalStyle, style } from '@vanilla-extract/css';
export const profileWrapper = style({ export const profileWrapper = style({
display: 'flex', display: 'flex',
alignItems: 'flex-end', alignItems: 'flex-end',
@@ -35,3 +35,31 @@ export const workspaceLabel = style({
lineHeight: '20px', lineHeight: '20px',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}); });
export const storageProgressContainer = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const storageProgressWrapper = style({
flexGrow: 1,
marginRight: '20px',
});
globalStyle(`${storageProgressWrapper} .storage-progress-desc`, {
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
height: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 2,
});
globalStyle(`${storageProgressWrapper} .storage-progress-bar-wrapper`, {
height: '8px',
borderRadius: '4px',
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
overflow: 'hidden',
});
export const storageProgressBar = style({
height: '100%',
});
@@ -0,0 +1,77 @@
import { ErrorMessage, Skeleton } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useEffect } from 'react';
import * as styles from './style.css';
export const WorkspaceQuotaPanel = () => {
const t = useI18n();
return (
<SettingRow
name={t['com.affine.workspace.storage']()}
desc=""
spreadCol={false}
>
<StorageProgress />
</SettingRow>
);
};
export const StorageProgress = () => {
const t = useI18n();
const workspacePermissionService = useService(
WorkspacePermissionService
).permission;
const workspaceQuotaService = useService(WorkspaceQuotaService).quota;
const isTeam = useLiveData(workspacePermissionService.isTeam$);
const isLoading = useLiveData(workspaceQuotaService.isLoading$);
const usedFormatted = useLiveData(workspaceQuotaService.usedFormatted$);
const maxFormatted = useLiveData(workspaceQuotaService.maxFormatted$);
const percent = useLiveData(workspaceQuotaService.percent$);
const color = useLiveData(workspaceQuotaService.color$);
useEffect(() => {
// revalidate quota to get the latest status
workspaceQuotaService.revalidate();
}, [workspaceQuotaService]);
const loadError = useLiveData(workspaceQuotaService.error$);
if (isLoading) {
if (loadError) {
return <ErrorMessage>Load error</ErrorMessage>;
}
return <Skeleton height={42} />;
}
if (!isTeam) {
return null;
}
return (
<div className={styles.storageProgressContainer}>
<div className={styles.storageProgressWrapper}>
<div className="storage-progress-desc">
<span>{t['com.affine.storage.used.hint']()}</span>
<span>
{usedFormatted}/{maxFormatted}
</span>
</div>
<div className="storage-progress-bar-wrapper">
<div
className={styles.storageProgressBar}
style={{
width: `${percent}%`,
backgroundColor: color ?? cssVarV2('toast/iconState/regular'),
}}
></div>
</div>
</div>
</div>
);
};
@@ -6,13 +6,15 @@ import {
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
exhaustMapSwitchUntilChanged,
fromPromise, fromPromise,
LiveData, LiveData,
mapInto,
onComplete, onComplete,
onStart, onStart,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { exhaustMap } from 'rxjs'; import { cssVarV2 } from '@toeverything/theme/v2';
import bytes from 'bytes';
import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud'; import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceQuotaStore } from '../stores/quota'; import type { WorkspaceQuotaStore } from '../stores/quota';
@@ -26,6 +28,38 @@ export class WorkspaceQuota extends Entity {
isLoading$ = new LiveData(false); isLoading$ = new LiveData(false);
error$ = new LiveData<any>(null); error$ = new LiveData<any>(null);
/** Used storage in bytes */
used$ = new LiveData<number | null>(null);
/** Formatted used storage */
usedFormatted$ = this.used$.map(used =>
used !== null ? bytes.format(used) : null
);
/** Maximum storage limit in bytes */
max$ = this.quota$.map(quota => (quota ? quota.storageQuota : null));
/** Maximum storage limit formatted */
maxFormatted$ = this.max$.map(max => (max ? bytes.format(max) : null));
/** Percentage of storage used */
percent$ = LiveData.computed(get => {
const max = get(this.max$);
const used = get(this.used$);
if (max === null || used === null) {
return null;
}
return Math.min(
100,
Math.max(0.5, Number(((used / max) * 100).toFixed(4)))
);
});
color$ = this.percent$.map(percent =>
percent !== null
? percent > 80
? cssVarV2('status/error')
: cssVarV2('toast/iconState/regular')
: null
);
constructor( constructor(
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly store: WorkspaceQuotaStore private readonly store: WorkspaceQuotaStore
@@ -34,28 +68,59 @@ export class WorkspaceQuota extends Entity {
} }
revalidate = effect( revalidate = effect(
exhaustMap(() => { map(() => ({
return fromPromise(signal => workspaceId: this.workspaceService.workspace.id,
this.store.fetchWorkspaceQuota( })),
this.workspaceService.workspace.id, exhaustMapSwitchUntilChanged(
signal (a, b) => a.workspaceId === b.workspaceId,
) ({ workspaceId }) => {
).pipe( return fromPromise(async signal => {
backoffRetry({ if (!workspaceId) {
when: isNetworkError, return; // no quota if no workspace
count: Infinity, }
}), const data = await this.store.fetchWorkspaceQuota(
backoffRetry({ this.workspaceService.workspace.id,
when: isBackendError, signal
count: 3, );
}), return { quota: data, used: data.usedSize };
mapInto(this.quota$), }).pipe(
catchErrorInto(this.error$, error => { backoffRetry({
logger.error('Failed to fetch isOwner', error); when: isNetworkError,
}), count: Infinity,
onStart(() => this.isLoading$.setValue(true)), }),
onComplete(() => this.isLoading$.setValue(false)) backoffRetry({
); when: isBackendError,
}) count: 3,
}),
mergeMap(data => {
if (data) {
const { quota, used } = data;
this.quota$.next(quota);
this.used$.next(used);
} else {
this.quota$.next(null);
this.used$.next(null);
}
return EMPTY;
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch workspace quota', error);
}),
onStart(() => this.isLoading$.setValue(true)),
onComplete(() => this.isLoading$.setValue(false))
);
}
)
); );
reset() {
this.quota$.next(null);
this.used$.next(null);
this.error$.next(null);
this.isLoading$.next(false);
}
override dispose(): void {
this.revalidate.unsubscribe();
}
} }
+2 -1
View File
@@ -1590,5 +1590,6 @@
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title": "Name Your Workspace", "com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title": "Name Your Workspace",
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.", "com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.",
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder": "Set a workspace name", "com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder": "Set a workspace name",
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm": "Continue to Pricing" "com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm": "Continue to Pricing",
"com.affine.workspace.storage": "Workspace storage"
} }