Skip to content

Commit

Permalink
Add 'Create Bucket' form (via S3-compat API)
Browse files Browse the repository at this point in the history
  • Loading branch information
alfonsomthd committed Oct 3, 2024
1 parent eb8baf6 commit 9d783fb
Show file tree
Hide file tree
Showing 11 changed files with 333 additions and 18 deletions.
8 changes: 8 additions & 0 deletions locales/en/plugin__odf-console.json
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,14 @@
"Create via Object Bucket Claim": "Create via Object Bucket Claim",
"Ideal for Kubernetes environments providing a more abstracted approach to managing storage resources and leveraging dynamic provisioning.": "Ideal for Kubernetes environments providing a more abstracted approach to managing storage resources and leveraging dynamic provisioning.",
"OBC references a StorageClass that uses a provisioner to interact with the S3 API and create the bucket. Kubernetes binds the OBC to the bucket, making it accessible to applications.": "OBC references a StorageClass that uses a provisioner to interact with the S3 API and create the bucket. Kubernetes binds the OBC to the bucket, making it accessible to applications.",
"Bucket Name": "Bucket Name",
"my-bucket": "my-bucket",
"A unique name for your bucket.": "A unique name for your bucket.",
"Tags": "Tags",
"Use different criteria for tagging your bucket.": "Use different criteria for tagging your bucket.",
"No tags are attached to this bucket.": "No tags are attached to this bucket.",
"Add tag": "Add tag",
"Value (optional)": "Value (optional)",
"Download objects": "Download objects",
"Delete objects": "Delete objects",
"Actions": "Actions",
Expand Down
4 changes: 3 additions & 1 deletion packages/odf/components/mcg/CreateObjectBucketClaim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,12 @@ export const CreateOBCForm: React.FC<CreateOBCFormProps> = (props) => {
};

type CreateOBCProps = {
formClassName?: string;
showNamespaceSelector?: boolean;
};

