Commit 7019471f by Peter Holmberg Committed by GitHub

Migrate: Create new folder page (#22693)

* Migrate create new folder page

* Add header

* Bump react-hook-form

* Form async validatio example

* fix async validation

* Change input size

* async validation on new folder create + documentation

* remove angular things

* fix errors in docs

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
parent 64877baa
......@@ -56,7 +56,7 @@
"react-custom-scrollbars": "4.2.1",
"react-dom": "16.12.0",
"react-highlight-words": "0.11.0",
"react-hook-form": "4.5.3",
"react-hook-form": "5.0.3",
"react-popper": "1.3.3",
"react-storybook-addon-props-combinations": "1.1.0",
"react-table": "7.0.0-rc.15",
......
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { Form } from './Form';
import { Meta, Story, Preview, Props } from "@storybook/addon-docs/blocks";
import { Form } from "./Form";
<Meta title="MDX|Form" component={Form} />
......@@ -62,12 +62,14 @@ Register accepts an object which describes validation rules for a given input:
/>
```
See [Validation](#validation) for examples on validation and validation rules.
#### `errors`
`errors` is an object that contains validation errors of the form. To show error message and invalid input indication in your form, wrap input element with `<Forms.Field ...>` component and pass `invalid` and `error` props to it:
```jsx
<Forms.Field label="Name" invalid={!!errors.name} error='Name is required'>
<Forms.Field label="Name" invalid={!!errors.name} error="Name is required">
<Forms.Input name="name" ref={register({ required: true })} />
</Forms.Field>
```
......@@ -109,6 +111,7 @@ import { Forms } from '@grafana/ui';
)}
</Forms.Form>
```
Note that when using `Forms.InputControl`, it expects the name of the prop that handles input change to be called `onChange`.
If the property is named differently for any specific component, additional `onChangeName` prop has to be provided, specifying the name.
Additionally, the `onChange` arguments passed as an array. Check [react-hook-form docs](https://react-hook-form.com/api/#Controller)
......@@ -182,6 +185,92 @@ const defaultValues: FormDto {
</Forms.Form>
```
### Validation
Validation can be performed either synchronously or asynchronously. What's important here is that the validation function must return either a `boolean` or a `string`.
#### Basic required example
```jsx
<Forms.Form ...>{
({register, errors}) => (
<>
<Forms.Field invalid={!!errors.name} error={errors.name && 'Name is required'}
<Forms.Input
defaultValue={default.name}
name="name"
ref={register({ required: true })}
/>
</>
)}
</Forms.Form>
```
#### Required with synchronous custom validation
One important thing to note is that if you want to provide different error messages for different kind of validation errors you'll need to return a `string` instead of a `boolean`.
```jsx
<Forms.Form ...>{
({register, errors}) => (
<>
<Forms.Field invalid={!!errors.name} error={errors.name?.message }
<Forms.Input
defaultValue={default.name}
name="name"
ref={register({
required: 'Name is required',
validation: v => {
return v !== 'John' && 'Name must be John'
},
)}
/>
</>
)}
</Forms.Form>
```
#### Asynchronous validation
For cases when you might want to validate fields asynchronously (on the backend or via some service) you can provide an asynchronous function to the field.
Consider this function that simulates a call to some service. Remember, if you want to display an error message replace `return true` or `return false` with `return 'your error message'`.
```jsx
validateAsync = (newValue: string) => {
try {
await new Promise<ValidateResult>((resolve, reject) => {
setTimeout(() => {
reject('Something went wrong...');
}, 2000);
});
return true;
} catch (e) {
return false;
}
};
```
```jsx
<Forms.Form ...>{
({register, errors}) => (
<>
<Forms.Field invalid={!!errors.name} error={errors.name?.message}
<Forms.Input
defaultValue={default.name}
name="name"
ref={register({
required: 'Name is required',
validation: async v => {
return await validateAsync(v);
},
)}
/>
</>
)}
</Forms.Form>
```
### Props
<Props of={Form} />
......@@ -14,6 +14,8 @@ import { RadioButtonGroup } from './RadioButtonGroup/RadioButtonGroup';
import { Select } from './Select/Select';
import Forms from './index';
import mdx from './Form.mdx';
import { ValidateResult } from 'react-hook-form';
import { boolean } from '@storybook/addon-knobs';
export default {
title: 'Forms/Test forms',
......@@ -158,3 +160,55 @@ export const defaultValues = () => {
</>
);
};
export const asyncValidation = () => {
const passAsyncValidation = boolean('Pass username validation', true);
return (
<>
<Form
onSubmit={(data: FormDTO) => {
alert('Submitted successfully!');
}}
>
{({ register, control, errors, formState }) =>
(console.log(errors) as any) || (
<>
<Legend>Edit user</Legend>
<Field label="Name" invalid={!!errors.name} error="Username is already taken">
<Input
name="name"
placeholder="Roger Waters"
size="md"
ref={register({ validate: validateAsync(passAsyncValidation) })}
/>
</Field>
<Button type="submit" disabled={formState.isSubmitting}>
Submit
</Button>
</>
)
}
</Form>
</>
);
};
const validateAsync = (shouldPass: boolean) => async () => {
try {
await new Promise<ValidateResult>((resolve, reject) => {
setTimeout(() => {
if (shouldPass) {
resolve();
} else {
reject('Something went wrong...');
}
}, 2000);
});
return true;
} catch (e) {
console.log(e);
return false;
}
};
import React, { useEffect } from 'react';
import { useForm, Mode, OnSubmit, DeepPartial, FormContextValues } from 'react-hook-form';
type FormAPI<T> = Pick<FormContextValues<T>, 'register' | 'errors' | 'control'>;
type FormAPI<T> = Pick<FormContextValues<T>, 'register' | 'errors' | 'control' | 'formState'>;
interface FormProps<T> {
validateOn?: Mode;
......@@ -11,7 +11,7 @@ interface FormProps<T> {
}
export function Form<T>({ defaultValues, onSubmit, children, validateOn = 'onSubmit' }: FormProps<T>) {
const { handleSubmit, register, errors, control, reset, getValues } = useForm<T>({
const { handleSubmit, register, errors, control, reset, getValues, formState } = useForm<T>({
mode: validateOn,
defaultValues,
});
......@@ -20,5 +20,5 @@ export function Form<T>({ defaultValues, onSubmit, children, validateOn = 'onSub
reset({ ...getValues(), ...defaultValues });
}, [defaultValues]);
return <form onSubmit={handleSubmit(onSubmit)}>{children({ register, errors, control })}</form>;
return <form onSubmit={handleSubmit(onSubmit)}>{children({ register, errors, control, formState })}</form>;
}
import { ILocationService, IScope } from 'angular';
import { AppEvents } from '@grafana/data';
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { backendSrv } from 'app/core/services/backend_srv';
import { ValidationSrv } from 'app/features/manage-dashboards';
import { NavModelSrv } from 'app/core/nav_model_srv';
import { promiseToDigest } from '../../core/utils/promiseToDigest';
export default class CreateFolderCtrl {
title = '';
navModel: any;
titleTouched = false;
hasValidationError: boolean;
validationError: any;
/** @ngInject */
constructor(
private $location: ILocationService,
private validationSrv: ValidationSrv,
navModelSrv: NavModelSrv,
private $scope: IScope
) {
this.navModel = navModelSrv.getNav('dashboards', 'manage-dashboards', 0);
}
create() {
if (this.hasValidationError) {
return;
}
promiseToDigest(this.$scope)(
backendSrv.createFolder({ title: this.title }).then((result: any) => {
appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']);
this.$location.url(locationUtil.stripBaseFromUrl(result.url));
})
);
}
titleChanged() {
this.titleTouched = true;
promiseToDigest(this.$scope)(
this.validationSrv
.validateNewFolderName(this.title)
.then(() => {
this.hasValidationError = false;
})
.catch(err => {
this.hasValidationError = true;
this.validationError = err.message;
})
);
}
}
import React, { PureComponent } from 'react';
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { Forms } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { createNewFolder } from '../state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { StoreState } from 'app/types';
import validationSrv from '../../manage-dashboards/services/ValidationSrv';
interface OwnProps {}
interface ConnectedProps {
navModel: NavModel;
}
interface DispatchProps {
createNewFolder: typeof createNewFolder;
}
interface FormModel {
folderName: string;
}
const initialFormModel: FormModel = { folderName: '' };
type Props = OwnProps & ConnectedProps & DispatchProps;
export class NewDashboardsFolder extends PureComponent<Props> {
onSubmit = (formData: FormModel) => {
this.props.createNewFolder(formData.folderName);
};
validateFolderName = (folderName: string) => {
return validationSrv
.validateNewFolderName(folderName)
.then(() => {
return true;
})
.catch(() => {
return 'Folder already exists.';
});
};
render() {
return (
<Page navModel={this.props.navModel}>
<Page.Contents>
<h3>New Dashboard Folder</h3>
<Forms.Form defaultValues={initialFormModel} onSubmit={this.onSubmit}>
{({ register, errors }) => (
<>
<Forms.Field
label="Folder name"
invalid={!!errors.folderName}
error={errors.folderName && errors.folderName.message}
>
<Forms.Input
name="folderName"
ref={register({
required: 'Folder name is required.',
validate: async v => await this.validateFolderName(v),
})}
/>
</Forms.Field>
<Forms.Button type="submit">Create</Forms.Button>
</>
)}
</Forms.Form>
</Page.Contents>
</Page>
);
}
}
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => ({
navModel: getNavModel(state.navIndex, 'manage-dashboards'),
});
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
createNewFolder,
};
export default connect(mapStateToProps, mapDispatchToProps)(NewDashboardsFolder);
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body" ng-cloak>
<h3 class="page-sub-heading">New Dashboard Folder</h3>
<form name="ctrl.saveForm" ng-submit="ctrl.create()" novalidate>
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<label class="gf-form-label width-10">Name</label>
<input type="text" class="gf-form-input" ng-model="ctrl.title" give-focus="true" ng-change="ctrl.titleChanged()" ng-model-options="{ debounce: 400 }" ng-class="{'validation-error': ctrl.nameExists || !ctrl.dash.title}">
<label class="gf-form-label text-success" ng-if="ctrl.titleTouched && !ctrl.hasValidationError">
<i class="fa fa-check"></i>
</label>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.hasValidationError">
<div class="gf-form offset-width-10 gf-form--grow">
<label class="gf-form-label text-warning gf-form-label--grow">
<i class="fa fa-warning"></i>
{{ctrl.validationError}}
</label>
</div>
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-primary width-12" ng-disabled="!ctrl.titleTouched || ctrl.hasValidationError">
Create
</button>
</div>
</form>
</div>
<footer />
......@@ -7,6 +7,7 @@ import { updateLocation, updateNavIndex } from 'app/core/actions';
import { buildNavModel } from './navModel';
import appEvents from 'app/core/app_events';
import { loadFolder, loadFolderPermissions } from './reducers';
import { getBackendSrv } from '@grafana/runtime';
export function getFolderByUid(uid: string): ThunkResult<void> {
return async dispatch => {
......@@ -118,3 +119,12 @@ export function addFolderPermission(newItem: NewDashboardAclItem): ThunkResult<v
await dispatch(getFolderPermissions(folder.uid));
};
}
export function createNewFolder(folderName: string): ThunkResult<void> {
return async dispatch => {
// @ts-ignore
const newFolder = await getBackendSrv().createFolder({ title: folderName });
appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']);
dispatch(updateLocation({ path: newFolder.url }));
};
}
......@@ -2,7 +2,6 @@ import './dashboard_loaders';
import './ReactContainer';
import { applyRouteRegistrationHandlers } from './registry';
// Pages
import CreateFolderCtrl from 'app/features/folders/CreateFolderCtrl';
import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl';
import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportCtrl';
import LdapPage from 'app/features/admin/ldap/LdapPage';
......@@ -159,9 +158,13 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
controllerAs: 'ctrl',
})
.when('/dashboards/folder/new', {
templateUrl: 'public/app/features/folders/partials/create_folder.html',
controller: CreateFolderCtrl,
controllerAs: 'ctrl',
template: '<react-container />',
resolve: {
component: () =>
SafeDynamicImport(
import(/*webpackChunkName: NewDashboardsFolder*/ 'app/features/folders/components/NewDashboardsFolder')
),
},
})
.when('/dashboards/f/:uid/:slug/permissions', {
template: '<react-container />',
......
......@@ -20743,10 +20743,10 @@ react-highlight-words@0.11.0:
highlight-words-core "^1.2.0"
prop-types "^15.5.8"
react-hook-form@4.5.3:
version "4.5.3"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-4.5.3.tgz#3f9abac7bd78eedf0624d02aa9e1f8487d729e18"
integrity sha512-oQB6s3zzXbFwM8xaWEkZJZR+5KD2LwUUYTexQbpdUuFzrfs41Qg0UE3kzfzxG8shvVlzADdkYKLMXqOLWQSS/Q==
react-hook-form@5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.0.3.tgz#106a76148278f54f67be9a8fa61a4bbca531187d"
integrity sha512-6EqRWATbyXTJdtoaUDp6/2WbH9NOaPUAjsygw12nbU1yK6+x12paMJPf1eLxqT1muSvVe2G8BPqdeidqIL7bmg==
react-hot-loader@4.8.0:
version "4.8.0"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment