Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / components / containers / members / MemberStorageSelector.tsx
blob61007f093f6405e7c3afc988a808314ffc6191b1
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 } = {}
23 ) => {
24     return {
25         memberUsedSpace: memberUsedSpace,
26         organizationUsedSpace: organizationAssignedSpace - memberMaxSpace,
27         organizationMaxSpace: organizationMaxSpace,
28     };
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;
35     }
36     if (isFamilyOrg || organization?.PlanName === PLANS.VISIONARY) {
37         return 500 * sizeUnits.GB;
38     }
39     if ([PLANS.DRIVE_PRO, PLANS.DRIVE_BUSINESS].includes(organization?.PlanName as any)) {
40         return sizeUnits.TB;
41     }
42     return 5 * sizeUnits.GB;
45 export const getInitialStorage = (
46     organization: Organization | undefined,
47     storageRange: {
48         min: number;
49         max: number;
50     }
51 ) => {
52     const result = getDefaultInitialStorage(organization);
53     if (result <= storageRange.max) {
54         return result;
55     }
56     return 5 * sizeUnits.GB;
59 export const getStorageRange = (
60     { UsedSpace: memberUsedSpace = 0, MaxSpace: memberMaxSpace = 0 } = {},
61     { MaxSpace: organizationMaxSpace = 0, AssignedSpace: organizationAssignedSpace = 0 } = {}
62 ) => {
63     return {
64         min: memberUsedSpace,
65         max: organizationMaxSpace - organizationAssignedSpace + memberMaxSpace,
66     };
69 interface Props {
70     range: ReturnType<typeof getStorageRange>;
71     totalStorage: ReturnType<typeof getTotalStorage>;
72     value: number;
73     sizeUnit: number;
74     onChange: (value: number) => void;
75     className?: string;
76     disabled?: boolean;
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)) {
93         return 0;
94     }
95     return percentage;
98 interface Segment {
99     label: string;
100     size: ReactNode;
101     tooltip: string;
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
110     );
111     const allocatedPercentage = Math.min(
112         getGraphValue(allocatedStorage, totalStorage.organizationMaxSpace),
113         100 - (alreadyUsedPercentage + alreadyAllocatedPercentage)
114     );
116     return [
117         {
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],
122         },
123         {
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],
128         },
129         {
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],
134         },
135     ];
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 = ({
147     range,
148     value,
149     onChange,
150     sizeUnit,
151     totalStorage,
152     className,
153     disabled,
154     orgInitialization = false,
155 }: Props) => {
156     const actualValue = getValueInUnit(value, sizeUnit);
157     const precision = 1;
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));
184             return;
185         }
186         const safeValue = clamp(value, min, max);
187         setTmpValue(getDisplayedValue(safeValue, precision));
188         onChange(clamp(Math.floor(getValueInBytes(safeValue, sizeUnit)), range.min, range.max));
189     };
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)}>
197             {sizeLabel}
198         </span>
199     );
201     return (
202         <div className={className}>
203             <div className="flex flex-column sm:flex-row">
204                 <div className="w-3/10">
205                     <InputField
206                         label={c('Label').t`Account storage`}
207                         disableChange={disabled}
208                         value={tmpValue}
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, ''));
214                         }}
215                         onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
216                             if (event.key === 'Enter') {
217                                 handleSafeChange(parsedValueInUnit);
218                             }
219                         }}
220                         onBlur={() => {
221                             handleSafeChange(parsedValueInUnit);
222                         }}
223                         suffix={sizeLabelSuffix}
224                     />
225                 </div>
226                 <div className="flex sm:flex-1 justify-end self-start">
227                     {orgInitialization ? (
228                         <>
229                             <div>
230                                 <b>{c('Info').t`Admin account allocation`}</b>:{' '}
231                                 {humanSize({
232                                     bytes: parsedValueInBytes,
233                                     unit,
234                                 })}
235                             </div>
236                             <div>
237                                 <b>{c('Info').t`Storage for users`}</b>:{' '}
238                                 {humanSize({ bytes: range.max - parsedValueInBytes, unit })}
239                             </div>
240                         </>
241                     ) : (
242                         <>
243                             <div className="w-custom" style={{ '--w-custom': `${(rect?.height || 0) / ratio}px` }}>
244                                 <Donut segments={segments.map(({ value }) => value)} />
245                             </div>
246                             <div className="ml-4 text-sm">
247                                 <div ref={labelRef}>
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}>
251                                             <Tooltip
252                                                 openDelay={0}
253                                                 title={
254                                                     <>
255                                                         {tooltip}
256                                                         <br />({size})
257                                                     </>
258                                                 }
259                                             >
260                                                 <span
261                                                     className="inline-block user-select-none mr-2 w-custom rounded"
262                                                     style={{
263                                                         background: `var(${getVariableFromThemeColor(color)})`,
264                                                         '--w-custom': '2em',
265                                                     }}
266                                                 >
267                                                     &nbsp;
268                                                 </span>
269                                             </Tooltip>
270                                             <span className="sr-only">
271                                                 {share} {sizeLabel}
272                                             </span>
273                                             <span className="text-semibold">{label}</span>
274                                         </div>
275                                     ))}
276                                 </div>
277                             </div>
278                         </>
279                     )}
280                 </div>
281             </div>
282             <div className="mt-2 pr-2 md:pr-0">
283                 <Slider
284                     marks
285                     disabled={disabled}
286                     min={min}
287                     max={max}
288                     step={step}
289                     aria-label={c('Label').t`Account storage`}
290                     aria-describedby={uid}
291                     value={parsedValueInUnit}
292                     getDisplayedValue={(value) => getDisplayedValue(value, precision)}
293                     onChange={handleSafeChange}
294                 />
295             </div>
296         </div>
297     );
300 export default MemberStorageSelector;