1 import type { KeyboardEvent, ReactNode } from 'react';
2 import { useRef, useState } from 'react';
4 import { c } from 'ttag';
6 import { Donut, Slider } from '@proton/atoms';
7 import { ThemeColor, getVariableFromThemeColor } from '@proton/colors';
8 import Tooltip from '@proton/components/components/tooltip/Tooltip';
9 import useElementRect from '@proton/components/hooks/useElementRect';
10 import { PLANS } from '@proton/payments';
11 import humanSize, { getLongSizeFormat, getSizeFormat, getUnit } from '@proton/shared/lib/helpers/humanSize';
12 import { sizeUnits } from '@proton/shared/lib/helpers/size';
13 import type { Organization } from '@proton/shared/lib/interfaces';
14 import { getOrganizationDenomination } from '@proton/shared/lib/organization/helper';
15 import clamp from '@proton/utils/clamp';
16 import generateUID from '@proton/utils/generateUID';
18 import InputField from '../../components/v2/field/InputField';
20 export const getTotalStorage = (
21 { UsedSpace: memberUsedSpace = 0, MaxSpace: memberMaxSpace = 0 } = {},
22 { MaxSpace: organizationMaxSpace = 0, AssignedSpace: organizationAssignedSpace = 0 } = {}
25 memberUsedSpace: memberUsedSpace,
26 organizationUsedSpace: organizationAssignedSpace - memberMaxSpace,
27 organizationMaxSpace: organizationMaxSpace,
31 const getDefaultInitialStorage = (organization: Organization | undefined) => {
32 const isFamilyOrg = getOrganizationDenomination(organization) === 'familyGroup';
33 if (organization?.PlanName === PLANS.PASS_FAMILY) {
34 return 2.5 * sizeUnits.GB;
36 if (isFamilyOrg || organization?.PlanName === PLANS.VISIONARY) {
37 return 500 * sizeUnits.GB;
39 if ([PLANS.DRIVE_PRO, PLANS.DRIVE_BUSINESS].includes(organization?.PlanName as any)) {
42 return 5 * sizeUnits.GB;
45 export const getInitialStorage = (
46 organization: Organization | undefined,
52 const result = getDefaultInitialStorage(organization);
53 if (result <= storageRange.max) {
56 return 5 * sizeUnits.GB;
59 export const getStorageRange = (
60 { UsedSpace: memberUsedSpace = 0, MaxSpace: memberMaxSpace = 0 } = {},
61 { MaxSpace: organizationMaxSpace = 0, AssignedSpace: organizationAssignedSpace = 0 } = {}
65 max: organizationMaxSpace - organizationAssignedSpace + memberMaxSpace,
70 range: ReturnType<typeof getStorageRange>;
71 totalStorage: ReturnType<typeof getTotalStorage>;
74 onChange: (value: number) => void;
77 orgInitialization?: boolean;
80 const getNumberWithPrecision = (value: number, precision: number) => {
81 const multiplier = Math.pow(10, precision || 0);
82 return Math.round(value * multiplier) / multiplier;
85 const getDisplayedValue = (value: number, precision: number) => {
86 return `${getNumberWithPrecision(value, precision).toFixed(precision)}`;
89 const getGraphValue = (value: number, total: number) => {
90 // Round to a nice number to avoid float issues
91 const percentage = Math.round((value / total) * 100);
92 if (percentage < 1 || Number.isNaN(percentage)) {
102 value: [number, ThemeColor];
105 const getSegments = (totalStorage: Props['totalStorage'], allocatedStorage: number): Segment[] => {
106 const alreadyUsedPercentage = getGraphValue(totalStorage.memberUsedSpace, totalStorage.organizationMaxSpace);
107 const alreadyAllocatedPercentage = getGraphValue(
108 totalStorage.organizationUsedSpace,
109 totalStorage.organizationMaxSpace
111 const allocatedPercentage = Math.min(
112 getGraphValue(allocatedStorage, totalStorage.organizationMaxSpace),
113 100 - (alreadyUsedPercentage + alreadyAllocatedPercentage)
118 label: c('Info').t`Already used`,
119 size: humanSize({ bytes: totalStorage.memberUsedSpace }),
120 tooltip: c('Info').t`Storage used by this user`,
121 value: [alreadyUsedPercentage, ThemeColor.Danger],
124 label: c('Info').t`Already allocated`,
125 size: humanSize({ bytes: totalStorage.organizationUsedSpace }),
126 tooltip: c('Info').t`Storage allocated to other users in this organisation`,
127 value: [alreadyAllocatedPercentage, ThemeColor.Warning],
130 label: c('Info').t`Allocated`,
131 size: humanSize({ bytes: allocatedStorage }),
132 tooltip: c('Info').t`Storage allocated to this user`,
133 value: [allocatedPercentage, ThemeColor.Success],
138 const getValueInUnit = (value: number, sizeInBytes: number) => {
139 return value / sizeInBytes;
142 const getValueInBytes = (value: number, sizeInUnits: number) => {
143 return value * sizeInUnits;
146 const MemberStorageSelector = ({
154 orgInitialization = false,
156 const actualValue = getValueInUnit(value, sizeUnit);
158 const [tmpValue, setTmpValue] = useState(getDisplayedValue(actualValue, precision));
160 // We change the step depending on the remaining space
161 const remainingSpace = totalStorage.organizationMaxSpace - totalStorage.organizationUsedSpace;
162 const stepInBytes = remainingSpace > sizeUnits.GB ? 0.5 * sizeUnits.GB : 0.1 * sizeUnits.GB;
164 const min = getNumberWithPrecision(getValueInUnit(range.min, sizeUnit), precision);
165 const max = getNumberWithPrecision(getValueInUnit(range.max, sizeUnit), precision);
166 const step = getNumberWithPrecision(getValueInUnit(stepInBytes, sizeUnit), precision);
168 const parsedValueInUnit = getNumberWithPrecision(Number.parseFloat(tmpValue), 1) || 0;
169 const parsedValueInBytes = Math.floor(getValueInBytes(parsedValueInUnit, sizeUnit));
171 const labelRef = useRef<HTMLDivElement>(null);
172 const rect = useElementRect(labelRef);
173 const sizeRef = useRef<HTMLDivElement>(null);
174 const sizeRect = useElementRect(sizeRef);
175 const [uid] = useState(generateUID('memberStorageSelector'));
177 const segments = getSegments(totalStorage, parsedValueInBytes);
178 const unit = getUnit(sizeUnit);
180 const handleSafeChange = (value: number) => {
181 if (Number.isNaN(value)) {
182 // Reset to old value if it's invalid
183 setTmpValue(getDisplayedValue(actualValue, precision));
186 const safeValue = clamp(value, min, max);
187 setTmpValue(getDisplayedValue(safeValue, precision));
188 onChange(clamp(Math.floor(getValueInBytes(safeValue, sizeUnit)), range.min, range.max));
191 const sizeElementWidth = 1;
192 // We calculate a ratio because the modal has a transform animation which the getBoundingClientRect doesn't take into account
193 const ratio = (sizeRect?.width || 0) / sizeElementWidth;
194 const sizeLabel = getSizeFormat(unit, parsedValueInUnit);
195 const sizeLabelSuffix = (
196 <span id={uid} aria-label={getLongSizeFormat(unit, parsedValueInUnit)}>
202 <div className={className}>
203 <div className="flex flex-column sm:flex-row">
204 <div className="w-3/10">
206 label={c('Label').t`Account storage`}
207 disableChange={disabled}
209 aria-label={c('Label').t`Account storage`}
210 data-testid="member-storage-selector"
211 aria-describedby={uid}
212 onValue={(value: string) => {
213 setTmpValue(value.replace(/[^\d.]/g, ''));
215 onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
216 if (event.key === 'Enter') {
217 handleSafeChange(parsedValueInUnit);
221 handleSafeChange(parsedValueInUnit);
223 suffix={sizeLabelSuffix}
226 <div className="flex sm:flex-1 justify-end self-start">
227 {orgInitialization ? (
230 <b>{c('Info').t`Admin account allocation`}</b>:{' '}
232 bytes: parsedValueInBytes,
237 <b>{c('Info').t`Storage for users`}</b>:{' '}
238 {humanSize({ bytes: range.max - parsedValueInBytes, unit })}
243 <div className="w-custom" style={{ '--w-custom': `${(rect?.height || 0) / ratio}px` }}>
244 <Donut segments={segments.map(({ value }) => value)} />
246 <div className="ml-4 text-sm">
248 <div ref={sizeRef} style={{ width: `${sizeElementWidth}px` }} />
249 {segments.map(({ label, size, tooltip, value: [share, color] }) => (
250 <div className="mb-4 flex items-center" key={tooltip}>
261 className="inline-block user-select-none mr-2 w-custom rounded"
263 background: `var(${getVariableFromThemeColor(color)})`,
270 <span className="sr-only">
273 <span className="text-semibold">{label}</span>
282 <div className="mt-2 pr-2 md:pr-0">
289 aria-label={c('Label').t`Account storage`}
290 aria-describedby={uid}
291 value={parsedValueInUnit}
292 getDisplayedValue={(value) => getDisplayedValue(value, precision)}
293 onChange={handleSafeChange}
300 export default MemberStorageSelector;