diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 26b34fb7ea9..2c9e6b09ea9 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/Forms/Form.mdx b/packages/grafana-ui/src/components/Forms/Form.mdx index 1ef5f9e2595..81b5638ee44 100644 --- a/packages/grafana-ui/src/components/Forms/Form.mdx +++ b/packages/grafana-ui/src/components/Forms/Form.mdx @@ -1,5 +1,5 @@ -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"; @@ -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 `` component and pass `invalid` and `error` props to it: ```jsx - + ``` @@ -109,6 +111,7 @@ import { Forms } from '@grafana/ui'; )} ``` + 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 { ``` +### 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 +{ + ({register, errors}) => ( + <> + + > + )} + +``` + +#### 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 +{ + ({register, errors}) => ( + <> + { + return v !== 'John' && 'Name must be John' + }, + )} + /> + > + )} + +``` + +#### 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((resolve, reject) => { + setTimeout(() => { + reject('Something went wrong...'); + }, 2000); + }); + return true; + } catch (e) { + return false; + } +}; +``` + +```jsx +{ + ({register, errors}) => ( + <> + { + return await validateAsync(v); + }, + )} + /> + > + )} + +``` + ### Props diff --git a/packages/grafana-ui/src/components/Forms/Form.story.tsx b/packages/grafana-ui/src/components/Forms/Form.story.tsx index 889488d1928..926990719ea 100644 --- a/packages/grafana-ui/src/components/Forms/Form.story.tsx +++ b/packages/grafana-ui/src/components/Forms/Form.story.tsx @@ -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 ( + <> + { + alert('Submitted successfully!'); + }} + > + {({ register, control, errors, formState }) => + (console.log(errors) as any) || ( + <> + Edit user + + + + + + + Submit + + > + ) + } + + > + ); +}; + +const validateAsync = (shouldPass: boolean) => async () => { + try { + await new Promise((resolve, reject) => { + setTimeout(() => { + if (shouldPass) { + resolve(); + } else { + reject('Something went wrong...'); + } + }, 2000); + }); + return true; + } catch (e) { + console.log(e); + return false; + } +}; diff --git a/packages/grafana-ui/src/components/Forms/Form.tsx b/packages/grafana-ui/src/components/Forms/Form.tsx index 4b726cf2117..f6690b4ced0 100644 --- a/packages/grafana-ui/src/components/Forms/Form.tsx +++ b/packages/grafana-ui/src/components/Forms/Form.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import { useForm, Mode, OnSubmit, DeepPartial, FormContextValues } from 'react-hook-form'; -type FormAPI = Pick, 'register' | 'errors' | 'control'>; +type FormAPI = Pick, 'register' | 'errors' | 'control' | 'formState'>; interface FormProps { validateOn?: Mode; @@ -11,7 +11,7 @@ interface FormProps { } export function Form({ defaultValues, onSubmit, children, validateOn = 'onSubmit' }: FormProps) { - const { handleSubmit, register, errors, control, reset, getValues } = useForm({ + const { handleSubmit, register, errors, control, reset, getValues, formState } = useForm({ mode: validateOn, defaultValues, }); @@ -20,5 +20,5 @@ export function Form({ defaultValues, onSubmit, children, validateOn = 'onSub reset({ ...getValues(), ...defaultValues }); }, [defaultValues]); - return {children({ register, errors, control })}; + return {children({ register, errors, control, formState })}; } diff --git a/public/app/features/folders/CreateFolderCtrl.ts b/public/app/features/folders/CreateFolderCtrl.ts deleted file mode 100644 index bc7d5f8fb47..00000000000 --- a/public/app/features/folders/CreateFolderCtrl.ts +++ /dev/null @@ -1,56 +0,0 @@ -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; - }) - ); - } -} diff --git a/public/app/features/folders/components/NewDashboardsFolder.tsx b/public/app/features/folders/components/NewDashboardsFolder.tsx new file mode 100644 index 00000000000..12d2fa91b01 --- /dev/null +++ b/public/app/features/folders/components/NewDashboardsFolder.tsx @@ -0,0 +1,84 @@ +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 { + 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 ( + + + New Dashboard Folder + + {({ register, errors }) => ( + <> + + await this.validateFolderName(v), + })} + /> + + Create + > + )} + + + + ); + } +} + +const mapStateToProps: MapStateToProps = state => ({ + navModel: getNavModel(state.navIndex, 'manage-dashboards'), +}); + +const mapDispatchToProps: MapDispatchToProps = { + createNewFolder, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(NewDashboardsFolder); diff --git a/public/app/features/folders/partials/create_folder.html b/public/app/features/folders/partials/create_folder.html deleted file mode 100644 index 3cd165121ab..00000000000 --- a/public/app/features/folders/partials/create_folder.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - New Dashboard Folder - - - - - Name - - - - - - - - - - - - {{ctrl.validationError}} - - - - - - - Create - - - - - - - diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts index 188f6deb38a..ad9a7106a17 100644 --- a/public/app/features/folders/state/actions.ts +++ b/public/app/features/folders/state/actions.ts @@ -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 { return async dispatch => { @@ -118,3 +119,12 @@ export function addFolderPermission(newItem: NewDashboardAclItem): ThunkResult { + return async dispatch => { + // @ts-ignore + const newFolder = await getBackendSrv().createFolder({ title: folderName }); + appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); + dispatch(updateLocation({ path: newFolder.url })); + }; +} diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 79fed580568..df4222dbd53 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -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: '', + resolve: { + component: () => + SafeDynamicImport( + import(/*webpackChunkName: NewDashboardsFolder*/ 'app/features/folders/components/NewDashboardsFolder') + ), + }, }) .when('/dashboards/f/:uid/:slug/permissions', { template: '', diff --git a/yarn.lock b/yarn.lock index 46285721eee..29f685e3831 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"