web/satellite/vuetify-poc: add project settings page

This change implements the Project Settings page in the Vuetify
project. It allows users to view and change a project's name,
description, storage limit, and bandwidth limit.

Resolves #6176

Change-Id: Ibcc56d2bb7c71e818390e69eec197508e3551803
This commit is contained in:
Jeremy Wharton 2023-08-14 19:56:24 -05:00
parent de7aabc8c9
commit 80186ecc67
10 changed files with 799 additions and 3 deletions

View File

@ -278,3 +278,8 @@ export enum LimitToChange {
Storage = 'Storage',
Bandwidth = 'Bandwidth',
}
export enum FieldToChange {
Name = 'Name',
Description = 'Description',
}

View File

@ -9,6 +9,13 @@ export enum Memory {
TB = 1e12,
PB = 1e15,
EB = 1e18,
KiB = 2 ** 10,
MiB = 2 ** 20,
GiB = 2 ** 30,
TiB = 2 ** 40,
PiB = 2 ** 50,
EiB = 2 ** 60,
}
export enum Dimensions {

View File

@ -106,7 +106,9 @@ export enum AnalyticsErrorEventSource {
ONBOARDING_NAME_STEP = 'Onboarding name step',
ONBOARDING_PERMISSIONS_STEP = 'Onboarding permissions step',
PROJECT_DASHBOARD_PAGE = 'Project dashboard page',
PROJECT_SETTINGS_AREA = 'Project settings area',
EDIT_PROJECT_DETAILS = 'Edit project details',
EDIT_PROJECT_LIMIT = 'Edit project limit',
PROJECTS_LIST = 'Projects list',
PROJECT_MEMBERS_HEADER = 'Project members page header',
PROJECT_MEMBERS_PAGE = 'Project members page',

View File

@ -16,7 +16,7 @@
<v-menu activator="parent" location="end" transition="scale-transition">
<v-list class="pa-2">
<v-list-item link rounded="lg">
<v-list-item link rounded="lg" :to="`/projects/${item.id}/settings`">
<template #prepend>
<icon-settings />
</template>

View File

@ -72,7 +72,7 @@
<v-menu activator="parent" location="bottom end" transition="scale-transition">
<v-list class="pa-0">
<template v-if="item.raw.role === ProjectRole.Owner">
<v-list-item link>
<v-list-item link :to="`/projects/${item.raw.id}/settings`">
<template #prepend>
<icon-settings />
</template>

View File

@ -0,0 +1,165 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-dialog
v-model="model"
width="410px"
transition="fade-transition"
:persistent="isLoading"
>
<v-card rounded="xlg">
<v-card-item class="pl-7 py-4">
<template #prepend>
<img class="d-block" src="@/../static/images/modals/boxesIcon.svg" alt="Boxes">
</template>
<v-card-title class="font-weight-bold">Edit Project {{ field }}</v-card-title>
<template #append>
<v-btn
icon="$close"
variant="text"
size="small"
color="default"
:disabled="isLoading"
@click="model = false"
/>
</template>
</v-card-item>
<v-divider />
<v-form v-model="formValid" class="pa-7" @submit.prevent>
<v-text-field
v-model="input"
class="py-4"
variant="outlined"
:rules="rules"
:label="`Project ${field}`"
:counter="maxLength"
persistent-counter
autofocus
/>
</v-form>
<v-divider />
<v-card-actions class="pa-7">
<v-row>
<v-col>
<v-btn variant="outlined" color="default" block :disabled="isLoading" @click="model = false">
Cancel
</v-btn>
</v-col>
<v-col>
<v-btn color="primary" variant="flat" block :loading="isLoading" @click="onSaveClick">
Save
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import {
VDialog,
VCard,
VCardItem,
VCardTitle,
VDivider,
VCardActions,
VRow,
VCol,
VBtn,
VForm,
VTextField,
} from 'vuetify/components';
import { useLoading } from '@/composables/useLoading';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { useNotify } from '@/utils/hooks';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { ValidationRule } from '@poc/types/common';
import { FieldToChange, ProjectFields, MAX_NAME_LENGTH, MAX_DESCRIPTION_LENGTH } from '@/types/projects';
const props = defineProps<{
modelValue: boolean,
field: FieldToChange,
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean],
}>();
const model = computed<boolean>({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
});
const projectsStore = useProjectsStore();
const analyticsStore = useAnalyticsStore();
const { isLoading, withLoading } = useLoading();
const notify = useNotify();
const formValid = ref<boolean>(false);
const input = ref<string>('');
/**
* Returns the maximum input length.
*/
const maxLength = computed<number>(() => {
return props.field === FieldToChange.Name ? MAX_NAME_LENGTH : MAX_DESCRIPTION_LENGTH;
});
/**
* Returns an array of validation rules applied to the input.
*/
const rules = computed<ValidationRule<string>[]>(() => {
const max = maxLength.value;
const required = props.field === FieldToChange.Name;
return [
v => (!!v || !required) || 'Required',
v => v.length <= max || 'Input is too long.',
];
});
/**
* Updates project field.
*/
async function onSaveClick(): Promise<void> {
if (!formValid.value) return;
await withLoading(async () => {
try {
if (props.field === FieldToChange.Name) {
await projectsStore.updateProjectName(new ProjectFields(input.value, ''));
} else {
await projectsStore.updateProjectDescription(new ProjectFields('', input.value));
}
} catch (error) {
notify.error(
`Error updating project ${props.field.toLowerCase()}. ${error.message}`,
AnalyticsErrorEventSource.EDIT_PROJECT_DETAILS,
);
return;
}
analyticsStore.eventTriggered(
props.field === FieldToChange.Name
? AnalyticsEvent.PROJECT_NAME_UPDATED
: AnalyticsEvent.PROJECT_DESCRIPTION_UPDATED,
);
notify.success(`Project ${props.field.toLowerCase()} updated.`);
model.value = false;
});
}
watch(() => model.value, shown => {
if (!shown) return;
const project = projectsStore.state.selectedProject;
input.value = props.field === FieldToChange.Name ? project.name : project.description;
}, { immediate: true });
</script>

View File

@ -0,0 +1,333 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-dialog
v-model="model"
width="410px"
transition="fade-transition"
:persistent="isLoading"
>
<v-card rounded="xlg">
<v-card-item class="pl-7 py-4">
<template #prepend>
<img class="d-block" src="@/../static/images/modals/limit.svg" alt="Speedometer">
</template>
<v-card-title class="font-weight-bold">Edit {{ limitType }} Limit</v-card-title>
<template #append>
<v-btn
icon="$close"
variant="text"
size="small"
color="default"
:disabled="isLoading"
@click="model = false"
/>
</template>
</v-card-item>
<v-divider />
<v-form v-model="formValid" class="pa-7" @submit.prevent>
<v-row>
<v-col cols="6">
<p class="text-subtitle-2 mb-1">Set {{ limitType }} Limit</p>
<v-text-field
class="edit-project-limit__text-field"
variant="outlined"
density="compact"
type="number"
:rules="rules"
:model-value="inputText"
@update:model-value="updateInputText"
>
<template #append-inner>
<v-menu>
<template #activator="{ props: slotProps, isActive }">
<v-btn
class="h-100 text-medium-emphasis"
variant="text"
density="compact"
color="default"
:append-icon="isActive ? 'mdi-menu-up' : 'mdi-menu-down'"
v-bind="slotProps"
@mousedown.stop
@click.stop
>
<span class="font-weight-regular">{{ activeMeasurement }}</span>
</v-btn>
</template>
<v-list v-model:selected="dropdownModel" density="compact">
<v-list-item :title="Dimensions.TB" :value="Dimensions.TB" />
<v-list-item :title="Dimensions.GB" :value="Dimensions.GB" />
</v-list>
</v-menu>
</template>
</v-text-field>
</v-col>
<v-col cols="6">
<p class="text-subtitle-2 mb-1">Available {{ limitType }}</p>
<v-text-field
class="edit-project-limit__text-field"
variant="solo-filled"
density="compact"
flat
readonly
:model-value="availableUsageFormatted"
>
<template #append-inner>
<v-menu>
<template #activator="{ props: slotProps, isActive }">
<v-btn
class="h-100 text-medium-emphasis"
variant="text"
density="compact"
color="default"
:append-icon="isActive ? 'mdi-menu-up' : 'mdi-menu-down'"
v-bind="slotProps"
@mousedown.stop
@click.stop
>
<span class="font-weight-regular">{{ activeMeasurement }}</span>
</v-btn>
</template>
<v-list v-model:selected="dropdownModel" density="compact">
<v-list-item :title="Dimensions.TB" :value="Dimensions.TB" />
<v-list-item :title="Dimensions.GB" :value="Dimensions.GB" />
</v-list>
</v-menu>
</template>
</v-text-field>
</v-col>
<v-col cols="12">
<v-card class="pa-3">
<div class="d-flex mx-2 text-subtitle-2 font-weight-bold text-medium-emphasis">
0 {{ activeMeasurement }}
<v-spacer />
{{ availableUsageFormatted }} {{ activeMeasurement }}
</div>
<v-slider
min="0"
:max="availableUsage"
:step="Memory[activeMeasurement]"
color="success"
:model-value="input"
@update:model-value="updateInput"
/>
</v-card>
</v-col>
</v-row>
</v-form>
<v-divider />
<v-card-actions class="pa-7">
<v-row>
<v-col>
<v-btn variant="outlined" color="default" block :disabled="isLoading" @click="model = false">
Cancel
</v-btn>
</v-col>
<v-col>
<v-btn color="primary" variant="flat" block :loading="isLoading" @click="onSaveClick">
Save
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import {
VDialog,
VCard,
VCardItem,
VCardTitle,
VDivider,
VCardActions,
VRow,
VCol,
VBtn,
VForm,
VTextField,
VSpacer,
VSlider,
VMenu,
VList,
VListItem,
} from 'vuetify/components';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useConfigStore } from '@/store/modules/configStore';
import { useNotify } from '@/utils/hooks';
import { useLoading } from '@/composables/useLoading';
import { RequiredRule, ValidationRule } from '@poc/types/common';
import { LimitToChange, ProjectLimits } from '@/types/projects';
import { Dimensions, Memory } from '@/utils/bytesSize';
import { decimalShift } from '@/utils/strings';
const projectsStore = useProjectsStore();
const analyticsStore = useAnalyticsStore();
const configStore = useConfigStore();
const { isLoading, withLoading } = useLoading();
const notify = useNotify();
const props = defineProps<{
modelValue: boolean,
limitType: LimitToChange,
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean],
}>();
const formValid = ref<boolean>(false);
const activeMeasurement = ref<Dimensions.GB | Dimensions.TB>(Dimensions.TB);
const inputText = ref<string>('0');
const input = ref<number>(0);
const model = computed<boolean>({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
});
const dropdownModel = computed<(Dimensions.GB | Dimensions.TB)[]>({
get: () => [ activeMeasurement.value ],
set: value => activeMeasurement.value = value[0],
});
/**
* Returns the maximum amount of bytes that the usage limit can be set to.
*/
const availableUsage = computed<number>(() => {
if (props.limitType === LimitToChange.Storage) {
return Math.max(
projectsStore.state.currentLimits.storageLimit,
parseConfigLimit(configStore.state.config.defaultPaidStorageLimit),
);
}
return Math.max(
projectsStore.state.currentLimits.bandwidthLimit,
parseConfigLimit(configStore.state.config.defaultPaidBandwidthLimit),
);
});
/**
* Returns the maximum amount of active measurement units that the usage limit can be set to.
*/
const availableUsageFormatted = computed<string>(() => {
return decimalShift((availableUsage.value / Memory[activeMeasurement.value]).toFixed(2), 0);
});
/**
* Returns an array of validation rules applied to the text input.
*/
const rules = computed<ValidationRule<string>[]>(() => {
const max = availableUsage.value;
return [
RequiredRule,
v => !(isNaN(+v) || isNaN(parseFloat(v))) || 'Invalid number',
v => (parseFloat(v) > 0) || 'Number must be positive',
v => (parseFloat(v) <= max) || 'Number is too large',
];
});
/**
* Parses limit value from config, returning it as a byte amount.
*/
function parseConfigLimit(limit: string): number {
const [value, unit] = limit.split(' ');
return parseFloat(value) * Memory[unit === 'B' ? 'Bytes' : unit];
}
/**
* Updates project limit.
*/
async function onSaveClick(): Promise<void> {
if (!formValid.value) return;
await withLoading(async () => {
try {
if (props.limitType === LimitToChange.Storage) {
await projectsStore.updateProjectStorageLimit(new ProjectLimits(0, 0, input.value));
} else {
await projectsStore.updateProjectBandwidthLimit(new ProjectLimits(input.value));
}
} catch (error) {
notify.error(error.message, AnalyticsErrorEventSource.EDIT_PROJECT_LIMIT);
return;
}
analyticsStore.eventTriggered(
props.limitType === LimitToChange.Storage
? AnalyticsEvent.PROJECT_STORAGE_LIMIT_UPDATED
: AnalyticsEvent.PROJECT_BANDWIDTH_LIMIT_UPDATED,
);
notify.success('Limit updated successfully.');
model.value = false;
});
}
/**
* Updates input refs with value from text field.
*/
function updateInputText(value: string): void {
inputText.value = value;
const num = +value;
if (isNaN(num) || isNaN(parseFloat(value))) return;
input.value = Math.floor(num * Memory[activeMeasurement.value]);
}
/**
* Updates input refs with value from slider.
*/
function updateInput(value: number): void {
input.value = value;
inputText.value = (value / Memory[activeMeasurement.value]).toString();
}
watch(() => model.value, shown => {
if (!shown) return;
const project = projectsStore.state.selectedProject;
updateInput(
props.limitType === LimitToChange.Storage
? projectsStore.state.currentLimits.storageLimit
: projectsStore.state.currentLimits.bandwidthLimit,
);
}, { immediate: true });
watch(() => activeMeasurement.value, unit => {
inputText.value = (input.value / Memory[unit]).toString();
});
</script>
<style scoped lang="scss">
.edit-project-limit__text-field {
:deep(.v-field) {
padding-inline-end: 0;
}
:deep(input) {
text-overflow: ellipsis;
/* Firefox */
appearance: textfield;
/* Chrome, Safari, Edge, Opera */
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
}
}
</style>