export const CreateOBC: React.FC<CreateOBCProps> = ({
formClassName = '',
showNamespaceSelector = false,
}) => {
const { t } = useCustomTranslation();
Expand Down Expand Up @@ -500,7 +502,7 @@ export const CreateOBC: React.FC<CreateOBCProps> = ({
// ToDo (Sanjal): Update the non-admin "Role" to a "ClusterRole", then read list of NooBaa/BucketClasses across all namespaces.
return (
<NamespaceSafetyBox allowFallback>
<Form onSubmit={handleSubmit(save)}>
<Form onSubmit={handleSubmit(save)} className={formClassName}>
{showNamespaceSelector && (
<FormGroupController
name="ns-dropdown"
Expand Down
13 changes: 11 additions & 2 deletions packages/odf/components/s3-browser/create-bucket/CreateBucket.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as React from 'react';
import { CreateOBC } from '@odf/core/components/mcg/CreateObjectBucketClaim';
import CreateBucketForm from '@odf/core/components/s3-browser/create-bucket/CreateBucketForm';
import { NoobaaS3Provider } from '@odf/core/components/s3-browser/noobaa-context';
import { useCustomTranslation } from '@odf/shared';
import { Alert, FormGroup, Tile } from '@patternfly/react-core';

Expand Down Expand Up @@ -61,10 +63,17 @@ const CreateBucket: React.FC<{}> = () => {
)}
className="pf-v5-u-mb-md"
/>
<CreateOBC showNamespaceSelector={true} />
<CreateOBC
formClassName="pf-v5-u-w-50"
showNamespaceSelector={true}
/>
</>
)}
{/* @TODO: implement S3 form. */}
{method === CreationMethod.S3 && (
<NoobaaS3Provider>
<CreateBucketForm />
</NoobaaS3Provider>
)}
</div>
</>
);
Expand Down
173 changes: 173 additions & 0 deletions packages/odf/components/s3-browser/create-bucket/CreateBucketForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import * as React from 'react';
import { PutBucketTaggingCommandInput } from '@aws-sdk/client-s3';
import useS3BucketFormValidation from '@odf/core/components/s3-browser/create-bucket/useS3BucketFormValidation';
import { NoobaaS3Context } from '@odf/core/components/s3-browser/noobaa-context';
import { BUCKETS_BASE_ROUTE, LIST_BUCKET } from '@odf/core/constants';
import {
ButtonBar,
formSettings,
TextInputWithFieldRequirements,
useCustomTranslation,
useYupValidationResolver,
} from '@odf/shared';
import { LazyNameValueEditor } from '@odf/shared/utils/NameValueEditor';
import cn from 'classnames';
import * as _ from 'lodash-es';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import useSWR from 'swr';
import {
ActionGroup,
Alert,
Button,
Form,
FormGroup,
} from '@patternfly/react-core';
import { TagIcon } from '@patternfly/react-icons';
import './create-bucket-form.scss';

type FormData = {
bucketName: string;
};

const CreateBucketForm: React.FC<{}> = () => {
const { t } = useCustomTranslation();
const navigate = useNavigate();
const [inProgress, setInProgress] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState('');
const [tagsData, setTagsData] = React.useState<string[][]>([]);
const { noobaaS3 } = React.useContext(NoobaaS3Context);
const { data, error, isLoading } = useSWR(LIST_BUCKET, () =>
noobaaS3.listBuckets()
);
const buckets = data && !error && !isLoading ? data.Buckets : [];
const { bucketFormSchema, fieldRequirements } =
useS3BucketFormValidation(buckets);
const resolver = useYupValidationResolver(bucketFormSchema);

const {
control,
handleSubmit,
formState: { isValid, isSubmitted },
} = useForm({
...formSettings,
resolver,
});

const save = async (formData: FormData) => {
setInProgress(true);
const { bucketName } = formData;
try {
await noobaaS3.createBucket({ Bucket: bucketName });
} catch ({ name, message }) {
setErrorMessage(`Error while creating bucket: ${name}: ${message}`);
}
if (!errorMessage) {
// Update bucket tags: any error here shouldn't prevent redirection
// as the bucket has been created successfully.
try {
const tagSet: PutBucketTaggingCommandInput['Tagging']['TagSet'] =
tagsData
.filter((pair: string[]) => !_.isEmpty(pair[0]))
.map((pair: string[]) => ({ Key: pair[0], Value: pair[1] }));
if (!_.isEmpty(tagSet)) {
await noobaaS3.updateBucketTags({
Bucket: bucketName,
Tagging: { TagSet: tagSet },
});
}
} catch ({ name, message }) {
// @TODO: add a toast warning that is visible after redirection to bucket details.
// eslint-disable-next-line no-console
console.error(`Error while updating bucket tags: ${name}: ${message}`);
}
}

if (errorMessage) {
setInProgress(false);
return;
}
navigate(`${BUCKETS_BASE_ROUTE}/${bucketName}`);
};

return (
<Form onSubmit={handleSubmit(save)} className="pf-v5-u-w-50">
<TextInputWithFieldRequirements
control={control}
fieldRequirements={fieldRequirements}
popoverProps={{
headerContent: t('Name requirements'),
footerContent: `${t('Example')}: my-bucket`,
}}
formGroupProps={{
label: t('Bucket Name'),
fieldId: 'bucket-name',
isRequired: true,
className: 'control-label',
}}
textInputProps={{
id: 'bucket-name',
name: 'bucketName',
className: 'pf-v5-c-form-control',
type: 'text',
placeholder: t('my-bucket'),
'aria-describedby': 'bucket-name-help',
'data-test': 'bucket-name',
}}
helperText={t('A unique name for your bucket.')}
/>
<FormGroup
label={t('Tags')}
labelInfo={t('Use different criteria for tagging your bucket.')}
className={cn('odf-create-s3-bucket-form__tags', {
'odf-create-s3-bucket-form__tags--empty': _.isEmpty(tagsData),
})}
>
{_.isEmpty(tagsData) && (
<div className="pf-v5-u-disabled-color-100 pf-v5-u-mr-sm">
{t('No tags are attached to this bucket.')}
</div>
)}
<LazyNameValueEditor
className="pf-v5-u-font-weight-bold pf-v5-u-font-size-sm"
addString={t('Add tag')}
valueString={t('Value (optional)')}
nameValuePairs={tagsData}
updateParentData={({ nameValuePairs }) => {
setTagsData(nameValuePairs);
}}
hideHeaderWhenNoItems={true}
IconComponent={TagIcon}
/>
</FormGroup>
{!isValid && isSubmitted && (
<Alert
variant="danger"
isInline
title={t('Address form errors to proceed')}
/>
)}
<ButtonBar errorMessage={errorMessage} inProgress={inProgress}>
<ActionGroup className="pf-v5-c-form">
<Button
id="create-s3-bucket-btn"
type="submit"
variant="primary"
data-test="obc-create"
>
{t('Create')}
</Button>
<Button
onClick={() => navigate(-1)}
type="button"
variant="secondary"
>
{t('Cancel')}
</Button>
</ActionGroup>
</ButtonBar>
</Form>
);
};

export default CreateBucketForm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.odf-create-s3-bucket-form__tags {
// Display 'Tags' label description under the title.
.pf-v5-c-form__group-label {
display: block;
}
.pf-v5-c-form__group-label-info {
margin-left: 0;
}
}

.odf-create-s3-bucket-form__tags--empty {
// Align NameValueEditor icon with 'no tags' text when only icon is shown.
.pf-v5-c-form__group-control {
display: flex;
align-items: center;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as React from 'react';
import { ListBucketsCommandOutput } from '@aws-sdk/client-s3';
import {
BUCKET_NAME_MAX_LENGTH,
BUCKET_NAME_MIN_LENGTH,
} from '@odf/core/constants';
import { fieldRequirementsTranslations } from '@odf/shared/constants';
import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook';
import validationRegEx from '@odf/shared/utils/validation';
import * as _ from 'lodash-es';
import * as Yup from 'yup';

export type S3BucketFormSchema = Yup.ObjectSchema<{
bucketName: Yup.StringSchema;
}>;

export type S3BucketFormValidation = {
bucketFormSchema: S3BucketFormSchema;
fieldRequirements: string[];
};

const useS3BucketFormValidation = (
buckets: ListBucketsCommandOutput['Buckets']
): S3BucketFormValidation => {
const { t } = useCustomTranslation();

return React.useMemo(() => {
const existingNames = !_.isEmpty(buckets)
? buckets.map((bucket) => bucket.Name)
: [];

const fieldRequirements = [
fieldRequirementsTranslations.maxChars(t, BUCKET_NAME_MAX_LENGTH),
fieldRequirementsTranslations.minChars(t, BUCKET_NAME_MIN_LENGTH),
fieldRequirementsTranslations.startAndEndName(t),
fieldRequirementsTranslations.alphaNumericPeriodAdnHyphen(t),
fieldRequirementsTranslations.cannotBeUsedBefore(t),
];

const bucketFormSchema = Yup.object({
bucketName: Yup.string()
.max(BUCKET_NAME_MAX_LENGTH, fieldRequirements[0])
.min(BUCKET_NAME_MIN_LENGTH, fieldRequirements[1])
.matches(
validationRegEx.startAndEndsWithAlphanumerics,
fieldRequirements[2]
)
.matches(
validationRegEx.alphaNumericsPeriodsHyphensNonConsecutive,
fieldRequirements[3]
)
.test(
'unique-name',
fieldRequirements[4],
(value: string) => !existingNames.includes(value)
)
.transform((value: string) => (!!value ? value : '')),
});

return { bucketFormSchema, fieldRequirements };
}, [buckets, t]);
};

export default useS3BucketFormValidation;
3 changes: 3 additions & 0 deletions packages/odf/constants/s3-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const DELIMITER = '/';
export const PREFIX = 'prefix';
export const MAX_KEYS = 300;

export const BUCKET_NAME_MAX_LENGTH = 63;
export const BUCKET_NAME_MIN_LENGTH = 3;

export const BUCKETS_BASE_ROUTE = '/odf/object-storage/buckets';

// key to be used by SWR for caching particular API call
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/constants/fieldRequirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { TFunction } from 'i18next';
export const maxChars = (t: TFunction, max: number) =>
t(`No more than ${max} characters`);

export const minChars = (t: TFunction, min: number) =>
t(`No less than ${min} characters`);

export const startAndEndName = (t: TFunction) =>
t('Starts and ends with a lowercase letter or number');

Expand All @@ -16,6 +19,7 @@ export const uniqueName = (t: TFunction, fieldName: string) =>

export const fieldRequirementsTranslations = {
maxChars,
minChars,
startAndEndName,
alphaNumericPeriodAdnHyphen,
cannotBeUsedBefore,
Expand Down
15 changes: 14 additions & 1 deletion packages/shared/src/s3/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ import {
S3Client,
ListBucketsCommand,
ListObjectsV2Command,
CreateBucketCommand,
PutBucketTaggingCommand,
} from '@aws-sdk/client-s3';
import { ListBuckets, ListObjectsV2 } from './types';
import {
CreateBucket,
ListBuckets,
ListObjectsV2,
UpdateBucketTags,
} from './types';

export class S3Commands {
private s3Client: S3Client;
Expand All @@ -22,9 +29,15 @@ export class S3Commands {
}

// Bucket command members
createBucket: CreateBucket = (input) =>
this.s3Client.send(new CreateBucketCommand(input));

listBuckets: ListBuckets = (input) =>
this.s3Client.send(new ListBucketsCommand(input));

updateBucketTags: UpdateBucketTags = (input) =>
this.s3Client.send(new PutBucketTaggingCommand(input));

// Object command members
listObjects: ListObjectsV2 = (input) =>
this.s3Client.send(new ListObjectsV2Command(input));
Expand Down
Loading

0 comments on commit 9d783fb

Please sign in to comment.