web/satellite: added modal to update single project limit

Added new modal where user can update storage or bandwidth limit (if Pro tier).

Issue:
https://github.com/storj/storj/issues/5827

Change-Id: Ic3ae6b7a73d055b4eac93d4e2faad7bf26cb9d73
This commit is contained in:
Vitalii 2023-05-10 15:00:32 +03:00 committed by Vitalii Shpital
parent b91f72f08a
commit 083b3d6fc1
10 changed files with 584 additions and 19 deletions

View File

@ -0,0 +1,530 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<VModal :on-close="closeModal">
<template #content>
<div class="modal">
<div class="modal__header">
<LimitIcon />
<h1 class="modal__header__title">{{ activeLimit }} Limit</h1>
</div>
<div class="modal__functional">
<div class="modal__functional__limits">
<div class="modal__functional__limits__wrap">
<p class="modal__functional__limits__wrap__label">Set {{ activeLimit }} Limit</p>
<div class="modal__functional__limits__wrap__inputs">
<input
v-model="limitValue"
type="number"
:min="0"
:max="isBandwidthUpdating ? paidBandwidthLimit : paidStorageLimit"
@input="setLimitValue"
>
<select
:value="activeMeasurement"
@change="setActiveMeasurement"
>
<option
v-for="option in measurementOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
</div>
<div class="modal__functional__limits__wrap">
<p class="modal__functional__limits__wrap__label">Available {{ activeLimit }}</p>
<div class="modal__functional__limits__wrap__inputs">
<input
:value="isBandwidthUpdating ? paidBandwidthLimit : paidStorageLimit"
disabled
>
<select
:value="activeMeasurement"
@change="setActiveMeasurement"
>
<option
v-for="option in measurementOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
</div>
</div>
<div class="modal__functional__range">
<div class="modal__functional__range__labels">
<p>0 {{ activeMeasurement }}</p>
<p>{{ isBandwidthUpdating ? paidBandwidthLimit : paidStorageLimit }} {{ activeMeasurement }}</p>
</div>
<input
ref="rangeInput"
v-model="limitValue"
min="0"
:max="isBandwidthUpdating ? paidBandwidthLimit : paidStorageLimit"
type="range"
@input="setLimitValue"
>
</div>
</div>
<p class="modal__info">
If you need more storage,
<a
class="modal__info__link"
href="https://supportdcs.storj.io/hc/en-us/requests/new?ticket_form_id=360000683212"
target="_blank"
rel="noopener noreferrer"
>
request limit increase.
</a>
</p>
<div class="modal__buttons">
<VButton
label="Cancel"
:on-press="closeModal"
width="100%"
height="48px"
font-size="14px"
border-radius="10px"
:is-disabled="isLoading"
is-white
/>
<VButton
label="Save"
:on-press="onSave"
width="100%"
height="48px"
font-size="14px"
border-radius="10px"
:is-disabled="isLoading"
/>
</div>
</div>
</template>
</VModal>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, onMounted, ref } from 'vue';
import { useAppStore } from '@/store/modules/appStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useConfigStore } from '@/store/modules/configStore';
import { useNotify } from '@/utils/hooks';
import { LimitToChange, ProjectLimits } from '@/types/projects';
import { Dimensions, Memory } from '@/utils/bytesSize';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { AnalyticsHttpApi } from '@/api/analytics';
import { useLoading } from '@/composables/useLoading';
import VModal from '@/components/common/VModal.vue';
import VButton from '@/components/common/VButton.vue';
import LimitIcon from '@/../static/images/modals/limit.svg';
const appStore = useAppStore();
const projectsStore = useProjectsStore();
const configStore = useConfigStore();
const notify = useNotify();
const { isLoading, withLoading } = useLoading();
const analytics: AnalyticsHttpApi = new AnalyticsHttpApi();
const activeLimit = ref<LimitToChange>(LimitToChange.Storage);
const activeMeasurement = ref<string>(Dimensions.TB);
const limitValue = ref<number>(0);
const rangeInput = ref<HTMLInputElement>();
/**
* Returns current limits from store.
*/
const currentLimits = computed((): ProjectLimits => {
return projectsStore.state.currentLimits;
});
/**
* Returns current default bandwidth limit for paid accounts.
*/
const paidBandwidthLimit = computed((): number => {
const limitVal = getLimitValue(configStore.state.config.defaultPaidBandwidthLimit);
const maxLimit = Math.max(currentLimits.value.bandwidthLimit / Memory.TB, limitVal);
if (activeMeasurement.value === Dimensions.GB) {
return toGB(maxLimit);
}
return maxLimit;
});
/**
* Returns current default storage limit for paid accounts.
*/
const paidStorageLimit = computed((): number => {
const limitVal = getLimitValue(configStore.state.config.defaultPaidStorageLimit);
const maxLimit = Math.max(currentLimits.value.storageLimit / Memory.TB, limitVal);
if (activeMeasurement.value === Dimensions.GB) {
return toGB(maxLimit);
}
return maxLimit;
});
/**
* Returns dimensions dropdown options.
*/
const measurementOptions = computed((): string[] => {
return [Dimensions.GB, Dimensions.TB];
});
/**
* Indicates if bandwidth limit is updating.
*/
const isBandwidthUpdating = computed((): boolean => {
return activeLimit.value === LimitToChange.Bandwidth;
});
/**
* Sets active dimension and recalculates limit values.
*/
function setActiveMeasurement(event: Event): void {
const target = event.target as HTMLSelectElement;
if (target.value === Dimensions.TB) {
activeMeasurement.value = Dimensions.TB;
limitValue.value = toTB(limitValue.value);
updateTrackColor();
return;
}
activeMeasurement.value = Dimensions.GB;
limitValue.value = toGB(limitValue.value);
updateTrackColor();
}
/**
* Sets limit value from inputs.
*/
function setLimitValue(event: Event): void {
const target = event.target as HTMLInputElement;
const paidCharLimit = isBandwidthUpdating.value ?
paidBandwidthLimit.value.toString().length :
paidStorageLimit.value.toString().length;
if (target.value.length > paidCharLimit) {
const formattedLimit = target.value.slice(0, paidCharLimit);
limitValue.value = parseFloat(formattedLimit);
return;
}
if (activeLimit.value === LimitToChange.Bandwidth && parseFloat(target.value) > paidBandwidthLimit.value) {
limitValue.value = paidBandwidthLimit.value;
return;
}
if (activeLimit.value === LimitToChange.Storage && parseFloat(target.value) > paidStorageLimit.value) {
limitValue.value = paidStorageLimit.value;
return;
}
limitValue.value = parseFloat(target.value);
updateTrackColor();
}
/**
* Get limit numeric value separated from included measurement
*/
function getLimitValue(limit: string): number {
return parseInt(limit.split(' ')[0]);
}
/**
* Convert value from GB to TB
*/
function toTB(limitValue: number): number {
return limitValue / 1000;
}
/**
* Convert value from TB to GB
*/
function toGB(limitValue: number): number {
return limitValue * 1000;
}
/**
* Closes modal.
*/
function closeModal(): void {
appStore.removeActiveModal();
}
/**
* Updates range input's track color depending on current active value.
*/
function updateTrackColor(): void {
if (!rangeInput.value) {
return;
}
const min = parseFloat(rangeInput.value.min);
const max = parseFloat(rangeInput.value.max);
const value = parseFloat(rangeInput.value.value);
const thumbPosition = (value - min) / (max - min);
const greenWidth = thumbPosition * 100;
rangeInput.value.style.background = `linear-gradient(to right, #00ac26 ${greenWidth}%, #d8dee3 ${greenWidth}%)`;
}
/**
* Updates desired limit.
*/
async function onSave(): Promise<void> {
await withLoading(async () => {
try {
let limit = limitValue.value;
if (activeMeasurement.value === Dimensions.GB) {
limit = limit * Number(Memory.GB);
} else if (activeMeasurement.value === Dimensions.TB) {
limit = limit * Number(Memory.TB);
}
if (isBandwidthUpdating.value) {
const updatedProject = new ProjectLimits(limit);
await projectsStore.updateProjectBandwidthLimit(updatedProject);
analytics.eventTriggered(AnalyticsEvent.PROJECT_BANDWIDTH_LIMIT_UPDATED);
notify.success('Project bandwidth limit updated successfully!');
} else {
const updatedProject = new ProjectLimits(0, 0, limit);
await projectsStore.updateProjectStorageLimit(updatedProject);
analytics.eventTriggered(AnalyticsEvent.PROJECT_STORAGE_LIMIT_UPDATED);
notify.success('Project storage limit updated successfully!');
}
closeModal();
} catch (error) {
notify.error(error.message, AnalyticsErrorEventSource.CHANGE_PROJECT_LIMIT_MODAL);
}
});
}
onBeforeMount(() => {
activeLimit.value = appStore.state.activeChangeLimit;
if (isBandwidthUpdating.value) {
limitValue.value = currentLimits.value.bandwidthLimit / Memory.TB;
return;
}
limitValue.value = currentLimits.value.storageLimit / Memory.TB;
});
onMounted(() => {
updateTrackColor();
});
</script>
<style scoped lang="scss">
.modal {
padding: 32px;
font-family: 'font_regular', sans-serif;
@media screen and (max-width: 375px) {
padding: 32px 16px;
}
&__header {
display: flex;
align-items: center;
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--c-grey-2);
&__title {
font-family: 'font_bold', sans-serif;
font-size: 24px;
line-height: 31px;
letter-spacing: -0.02em;
color: var(--c-black);
margin-left: 16px;
}
}
&__functional {
padding: 16px;
background: var(--c-grey-1);
border: 1px solid var(--c-grey-2);
border-radius: 16px;
&__limits {
display: flex;
align-items: center;
column-gap: 16px;
&__wrap {
@media screen and (max-width: 475px) {
width: calc(50% - 8px);
}
&__label {
font-weight: 500;
font-size: 14px;
line-height: 20px;
color: var(--c-blue-6);
text-align: left;
margin-bottom: 8px;
}
&__inputs {
display: flex;
align-items: center;
border: 1px solid var(--c-grey-4);
border-radius: 8px;
input,
select {
border: none;
font-family: 'font_bold', sans-serif;
font-size: 14px;
line-height: 20px;
color: var(--c-grey-6);
background-color: #fff;
}
input {
padding: 9px 13px;
box-sizing: border-box;
width: 100px;
border-radius: 8px 0 0 8px;
@media screen and (max-width: 475px) {
padding-right: 0;
width: calc(100% - 54px);
}
&:disabled {
background-color: var(--c-grey-2);
}
}
select {
box-sizing: border-box;
min-width: 54px;
padding: 9px 0 9px 13px;
border-radius: 0 8px 8px 0;
}
}
}
}
&__range {
padding: 16px;
border: 1px solid var(--c-grey-3);
border-radius: 8px;
margin-top: 16px;
background-color: var(--c-white);
&__labels {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
p {
font-family: 'font_bold', sans-serif;
font-size: 14px;
line-height: 14px;
color: var(--c-grey-6);
}
}
}
}
&__info {
font-weight: 500;
font-size: 14px;
line-height: 20px;
color: var(--c-blue-6);
margin-top: 16px;
text-align: left;
&__link {
text-decoration: underline !important;
text-underline-position: under;
color: var(--c-blue-6);
&:visited {
color: var(--c-blue-6);
}
}
}
&__buttons {
border-top: 1px solid var(--c-grey-2);
margin-top: 16px;
padding-top: 24px;
display: flex;
align-items: center;
column-gap: 16px;
}
}
input[type='range'] {
width: 100%;
cursor: pointer;
appearance: none;
border: none;
border-radius: 4px;
}
input[type='range']::-webkit-slider-thumb {
appearance: none;
margin-top: -4px;
width: 16px;
height: 16px;
background: var(--c-white);
border: 1px solid var(--c-green-5);
cursor: col-resize;
border-radius: 50%;
background-image: url('../../../static/images/modals/burger.png');
background-repeat: no-repeat;
background-size: 10px 7px;
background-position: center;
}
input[type='range']::-moz-range-thumb {
appearance: none;
margin-top: -4px;
width: 16px;
height: 16px;
background: var(--c-white);
border: 1px solid var(--c-green-5);
cursor: col-resize;
border-radius: 50%;
background-image: url('../../../static/images/modals/burger.png');
background-repeat: no-repeat;
background-size: 10px 7px;
background-position: center;
}
input[type='range']::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
cursor: pointer;
border-radius: 4px;
}
input[type='range']::-moz-range-track {
width: 100%;
height: 8px;
cursor: pointer;
border-radius: 4px;
}
</style>

