Skip to content

Commit

Permalink
Merge pull request #150 from bcgov/sub
Browse files Browse the repository at this point in the history
Add sub-folder creation support
  • Loading branch information
kyle1morel authored Dec 18, 2023
2 parents 47caf93 + 231695a commit 096b9f4
Show file tree
Hide file tree
Showing 10 changed files with 199 additions and 29 deletions.
5 changes: 5 additions & 0 deletions frontend/src/assets/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,8 @@ a:visited {
a.p-tabview-header-action {
text-decoration: none;
}

/* forms */
.field span[role="alert"] {
color: $bcbox-error;
}
1 change: 1 addition & 0 deletions frontend/src/assets/variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ $bcbox-primary: #036;
$bcbox-link-text: #1a5a96;
$bcbox-link-text-hover: #00f;
$bcbox-outline-on-primary: #fff;
$bcbox-error: #d8292f;

// highlighted sections, table rows
$bcbox-highlight-background: #d9e1e8;
Expand Down
129 changes: 129 additions & 0 deletions frontend/src/components/bucket/BucketChildConfig.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { Form } from 'vee-validate';
import { ref } from 'vue';
import { object, string } from 'yup';
import TextInput from '@/components/form/TextInput.vue';
import { Button, Dialog, Message, useToast } from '@/lib/primevue';
import { useAuthStore, useBucketStore } from '@/store';
import type { Ref } from 'vue';
import type { Bucket } from '@/types';
// Props
const props = defineProps<{
parentBucket: Bucket;
}>();
// Store
const bucketStore = useBucketStore();
const { getUserId } = storeToRefs(useAuthStore());
// Form validation
const validationMessages: Ref<Array<string>> = ref([]);
const schema = object({
bucketName: string().required().max(255).label('Folder display name'),
subKey: string()
.required()
.matches(/^[^/\\]+$/, 'Folder sub-path must not contain back or forward slashes')
.label('Folder sub-path')
});
// Actions
const toast = useToast();
const dialogIsVisible: Ref<boolean> = ref(false);
const showDialog = (x: boolean) => {
dialogIsVisible.value = x;
};
const onSubmit = async (values: any) => {
try {
const formData = {
bucketName: values.bucketName.trim(),
subKey: values.subKey.trim()
};
// create bucket
await bucketStore.createBucketChild(props.parentBucket.bucketId, formData.subKey, formData.bucketName);
// refresh stores
await bucketStore.fetchBuckets({ userId: getUserId.value, objectPerms: true });
showDialog(false);
toast.success('Adding Folder to a bucket', 'Folder configuration successful');
} catch (error: any) {
validationMessages.value = [];
validationMessages.value.push(error.response.data.detail);
}
};
const onCancel = () => {
showDialog(false);
};
</script>

<template>
<Button
v-tooltip.bottom="'Add folder to a bucket'"
class="p-button-lg p-button-text"
aria-label="Add folder to a bucket"
@click="showDialog(true)"
>
<font-awesome-icon icon="fa-solid fa-folder-plus" />
</Button>

<Dialog
class="bcbox-info-dialog"
:style="{ width: '50vw' }"
:modal="true"
:visible="dialogIsVisible"
@update:visible="showDialog(false)"
>
<template #header>
<font-awesome-icon
icon="fas fa-cog"
fixed-width
/>
<span class="p-dialog-title">Add folder to bucket</span>
</template>

<h3 class="bcbox-info-dialog-subhead mb-0">{{ props.parentBucket.bucketName }}</h3>
<ul class="mt-0 pl-3">
<li>Sets a folder to appear within a bucket</li>
</ul>
<Form
:validation-schema="schema"
@submit="onSubmit"
>
<Message
v-for="msg of validationMessages"
:key="msg"
severity="error"
>
{{ msg }}
</Message>
<TextInput
name="subKey"
label="Folder sub-path"
placeholder="my-documents"
help-text="The directory will be created if it does not already exist.
Sub-paths are supported using '/' between levels.
This value cannot be changed after it is set."
/>
<TextInput
name="bucketName"
label="Folder display name *"
placeholder="My Documents"
help-text="Your custom display name for the bucket - any name as you would like to see it listed in BCBox."
autofocus
/>
<Button
class="p-button mt-2 mr-2"
label="Save"
type="submit"
/>
<Button
class="p-button-outlined mt-2"
label="Cancel"
@click="onCancel"
/>
</Form>
</Dialog>
</template>
1 change: 0 additions & 1 deletion frontend/src/components/bucket/BucketConfigForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ const onSubmit = async (values: any) => {
await bucketStore.fetchBuckets({ userId: getUserId.value, objectPerms: true });
// trim trailing "//", if present
// (modifying getBucketPath() instead seems to break nested bucket display [PR-146])
const currBucketPath = getBucketPath(initialValues as Bucket).endsWith('//')
? getBucketPath(initialValues as Bucket).slice(0, -1)
: getBucketPath(initialValues as Bucket);
Expand Down
22 changes: 9 additions & 13 deletions frontend/src/components/bucket/BucketTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { storeToRefs } from 'pinia';
import { ref, watch } from 'vue';
import { BucketPermission, BucketTableBucketName } from '@/components/bucket';
import { BucketChildConfig, BucketPermission, BucketTableBucketName } from '@/components/bucket';
import { Spinner } from '@/components/layout';
import { SyncButton } from '@/components/common';
import { Button, Column, Dialog, TreeTable, useConfirm } from '@/lib/primevue';
Expand Down Expand Up @@ -67,13 +67,6 @@ async function deleteBucket(bucketId: string) {
await bucketStore.fetchBuckets({ userId: getUserId.value, objectPerms: true });
}
/** Get the full path to the first part of its key */
function getFirstKeyPartPath(node: BucketTreeNode): string {
const parts = node.data.key.split(DELIMITER).filter((part) => part);
return `${node.data.endpoint}/${node.data.bucket}/${parts[0]}`;
}
/**
* Finds the nearest indirect path to node \
* Assumes the endpointMap paths have been pre sorted
Expand Down Expand Up @@ -197,19 +190,18 @@ watch(getBuckets, () => {
} else {
if (node.data.key !== '/') {
// Top level bucket not at root so create dummy hierarchy to reach it
const rootFullPath = getFirstKeyPartPath(node);
const rootKey = node.data.key.split(DELIMITER).filter((part) => part)[0];
const rootFullPath = `${node.data.endpoint}/${node.data.bucket}//`;
const dummyRootNode: BucketTreeNode = {
key: rootFullPath,
data: {
accessKeyId: '',
active: false,
bucket: node.data.bucket,
bucketId: '',
bucketName: rootKey,
bucketName: node.data.bucket,
dummy: true,
endpoint: node.data.endpoint,
key: rootKey,
key: '/',
region: '',
secretAccessKey: ''
},
Expand Down Expand Up @@ -296,10 +288,14 @@ watch(getBuckets, () => {
header="Actions"
header-class="text-right"
body-class="action-buttons"
style="width: 250px"
style="width: 280px"
>
<template #body="{ node }">
<span v-if="!node.data.dummy">
<BucketChildConfig
v-if="permissionStore.isBucketActionAllowed(node.data.bucketId, getUserId, Permissions.MANAGE)"
:parent-bucket="node.data"
/>
<Button
v-if="permissionStore.isBucketActionAllowed(node.data.bucketId, getUserId, Permissions.UPDATE)"
v-tooltip.bottom="'Configure bucket'"
Expand Down
36 changes: 21 additions & 15 deletions frontend/src/components/bucket/BucketTableBucketName.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { RouteNames } from '@/utils/constants';
import { getBucketPath, getLastSegment } from '@/utils/utils';
import type { BucketTreeNode } from '@/types';
Expand All @@ -15,29 +16,34 @@ const props = withDefaults(defineProps<Props>(), {});
<span v-if="props.node.data.dummy">
{{ props.node.data.bucketName }}
<span
v-if="node.isRoot"
class="bucket-subtext pl-1"
v-if="props.node.data.key === '/'"
class="pl-2 text-xs"
>
{{ node.data.bucket }}
{{ props.node.data.bucketName }}
</span>
<span
v-else
class="pl-2 text-xs"
>
{{ '/' + props.node.data.bucketName }}
</span>
</span>

<span v-else>
<router-link :to="{ name: RouteNames.LIST_OBJECTS, query: { bucketId: props.node.data.bucketId } }">
{{ node.data.bucketName }}
{{ props.node.data.bucketName }}
</router-link>
<span
v-if="node.isRoot"
class="bucket-subtext pl-2"
v-if="props.node.data.key === '/'"
class="pl-2 text-xs"
>
{{ node.data.bucket }}
{{ getLastSegment(getBucketPath(props.node.data)) }}
</span>
<span
v-else
class="pl-2 text-xs"
>
{{ '/' + getLastSegment(getBucketPath(props.node.data)) }}
</span>
</span>
</template>

<style scoped lang="scss">
.bucket-subtext {
font-size: small;
color: gray;
font-style: italic;
}
</style>
1 change: 1 addition & 0 deletions frontend/src/components/bucket/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as BucketChildConfig } from './BucketChildConfig.vue';
export { default as BucketConfigForm } from './BucketConfigForm.vue';
export { default as BucketList } from './BucketList.vue';
export { default as BucketPermission } from './BucketPermission.vue';
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/services/bucketService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export default {
return comsAxios().put(`${BUCKET_PATH}`, data);
},

/**
* @function createBucketChild
* Creates a bucket
* @param {string} parentBucketId ID of parent COMS 'bucket'
* @param {string} subKey 'sub-folder' name (last part of the key)
* @param {string} bucketName Display name for the mapped sub-folder
* @returns {Promise} An axios response
*/
createBucketChild(parentBucketId: string, subKey: string, bucketName: string) {
return comsAxios().put(`${BUCKET_PATH}/${parentBucketId}/child`, { subKey, bucketName });
},

/**
* @function deleteBucket
* Deletes a bucket
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/store/bucketStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export const useBucketStore = defineStore('bucket', () => {
}
}

async function createBucketChild(parentBucketId: string, subKey: string, bucketName: string) {
try {
appStore.beginIndeterminateLoading();
return await bucketService.createBucketChild(parentBucketId, subKey, bucketName);
} finally {
appStore.endIndeterminateLoading();
}
}

async function deleteBucket(bucketId: string) {
try {
appStore.beginIndeterminateLoading();
Expand Down Expand Up @@ -120,6 +129,7 @@ export const useBucketStore = defineStore('bucket', () => {

// Actions
createBucket,
createBucketChild,
deleteBucket,
fetchBuckets,
findBucketById,
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,14 @@ export function setDispositionHeader(filename: string) {
export function getBucketPath(bucket: Bucket): string {
return `${bucket.endpoint}/${bucket.bucket}/${bucket.key}`;
}

/**
* @function getLastSegment
* Returns the last segment of a path, ignoring trailing slashes
* @param {string} path full path (eg: http://abc.com/bucket/)
* @returns {string} last segment of a path (eg: bucket)
*/
export function getLastSegment(path: string) {
const p = path.replace(/\/+$/, '');
return p.slice(p.lastIndexOf('/') + 1);
}

0 comments on commit 096b9f4

Please sign in to comment.