mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
64877baa82
commit
7019471f3f
@ -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",
|
||||
|
@ -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";
|
||||
|
||||
<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;
|
||||
}
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
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>;
|
||||
}
|
||||
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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<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);
|
@ -1,36 +0,0 @@
|
||||
<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"
|
||||
|
Loading…
Reference in New Issue
Block a user