View File

@ -18,7 +18,7 @@
<p class="card__data__info">{{ usedInfo }}</p>
</div>
<div>
<h2 class="card__data__title">{{ availableTitle }}</h2>
<h2 class="card__data__title alight-right">{{ availableTitle }}</h2>
<p v-if="useAction" class="card__data__action" @click="onAction">{{ actionTitle }}</p>
<a
v-else
@ -155,4 +155,8 @@ const style = computed((): Record<string, string> => {
}
}
}
.alight-right {
text-align: right;
}
</style>

View File

@ -12,7 +12,7 @@
:used-info="`Storage limit: ${usedOrLimitFormatted(limits.storageLimit, true)}`"
:available-title="`${availableFormatted(limits.storageLimit - limits.storageUsed)} Available`"
:action-title="usageActionTitle(storageUsed)"
:on-action="storageAction"
:on-action="() => usageAction(LimitToChange.Storage)"
:is-loading="isLoading"
use-action
/>
@ -25,7 +25,7 @@
:used-info="`Download limit: ${usedOrLimitFormatted(limits.bandwidthLimit, true)} per month`"
:available-title="`${availableFormatted(limits.bandwidthLimit - limits.bandwidthUsed)} Available`"
:action-title="usageActionTitle(bandwidthUsed)"
:on-action="bandwidthAction"
:on-action="() => usageAction(LimitToChange.Bandwidth)"
:is-loading="isLoading"
use-action
/>
@ -84,7 +84,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProjectLimits } from '@/types/projects';
import { LimitToChange, ProjectLimits } from '@/types/projects';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useUsersStore } from '@/store/modules/usersStore';
import { useBillingStore } from '@/store/modules/billingStore';
@ -280,27 +280,16 @@ function usedOrLimitFormatted(value: number, withoutSpace = false): string {
}
/**
* Handles storage card CTA click.
* Handles usage card CTA click.
*/
function storageAction(): void {
function usageAction(limit: LimitToChange): void {
if (!isPaidTier.value) {
startUpgradeFlow();
return;
}
// toggle storage modal
}
/**
* Handles bandwidth card CTA click.
*/
function bandwidthAction(): void {
if (!isPaidTier.value) {
startUpgradeFlow();
return;
}
// toggle bandwidth modal
appStore.setActiveChangeLimit(limit);
appStore.updateActiveModal(MODALS.changeProjectLimit);
}
/**
@ -324,5 +313,9 @@ function navigateToCoupons(): void {
grid-template-columns: calc(50% - 8px) calc(50% - 8px);
grid-gap: 16px;
margin-top: 16px;
@media screen and (max-width: 750px) {
grid-template-columns: auto;
}
}
</style>

View File

@ -550,6 +550,12 @@ onBeforeUnmount((): void => {
align-items: center;
margin: 16px 16px 0 0;
@media screen and (max-width: 390px) {
flex-direction: column;
align-items: flex-end;
row-gap: 5px;
}
&__allocated-color,
&__settled-color {
width: 10px;
@ -575,10 +581,18 @@ onBeforeUnmount((): void => {
&__allocated-label {
margin-right: 16px;
@media screen and (max-width: 390px) {
margin-right: 0;
}
}
&__settled-label {
margin-right: 11px;
@media screen and (max-width: 390px) {
margin-right: 0;
}
}
&__info {

View File

@ -8,6 +8,7 @@ import { OnboardingOS, PricingPlanInfo } from '@/types/common';
import { FetchState } from '@/utils/constants/fetchStateEnum';
import { ManageProjectPassphraseStep } from '@/types/managePassphrase';
import { LocalData } from '@/utils/localData';
import { LimitToChange } from '@/types/projects';
class AppState {
public fetchState = FetchState.LOADING;
@ -32,6 +33,7 @@ class AppState {
public error: ErrorPageState = new ErrorPageState();
public isLargeUploadNotificationShown = true;
public isLargeUploadWarningNotificationShown = false;
public activeChangeLimit: LimitToChange = LimitToChange.Storage;
}
class ErrorPageState {
@ -107,6 +109,10 @@ export const useAppStore = defineStore('app', () => {
state.onbSelectedOs = os;
}
function setActiveChangeLimit(limit: LimitToChange): void {
state.activeChangeLimit = limit;
}
function setPricingPlan(plan: PricingPlanInfo): void {
state.selectedPricingPlan = plan;
}
@ -176,6 +182,7 @@ export const useAppStore = defineStore('app', () => {
setOnboardingAPIKey,
setOnboardingCleanAPIKey,
setOnboardingOS,
setActiveChangeLimit,
setPricingPlan,
setManagePassphraseStep,
setHasShownPricingPlan,

View File

@ -230,3 +230,8 @@ export interface ProjectUsageDateRange {
since: Date;
before: Date;
}
export enum LimitToChange {
Storage = 'Storage',
Bandwidth = 'Bandwidth',
}

View File

@ -79,6 +79,7 @@ export enum AnalyticsErrorEventSource {
UPGRADE_ACCOUNT_MODAL = 'Upgrade account modal',
ADD_PROJECT_MEMBER_MODAL = 'Add project member modal',
ADD_TOKEN_FUNDS_MODAL = 'Add token funds modal',
CHANGE_PROJECT_LIMIT_MODAL = 'Change project limit modal',
CHANGE_PASSWORD_MODAL = 'Change password modal',
CREATE_PROJECT_MODAL = 'Create project modal',
CREATE_PROJECT_PASSPHRASE_MODAL = 'Create project passphrase modal',

View File

@ -5,6 +5,7 @@ import AddTeamMemberModal from '@/components/modals/AddTeamMemberModal.vue';
import RemoveTeamMemberModal from '@/components/modals/RemoveProjectMemberModal.vue';
import EditProfileModal from '@/components/modals/EditProfileModal.vue';
import ChangePasswordModal from '@/components/modals/ChangePasswordModal.vue';
import ChangeProjectLimitModal from '@/components/modals/ChangeProjectLimitModal.vue';
import CreateProjectModal from '@/components/modals/CreateProjectModal.vue';
import OpenBucketModal from '@/components/modals/OpenBucketModal.vue';
import MFARecoveryCodesModal from '@/components/modals/MFARecoveryCodesModal.vue';
@ -82,6 +83,7 @@ enum Modals {
UPGRADE_ACCOUNT = 'upgradeAccount',
DELETE_ACCESS_GRANT = 'deleteAccessGrant',
SKIP_PASSPHRASE = 'skipPassphrase',
CHANGE_PROJECT_LIMIT = 'changeProjectLimit',
}
// modals could be of VueConstructor type or Object (for composition api components).
@ -115,4 +117,5 @@ export const MODALS: Record<Modals, unknown> = {
[Modals.UPGRADE_ACCOUNT]: UpgradeAccountModal,
[Modals.DELETE_ACCESS_GRANT]: DeleteAccessGrantModal,
[Modals.SKIP_PASSPHRASE]: SkipPassphraseModal,
[Modals.CHANGE_PROJECT_LIMIT]: ChangeProjectLimitModal,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

View File

@ -0,0 +1,8 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4423 0H23.3463C28.8835 0 31.0805 0.613717 33.2353 1.76615C35.3902 2.91858 37.0814 4.60973 38.2338 6.76459L38.3214 6.93049C39.4029 9.00663 39.9846 11.1999 40 16.4422V23.3461C40 28.8832 39.3863 31.0802 38.2338 33.235C37.0814 35.3899 35.3902 37.081 33.2353 38.2335L33.0694 38.321C30.9933 39.4025 28.8 39.9842 23.5577 39.9996H16.6537C11.1165 39.9996 8.91954 39.3859 6.76466 38.2335C4.60977 37.081 2.91861 35.3899 1.76617 33.235L1.67858 33.0691C0.597074 30.993 0.0154219 28.7998 0 23.5574V16.6535C0 11.1164 0.613723 8.91945 1.76617 6.76459C2.91861 4.60973 4.60977 2.91858 6.76466 1.76615L6.93056 1.67856C9.00672 0.597068 11.2 0.0154217 16.4423 0Z" fill="#0218A7"/>
<path d="M20.1655 8.92554C29.294 8.92554 36.6942 16.3257 36.6942 25.4543H3.63672C3.63672 16.417 10.8896 9.07371 19.8921 8.92775L20.1655 8.92554Z" fill="#0149FF"/>
<path d="M31.6463 13.5636C34.7586 16.5693 36.6941 20.7858 36.6941 25.4543H20.1655L31.6463 13.5636Z" fill="#FF458B"/>
<path d="M10.8144 24.4289V25.4543H6.54443V24.4289H10.8144ZM33.7864 24.4289V25.4543H29.5164V24.4289H33.7864ZM11.0582 15.0167L14.0742 18.0328L13.0346 19.0724L10.0185 16.0563L11.0582 15.0167ZM20.9 11.5426V15.8125H19.4308V11.5426H20.9Z" fill="white"/>
<path d="M20.1653 22.8066C21.6276 22.8066 22.813 23.992 22.813 25.4543H17.5176C17.5176 23.992 18.703 22.8066 20.1653 22.8066Z" fill="#FFC600"/>
<path d="M29.2704 14.9165L19.5044 24.7799H21.755L30.4069 16.0418L29.2704 14.9165Z" fill="#FFC600"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB