web/satellite: fix browser hover and click behaviour

This change adds checkboxes to file items in the browser so they can be
selected that way instead of the previous click behaviour, which now
opens the object modal for files and open folder for folders. This
change also features some styling fixes for the browser.

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

Change-Id: I5b38208a8e8673d8212c749586bdb7e169c086f8
This commit is contained in:
Wilfred Asomani 2023-04-04 15:57:38 +00:00
parent 1d63395fd1
commit 085bc0c4cb
10 changed files with 159 additions and 57 deletions

View File

@ -95,7 +95,7 @@
:on-close="closeBanner"
/>
<v-table class="file-browser-table">
<v-table selectable :selected="allFilesSelected" show-select class="file-browser-table" @selectAllClicked="toggleSelectAllFiles">
<template #head>
<file-browser-header />
</template>
@ -105,8 +105,9 @@
:key="index"
>
<!-- using <th> to comply with common Vtable.vue-->
<th class="hide-mobile" />
<th
class="align-left data"
class="align-left"
aria-roledescription="file-uploading"
>
<p class="file-name">
@ -114,7 +115,7 @@
<span>{{ filename(file) }}</span>
</p>
</th>
<th class="align-left data" aria-roledescription="progress-bar">
<th aria-roledescription="progress-bar">
<div class="progress">
<div
class="progress-bar"
@ -127,7 +128,7 @@
</div>
</div>
</th>
<th class="align-left data">
<th>
<v-button
width="60px"
font-size="14px"
@ -135,16 +136,17 @@
:on-press="() => cancelUpload(file.Key)"
/>
</th>
<th />
<th class="hide-mobile" />
</tr>
<tr v-if="filesUploading.length" class="files-uploading-count">
<th class="align-left data files-uploading-count__content" aria-roledescription="files-uploading-count">
<th class="hide-mobile files-uploading-count__content" />
<th class="align-left files-uploading-count__content" aria-roledescription="files-uploading-count">
{{ formattedFilesWaitingToBeUploaded }}
waiting to be uploaded...
</th>
<th class="files-uploading-count__content" />
<th class="files-uploading-count__content" />
<th class="hide-mobile files-uploading-count__content" />
<th class="hide-mobile files-uploading-count__content" />
<th class="files-uploading-count__content" />
</tr>
@ -318,21 +320,42 @@ const bucketName = computed((): string => {
return store.state.files.bucket;
});
const files = computed((): BrowserFile[] => {
/**
* Whether all files are selected.
* */
const allFilesSelected = computed((): boolean => {
if (files.value.length === 0) {
return false;
}
const shiftSelectedFiles = store.state.files.shiftSelectedFiles;
const selectedFiles = store.state.files.selectedFiles;
const selectedAnchorFile = store.state.files.selectedAnchorFile;
const allSelectedFiles = [
...selectedFiles,
...shiftSelectedFiles,
];
if (selectedAnchorFile && !allSelectedFiles.includes(selectedAnchorFile)) {
allSelectedFiles.push(selectedAnchorFile);
}
return allSelectedFiles.length === files.value.length;
});
const files = computed((): BrowserObject[] => {
return store.getters['files/sortedFiles'];
});
/**
* Return an array of BrowserFile type that are files and not folders.
*/
const singleFiles = computed((): BrowserFile[] => {
const singleFiles = computed((): BrowserObject[] => {
return files.value.filter((f) => f.type === 'file');
});
/**
* Return an array of BrowserFile type that are folders and not files.
*/
const folders = computed((): BrowserFile[] => {
const folders = computed((): BrowserObject[] => {
return files.value.filter((f) => f.type === 'folder');
});
@ -382,9 +405,7 @@ function closeModalDropdown(): void {
store.dispatch('files/closeDropdown');
}
if (store.state.files.selectedFile) {
store.dispatch('files/clearAllSelectedFiles');
}
store.dispatch('files/clearAllSelectedFiles');
}
/**
@ -477,6 +498,22 @@ async function goToBuckets(): Promise<void> {
await onRouteChange();
}
/**
* Toggles the selection of all files.
* */
async function toggleSelectAllFiles(): Promise<void> {
if (files.value.length === 0) {
return;
}
if (allFilesSelected.value) {
await store.dispatch('files/clearAllSelectedFiles');
} else {
await store.dispatch('files/clearAllSelectedFiles');
store.commit('files/setSelectedAnchorFile', files.value[0]);
await store.dispatch('files/updateSelectedFiles', files.value.slice(1, files.value.length));
}
}
/**
* Set spinner state. If routePath is not present navigate away.
* If there's some error then re-render the page with a call to list.
@ -491,6 +528,9 @@ onBeforeMount(async () => {
return;
}
// clear previous file selections.
store.dispatch('files/clearAllSelectedFiles');
// display the spinner while files are being fetched
fetchingFilesSpinner.value = true;
@ -513,6 +553,20 @@ onBeforeMount(async () => {
min-height: 500px;
}
.hide-mobile {
@media screen and (max-width: 550px) {
display: none;
}
}
@media screen and (max-width: 550px) {
// hide size, upload date columns on mobile screens
:deep(.data:not(:nth-child(2))) {
display: none;
}
}
.position-relative {
position: relative;
}

View File

@ -111,9 +111,9 @@ function fromFilesStore(prop: string): string {
/**
* Check if the trashcan to delete selected files/folder should be displayed.
*/
const filesToDelete = computed((): string => {
const filesToDelete = computed((): boolean => {
return (!!store.state.files.selectedAnchorFile || (
store.state.files.unselectedAnchorFile &&
!!store.state.files.unselectedAnchorFile &&
(store.state.files.selectedFiles.length > 0 ||
store.state.files.shiftSelectedFiles.length > 0)
));
@ -219,7 +219,8 @@ function cancelDeleteSelection(): void {
}
&__functional {
padding: 0 10px;
padding: 0;
width: 50px;
position: relative;
cursor: pointer;

View File

@ -4,11 +4,13 @@
<template>
<table-item
v-if="fileTypeIsFile"
selectable
:selected="isFileSelected"
:on-click="selectFile"
:on-click="openModal"
:on-primary-click="openModal"
:item="{'name': file.Key, 'size': size, 'date': uploadDate}"
:item-type="fileType"
@selectClicked="selectFile"
>
<th slot="options" v-click-outside="closeDropdown" class="file-entry__functional options overflow-visible" @click.stop="openDropdown">
<div
@ -61,10 +63,12 @@
<table-item
v-else-if="fileTypeIsFolder"
:item="{'name': file.Key, 'size': '', 'date': ''}"
selectable
:selected="isFileSelected"
:on-click="selectFile"
:on-primary-click="openBucket"
:on-click="openFolder"
:on-primary-click="openFolder"
item-type="folder"
@selectClicked="selectFile"
>
<th slot="options" v-click-outside="closeDropdown" class="file-entry__functional options overflow-visible" @click.stop="openDropdown">
<div
@ -108,11 +112,11 @@
import { computed, ref } from 'vue';
import prettyBytes from 'pretty-bytes';
import type { BrowserFile } from '@/types/browser';
import { useNotify, useRouter, useStore } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { MODALS } from '@/utils/constants/appStatePopUps';
import { APP_STATE_MUTATIONS } from '@/store/mutationConstants';
import { BrowserObject } from '@/store/modules/files';
import TableItem from '@/components/common/TableItem.vue';
@ -129,7 +133,7 @@ const router = useRouter();
const props = defineProps<{
path: string,
file: BrowserFile,
file: BrowserObject,
}>();
const emit = defineEmits(['onUpdate']);
@ -265,8 +269,9 @@ function selectFile(event: KeyboardEvent): void {
setSelectedFile(isSelectedFile);
}
async function openBucket(): Promise<void> {
async function openFolder(): Promise<void> {
await router.push(link.value);
store.dispatch('files/clearAllSelectedFiles');
emit('onUpdate');
}
@ -340,11 +345,30 @@ function setSelectedFile(command: boolean): void {
store.dispatch('files/updateShiftSelectedFiles', []);
} else {
/* if it's just a file click without any modifier, then set selectedAnchorFile to the file that was clicked, set shiftSelectedFiles and selectedFiles to an empty array. */
store.commit('files/setSelectedAnchorFile', props.file);
/* if it's just a file click without any modifier ... */
const newSelection = [...files];
const fileIdx = newSelection.findIndex((file) => file === props.file);
switch (true) {
case fileIdx !== -1:
// this file is already selected, deselect.
newSelection.splice(fileIdx, 1);
break;
case selectedAnchorFile === props.file:
// this file is already selected, deselect.
store.commit('files/setSelectedAnchorFile', null);
store.commit('files/setUnselectedAnchorFile', props.file);
break;
case !!selectedAnchorFile:
// there's an anchor file, but not this file.
// add the anchor file to the selection arr and make this file the anchor file.
newSelection.push(selectedAnchorFile as BrowserObject);
store.commit('files/setSelectedAnchorFile', props.file);
break;
default:
store.commit('files/setSelectedAnchorFile', props.file);
}
store.dispatch('files/updateShiftSelectedFiles', []);
store.dispatch('files/updateSelectedFiles', []);
store.dispatch('files/updateSelectedFiles', newSelection);
}
}
@ -483,10 +507,16 @@ function cancelDeletion(): void {
.file-entry {
&__functional {
padding: 0 10px;
padding: 0;
width: 50px;
position: relative;
cursor: pointer;
@media screen and (max-width: 550px) {
padding: 0 10px;
width: unset;
}
&__dropdown {
position: absolute;
top: 25px;
@ -570,7 +600,7 @@ function cancelDeletion(): void {
@media screen and (max-width: 550px) {
// hide size, upload date columns on mobile screens
:deep(.data:not(:first-of-type)) {
:deep(.data:not(:nth-child(2))) {
display: none;
}
}

View File

@ -3,6 +3,8 @@
<template>
<table-item
selectable
select-hidden
:on-click="openModal"
:on-primary-click="openModal"
:item="{'name': 'Objects locked', 'size': '', 'date': ''}"

View File

@ -3,8 +3,9 @@
<template>
<table-item
selectable
select-hidden
:on-click="onBack"
:on-primary-click="onBack"
:item="{'name': 'Back', 'size': '', 'date': ''}"
item-type="back"
>

View File

@ -7,8 +7,8 @@
:class="{ 'selected': selected }"
@click="onClick"
>
<th v-if="selectable" class="icon select">
<v-table-checkbox :disabled="selectDisabled" :value="selected" @checkChange="onChange" />
<th v-if="selectable" class="icon select" @click.stop="selectClicked">
<v-table-checkbox v-if="!selectHidden" :disabled="selectDisabled || selectHidden" :value="selected" @selectClicked="selectClicked" />
</th>
<th
v-for="(val, _, index) in item" :key="index" class="align-left data"
@ -22,7 +22,7 @@
<component :is="icon" />
</div>
<p :class="{primary: index === 0}" :title="val" @click.stop="(e) => cellContentClicked(index, e)">
<middle-truncate v-if="(itemType.toLowerCase() === 'file')" :text="val" />
<middle-truncate v-if="(itemType?.toLowerCase() === 'file')" :text="val" />
<span v-else>{{ val }}</span>
</p>
<div v-if="showBucketGuide(index)" class="animation">
@ -57,6 +57,7 @@ import ZipIcon from '@/../static/images/objects/zip.svg';
const props = withDefaults(defineProps<{
selectDisabled?: boolean;
selectHidden?: boolean;
selected?: boolean;
selectable?: boolean;
showGuide?: boolean;
@ -68,6 +69,7 @@ const props = withDefaults(defineProps<{
onPrimaryClick?: (data?: unknown) => void;
}>(), {
selectDisabled: false,
selectHidden: false,
selected: false,
selectable: false,
showGuide: false,
@ -78,7 +80,7 @@ const props = withDefaults(defineProps<{
onPrimaryClick: undefined,
});
const emit = defineEmits(['selectChange']);
const emit = defineEmits(['selectClicked']);
const icons = new Map<string, VueConstructor>([
['locked', TableLockedIcon],
@ -97,8 +99,8 @@ const icons = new Map<string, VueConstructor>([
const icon = computed(() => icons.get(props.itemType.toLowerCase()));
function onChange(value: boolean): void {
emit('selectChange', value);
function selectClicked(event: Event): void {
emit('selectClicked', event);
}
function showBucketGuide(index: number): boolean {
@ -185,15 +187,15 @@ function cellContentClicked(cellIndex: number, event: Event) {
.primary {
color: var(--c-blue-3);
}
svg :deep(path) {
fill: var(--c-blue-3);
}
}
}
&.selected {
background: var(--c-yellow-1);
:deep(.select) {
background: var(--c-yellow-1);
}
}
}

View File

@ -6,7 +6,9 @@
<table class="base-table" border="0" cellpadding="0" cellspacing="0">
<thead>
<tr>
<th v-if="selectable" class="icon select" />
<th v-if="selectable" class="icon select" @click.stop="() => emit('selectAllClicked')">
<v-table-checkbox v-if="showSelect" :value="selected" @selectClicked="() => emit('selectAllClicked')" />
</th>
<slot name="head" />
</tr>
</thead>
@ -31,6 +33,7 @@
import { OnPageClickCallback } from '@/types/pagination';
import TablePagination from '@/components/common/TablePagination.vue';
import VTableCheckbox from '@/components/common/VTableCheckbox.vue';
const props = withDefaults(defineProps<{
itemsLabel?: string,
@ -39,14 +42,20 @@ const props = withDefaults(defineProps<{
onPageClickCallback?: OnPageClickCallback,
totalPageCount?: number,
selectable?: boolean,
selected?: boolean,
showSelect?: boolean,
}>(), {
selectable: false,
selected: false,
showSelect: false,
totalPageCount: 0,
itemsLabel: '',
limit: 0,
totalItemsCount: 0,
onPageClickCallback: () => () => Promise.resolve(),
});
const emit = defineEmits(['selectAllClicked']);
</script>
<style lang="scss">
@ -82,6 +91,10 @@ const props = withDefaults(defineProps<{
height: 52px;
font-size: 0.875rem;
color: #6b7280;
th.icon {
border-top-left-radius: 12px;
}
}
}
@ -118,8 +131,9 @@ const props = withDefaults(defineProps<{
}
.icon {
width: 5%;
width: 50px;
overflow: visible !important;
background: var(--c-grey-1);
}
.table-footer {
@ -155,7 +169,7 @@ const props = withDefaults(defineProps<{
}
}
@media screen and (max-width: 768px) {
@media screen and (max-width: 550px) {
.select {
display: none;

View File

@ -2,11 +2,11 @@
// See LICENSE for copying information.
<template>
<label class="container" @click.stop> <!--don't propagate click event to parent <tr>-->
<label class="container" @click.stop.prevent="selectClicked"> <!--don't propagate click event to parent <tr>-->
<input
id="checkbox" :disabled="disabled" :checked="value"
class="checkmark-input"
type="checkbox" @change="onChange"
type="checkbox"
>
<span class="checkmark" />
</label>
@ -18,23 +18,23 @@ const props = withDefaults(defineProps<{
disabled?: boolean,
}>(), { value: false, disabled: false });
const emit = defineEmits(['checkChange']);
const emit = defineEmits(['selectClicked']);
/**
* Emits value to parent component.
* Emits click event to parent component.
* The parent is responsible for toggling the value prop.
*/
function onChange(event: { target: { checked: boolean } }): void {
emit('checkChange', event.target.checked);
function selectClicked(event: Event): void {
emit('selectClicked', event);
}
</script>
<style scoped lang="scss">
.container {
display: block;
position: relative;
padding-left: 15px;
height: 20px;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
outline: none;
@ -50,8 +50,6 @@ function onChange(event: { target: { checked: boolean } }): void {
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
border: 2px solid rgb(56 75 101 / 40%);

View File

@ -9,7 +9,7 @@
:select-disabled="isProjectOwner"
:selected="itemData.isSelected"
:on-click="(_) => $emit('memberClick', itemData)"
@selectChange="(value) => $emit('selectChange', value)"
@selectClicked="($event) => $emit('selectClicked', $event)"
/>
</template>

View File

@ -38,7 +38,7 @@
:key="key"
:item-data="member"
@memberClick="onMemberCheckChange"
@selectChange="(_) => onMemberCheckChange(member)"
@selectClicked="(_) => onMemberCheckChange(member)"
/>
</template>
</v-table>