View File

@ -57,7 +57,7 @@
<v-divider class="my-2" />
<!-- Project Settings -->
<v-list-item link rounded="lg">
<v-list-item link rounded="lg" :to="`/projects/${selectedProject.id}/settings`">
<template #prepend>
<IconSettings />
</template>

View File

@ -72,6 +72,11 @@ const routes: RouteRecordRaw[] = [
name: 'Team',
component: () => import(/* webpackChunkName: "Team" */ '@poc/views/Team.vue'),
},
{
path: 'settings',
name: 'Project Settings',
component: () => import(/* webpackChunkName: "ProjectSettings" */ '@poc/views/ProjectSettings.vue'),
},
],
},
];

View File

@ -0,0 +1,279 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-container>
<h1 class="text-h5 font-weight-bold mb-2">Project Settings</h1>
<v-card class="my-6">
<v-list lines="three">
<v-list-subheader class="mb-2">Details</v-list-subheader>
<v-divider />
<v-list-item>
<v-list-item-title>Project Name</v-list-item-title>
<v-list-item-subtitle>{{ project.name }}</v-list-item-subtitle>
<template #append>
<v-list-item-action>
<v-btn variant="outlined" color="default" size="small" @click="showEditNameDialog">
Edit Project Name
</v-btn>
</v-list-item-action>
</template>
</v-list-item>
<v-divider />
<v-list-item>
<v-list-item-title>Description</v-list-item-title>
<v-list-item-subtitle>{{ project.description }}</v-list-item-subtitle>
<template #append>
<v-list-item-action>
<v-btn variant="outlined" color="default" size="small" @click="showEditDescriptionDialog">
Edit Description
</v-btn>
</v-list-item-action>
</template>
</v-list-item>
</v-list>
</v-card>
<v-card class="my-6">
<v-list lines="three">
<v-list-subheader class="mb-2">Limits</v-list-subheader>
<v-divider />
<v-list-item v-if="!isPaidTier">
<v-list-item-title>Free Account</v-list-item-title>
<v-list-item-subtitle>
{{ storageLimitFormatted }} Storage / {{ bandwidthLimitFormatted }} Bandwidth.
Need more? Upgrade now.
</v-list-item-subtitle>
<template #append>
<v-list-item-action>
<v-btn variant="flat" color="primary" size="small">
Upgrade to Pro
</v-btn>
</v-list-item-action>
</template>
</v-list-item>
<template v-else>
<v-list-item>
<v-list-item-title>Storage</v-list-item-title>
<v-list-item-subtitle>
{{ storageLimitFormatted }} of {{ paidStorageLimitFormatted }} available storage.
</v-list-item-subtitle>
<template #append>
<v-list-item-action>
<v-btn variant="outlined" color="default" size="small" @click="showStorageLimitDialog">
Edit Storage Limit
</v-btn>
</v-list-item-action>
</template>
</v-list-item>
<v-divider />
<v-list-item>
<v-list-item-title>Bandwidth</v-list-item-title>
<v-list-item-subtitle>
{{ bandwidthLimitFormatted }} of {{ paidBandwidthLimitFormatted }} available bandwidth.
</v-list-item-subtitle>
<template #append>
<v-list-item-action>
<v-btn variant="outlined" color="default" size="small" @click="showBandwidthLimitDialog">
Edit Bandwidth Limit
</v-btn>
</v-list-item-action>
</template>
</v-list-item>
<v-divider />
<v-list-item>
<v-list-item-title>Account</v-list-item-title>
<v-list-item-subtitle>
{{ paidStorageLimitFormatted }} storage and {{ paidBandwidthLimitFormatted }} bandwidth per month.
</v-list-item-subtitle>
<template #append>
<v-list-item-action>
<v-btn
variant="outlined"
color="default"
size="small"
:href="projectLimitsIncreaseRequestURL"
target="_blank"
rel="noopener noreferrer"
>
Request Increase
</v-btn>
</v-list-item-action>
</template>
</v-list-item>
</template>
</v-list>
</v-card>
</v-container>
<edit-project-details-dialog v-model="isEditDetailsDialogShown" :field="fieldToChange" />
<edit-project-limit-dialog v-model="isEditLimitDialogShown" :limit-type="limitToChange" />
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import {
VContainer,
VCard,
VList,
VListSubheader,
VDivider,
VListItem,
VListItemTitle,
VListItemSubtitle,
VListItemAction,
VBtn,
} from 'vuetify/components';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { FieldToChange, LimitToChange, Project } from '@/types/projects';
import { useUsersStore } from '@/store/modules/usersStore';
import { Memory, Size } from '@/utils/bytesSize';
import { useConfigStore } from '@/store/modules/configStore';
import { decimalShift } from '@/utils/strings';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import EditProjectDetailsDialog from '@poc/components/dialogs/EditProjectDetailsDialog.vue';
import EditProjectLimitDialog from '@poc/components/dialogs/EditProjectLimitDialog.vue';
const isEditDetailsDialogShown = ref<boolean>(false);
const isEditLimitDialogShown = ref<boolean>(false);
const fieldToChange = ref<FieldToChange>(FieldToChange.Name);
const limitToChange = ref<LimitToChange>(LimitToChange.Storage);
const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
const configStore = useConfigStore();
const notify = useNotify();
/**
* Returns selected project from the store.
*/
const project = computed<Project>(() => {
return projectsStore.state.selectedProject;
});
/**
* Returns user's paid tier status from store.
*/
const isPaidTier = computed<boolean>(() => {
return usersStore.state.user.paidTier;
});
/**
* Returns formatted storage limit.
*/
const storageLimitFormatted = computed<string>(() => {
return formatLimit(projectsStore.state.currentLimits.storageLimit);
});
/**
* Returns formatted bandwidth limit.
*/
const bandwidthLimitFormatted = computed<string>(() => {
return formatLimit(projectsStore.state.currentLimits.bandwidthLimit);
});
/**
* Returns formatted paid tier storage limit.
*/
const paidStorageLimitFormatted = computed<string>(() => {
const limit = Math.max(
projectsStore.state.currentLimits.storageLimit,
parseConfigLimit(configStore.state.config.defaultPaidStorageLimit),
);
return formatLimit(limit);
});
/**
* Returns formatted paid tier bandwidth limit.
*/
const paidBandwidthLimitFormatted = computed<string>(() => {
const limit = Math.max(
projectsStore.state.currentLimits.bandwidthLimit,
parseConfigLimit(configStore.state.config.defaultPaidBandwidthLimit),
);
return formatLimit(limit);
});
/**
* Returns project limits increase request URL from config.
*/
const projectLimitsIncreaseRequestURL = computed((): string => {
return configStore.state.config.projectLimitsIncreaseRequestURL;
});
/**
* Returns formatted limit value.
*/
function formatLimit(limit: number): string {
const size = new Size(limit, 2);
return `${decimalShift(size.formattedBytes, 0)} ${size.label}`;
}
/**
* Displays the Edit Project Name dialog.
*/
function showEditNameDialog(): void {
fieldToChange.value = FieldToChange.Name;
isEditDetailsDialogShown.value = true;
}
/**
* Displays the Edit Description dialog.
*/
function showEditDescriptionDialog(): void {
fieldToChange.value = FieldToChange.Description;
isEditDetailsDialogShown.value = true;
}
/**
* Displays the Storage Limit dialog.
*/
function showStorageLimitDialog(): void {
limitToChange.value = LimitToChange.Storage;
isEditLimitDialogShown.value = true;
}
/**
* Displays the Bandwidth Limit dialog.
*/
function showBandwidthLimitDialog(): void {
limitToChange.value = LimitToChange.Bandwidth;
isEditLimitDialogShown.value = true;
}
/**
* Parses limit value from config, returning it as a byte amount.
*/
function parseConfigLimit(limit: string): number {
const [value, unit] = limit.split(' ');
return parseFloat(value) * Memory[unit === 'B' ? 'Bytes' : unit];
}
/**
* Lifecycle hook after initial render.
* Fetches project limits.
*/
onMounted(async () => {
try {
await projectsStore.getProjectLimits(project.value.id);
} catch (error) {
notify.error(`Error fetching project limits. ${error.message}`, AnalyticsErrorEventSource.PROJECT_SETTINGS_AREA);
}
});
</script>