From b3721a85bbb39a252d9cd62933d787d5a606ae32 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 9 Jul 2019 09:27:39 -0300 Subject: [PATCH] Migrate Query Snippets to React (#3627) --- .../components/dynamic-form/DynamicForm.jsx | 2 + .../items-list/components/ItemsTable.jsx | 8 +- client/app/components/proptypes.js | 1 + .../query-snippets/QuerySnippetDialog.jsx | 88 +++++++ .../query-snippets/QuerySnippetsList.jsx | 233 ++++++++++++++++++ .../query-snippets/QuerySnippetsList.less | 10 + client/app/pages/query-snippets/edit.html | 31 --- client/app/pages/query-snippets/edit.js | 74 ------ client/app/pages/query-snippets/list.html | 42 ---- client/app/pages/query-snippets/list.js | 33 --- client/app/services/navigateTo.js | 13 +- client/app/services/policy/DefaultPolicy.js | 4 + 12 files changed, 356 insertions(+), 183 deletions(-) create mode 100644 client/app/components/query-snippets/QuerySnippetDialog.jsx create mode 100644 client/app/pages/query-snippets/QuerySnippetsList.jsx create mode 100644 client/app/pages/query-snippets/QuerySnippetsList.less delete mode 100644 client/app/pages/query-snippets/edit.html delete mode 100644 client/app/pages/query-snippets/edit.js delete mode 100644 client/app/pages/query-snippets/list.html delete mode 100644 client/app/pages/query-snippets/list.js diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index b0ab61b1b0..382e5606c6 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -174,6 +174,8 @@ class DynamicForm extends React.Component { return field.content; } else if (type === 'number') { return getFieldDecorator(name, options)(); + } else if (type === 'textarea') { + return getFieldDecorator(name, options)(); } return getFieldDecorator(name, options)(); } diff --git a/client/app/components/items-list/components/ItemsTable.jsx b/client/app/components/items-list/components/ItemsTable.jsx index 200c9c56c1..565f08d661 100644 --- a/client/app/components/items-list/components/ItemsTable.jsx +++ b/client/app/components/items-list/components/ItemsTable.jsx @@ -6,7 +6,7 @@ import Table from 'antd/lib/table'; import { FavoritesControl } from '@/components/FavoritesControl'; import { TimeAgo } from '@/components/TimeAgo'; import { durationHumanize } from '@/filters'; -import { formatDateTime } from '@/filters/datetime'; +import { formatDate, formatDateTime } from '@/filters/datetime'; // `this` refers to previous function in the chain (`Columns.***`). // Adds `sorter: true` field to column definition @@ -35,6 +35,11 @@ export const Columns = { ), }, overrides); }, + date(overrides) { + return extend({ + render: text => formatDate(text), + }, overrides); + }, dateTime(overrides) { return extend({ render: text => formatDateTime(text), @@ -59,6 +64,7 @@ export const Columns = { }, }; +Columns.date.sortable = sortable; Columns.dateTime.sortable = sortable; Columns.duration.sortable = sortable; Columns.timeAgo.sortable = sortable; diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index 05b585904d..4496525f8d 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -36,6 +36,7 @@ export const Field = PropTypes.shape({ title: PropTypes.string, type: PropTypes.oneOf([ 'text', + 'textarea', 'email', 'password', 'number', diff --git a/client/app/components/query-snippets/QuerySnippetDialog.jsx b/client/app/components/query-snippets/QuerySnippetDialog.jsx new file mode 100644 index 0000000000..fbe0eade9d --- /dev/null +++ b/client/app/components/query-snippets/QuerySnippetDialog.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { get } from 'lodash'; +import Button from 'antd/lib/button'; +import Modal from 'antd/lib/modal'; +import DynamicForm from '@/components/dynamic-form/DynamicForm'; +import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; + +class QuerySnippetDialog extends React.Component { + static propTypes = { + dialog: DialogPropType.isRequired, + querySnippet: PropTypes.object, // eslint-disable-line react/forbid-prop-types + readOnly: PropTypes.bool, + onSubmit: PropTypes.func.isRequired, + }; + + static defaultProps = { + querySnippet: null, + readOnly: false, + } + + constructor(props) { + super(props); + this.state = { saving: false }; + } + + handleSubmit = (values, successCallback, errorCallback) => { + const { querySnippet, dialog, onSubmit } = this.props; + const querySnippetId = get(querySnippet, 'id'); + + this.setState({ saving: true }); + onSubmit(querySnippetId ? { id: querySnippetId, ...values } : values).then(() => { + dialog.close(); + successCallback('Saved.'); + }).catch(() => { + this.setState({ saving: false }); + errorCallback('Failed saving snippet.'); + }); + }; + + render() { + const { saving } = this.state; + const { querySnippet, dialog, readOnly } = this.props; + const isEditing = !!get(querySnippet, 'id'); + + const formFields = [ + { name: 'trigger', title: 'Trigger', type: 'text', required: true, autoFocus: !isEditing }, + { name: 'description', title: 'Description', type: 'text' }, + { name: 'snippet', + title: 'Snippet', + type: 'textarea', + required: true, + props: { autosize: { minRows: 3, maxRows: 6 } } }, + ].map(field => ({ ...field, readOnly, initialValue: get(querySnippet, field.name, '') })); + + return ( + {readOnly ? 'Close' : 'Cancel'} + ), ( + !readOnly && ( + + ) + )]} + > + + + ); + } +} + +export default wrapDialog(QuerySnippetDialog); diff --git a/client/app/pages/query-snippets/QuerySnippetsList.jsx b/client/app/pages/query-snippets/QuerySnippetsList.jsx new file mode 100644 index 0000000000..a9f57ddfb2 --- /dev/null +++ b/client/app/pages/query-snippets/QuerySnippetsList.jsx @@ -0,0 +1,233 @@ +import { get } from 'lodash'; +import React from 'react'; +import { react2angular } from 'react2angular'; + +import Button from 'antd/lib/button'; +import Modal from 'antd/lib/modal'; +import PromiseRejectionError from '@/lib/promise-rejection-error'; +import { Paginator } from '@/components/Paginator'; +import QuerySnippetDialog from '@/components/query-snippets/QuerySnippetDialog'; + +import { wrap as liveItemsList, ControllerType } from '@/components/items-list/ItemsList'; +import { ResourceItemsSource } from '@/components/items-list/classes/ItemsSource'; +import { StateStorage } from '@/components/items-list/classes/StateStorage'; + +import LoadingState from '@/components/items-list/components/LoadingState'; +import ItemsTable, { Columns } from '@/components/items-list/components/ItemsTable'; + +import { QuerySnippet } from '@/services/query-snippet'; +import navigateTo from '@/services/navigateTo'; +import settingsMenu from '@/services/settingsMenu'; +import { currentUser } from '@/services/auth'; +import { policy } from '@/services/policy'; +import notification from '@/services/notification'; +import { routesToAngularRoutes } from '@/lib/utils'; +import './QuerySnippetsList.less'; + +const canEditQuerySnippet = querySnippet => (currentUser.isAdmin || currentUser.id === get(querySnippet, 'user.id')); + +class QuerySnippetsList extends React.Component { + static propTypes = { + controller: ControllerType.isRequired, + }; + + listColumns = [ + Columns.custom.sortable((text, querySnippet) => ( +
+ this.showSnippetDialog(querySnippet)}> + {querySnippet.trigger} + +
+ ), { + title: 'Trigger', + field: 'trigger', + className: 'text-nowrap', + }), + Columns.custom.sortable(text => text, { + title: 'Description', + field: 'description', + className: 'text-nowrap', + }), + Columns.custom(snippet => ( + + {snippet} + + ), { + title: 'Snippet', + field: 'snippet', + }), + Columns.avatar({ field: 'user', className: 'p-l-0 p-r-0' }, name => `Created by ${name}`), + Columns.date.sortable({ + title: 'Created At', + field: 'created_at', + className: 'text-nowrap', + width: '1%', + }), + Columns.custom((text, querySnippet) => canEditQuerySnippet(querySnippet) && ( + + ), { + width: '1%', + }), + ]; + + componentDidMount() { + const { isNewOrEditPage, querySnippetId } = this.props.controller.params; + + if (isNewOrEditPage) { + if (querySnippetId === 'new') { + if (policy.isCreateQuerySnippetEnabled()) { + this.showSnippetDialog(); + } else { + navigateTo('/query_snippets'); + } + } else { + QuerySnippet.get({ id: querySnippetId }).$promise + .then(this.showSnippetDialog) + .catch((error = {}) => { + // ANGULAR_REMOVE_ME This code is related to Angular's HTTP services + if (error.status && error.data) { + error = new PromiseRejectionError(error); + } + this.props.controller.handleError(error); + }); + } + } + } + + saveQuerySnippet = querySnippet => QuerySnippet.save(querySnippet).$promise; + + deleteQuerySnippet = (event, querySnippet) => { + Modal.confirm({ + title: 'Delete Query Snippet', + content: 'Are you sure you want to delete this query snippet?', + okText: 'Yes', + okType: 'danger', + cancelText: 'No', + onOk: () => { + querySnippet.$delete(() => { + notification.success('Query snippet deleted successfully.'); + this.props.controller.update(); + }, () => { + notification.error('Failed deleting query snippet.'); + }); + }, + }); + } + + showSnippetDialog = (querySnippet = null) => { + const canSave = !querySnippet || canEditQuerySnippet(querySnippet); + navigateTo('/query_snippets/' + get(querySnippet, 'id', 'new'), true, false); + QuerySnippetDialog.showModal({ + querySnippet, + onSubmit: this.saveQuerySnippet, + readOnly: !canSave, + }).result + .then(() => this.props.controller.update()) + .finally(() => { + navigateTo('/query_snippets', true, false); + }); + }; + + render() { + const { controller } = this.props; + + return ( +
+
+ +
+ + {!controller.isLoaded && } + {controller.isLoaded && controller.isEmpty && ( +
+ There are no query snippets yet. + {policy.isCreateQuerySnippetEnabled() && ( +
+ Click here to add one. +
+ )} +
+ )} + { + controller.isLoaded && !controller.isEmpty && ( +
+ + controller.updatePagination({ page })} + /> +
+ ) + } +
+ ); + } +} + +export default function init(ngModule) { + settingsMenu.add({ + permission: 'create_query', + title: 'Query Snippets', + path: 'query_snippets', + order: 5, + }); + + ngModule.component('pageQuerySnippetsList', react2angular(liveItemsList( + QuerySnippetsList, + new ResourceItemsSource({ + isPlainList: true, + getRequest() { + return {}; + }, + getResource() { + return QuerySnippet.query.bind(QuerySnippet); + }, + getItemProcessor() { + return (item => new QuerySnippet(item)); + }, + }), + new StateStorage({ orderByField: 'trigger', itemsPerPage: 10 }), + ))); + + return routesToAngularRoutes([ + { + path: '/query_snippets', + title: 'Query Snippets', + key: 'query_snippets', + }, + { + path: '/query_snippets/:querySnippetId', + title: 'Query Snippets', + key: 'query_snippets', + isNewOrEditPage: true, + }, + ], { + reloadOnSearch: false, + template: '', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }); +} + +init.init = true; diff --git a/client/app/pages/query-snippets/QuerySnippetsList.less b/client/app/pages/query-snippets/QuerySnippetsList.less new file mode 100644 index 0000000000..310ad31f39 --- /dev/null +++ b/client/app/pages/query-snippets/QuerySnippetsList.less @@ -0,0 +1,10 @@ +.snippet-content { + max-width: 500px; + max-height: 56px; + overflow: hidden; + white-space: pre-wrap; + /* autoprefixer: off */ + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} diff --git a/client/app/pages/query-snippets/edit.html b/client/app/pages/query-snippets/edit.html deleted file mode 100644 index bf9491ca61..0000000000 --- a/client/app/pages/query-snippets/edit.html +++ /dev/null @@ -1,31 +0,0 @@ - -
-
-
- - -
- -
- - -
- -
- -
{{$ctrl.snippet.snippet}}
-
-
- -
- - -
- - Created by: - - {{$ctrl.snippet.user.name}} - -
-
-
diff --git a/client/app/pages/query-snippets/edit.js b/client/app/pages/query-snippets/edit.js deleted file mode 100644 index 9430d52de6..0000000000 --- a/client/app/pages/query-snippets/edit.js +++ /dev/null @@ -1,74 +0,0 @@ -import 'brace/mode/snippets'; -import notification from '@/services/notification'; -import template from './edit.html'; - -function SnippetCtrl($routeParams, $http, $location, currentUser, AlertDialog, QuerySnippet) { - this.snippetId = $routeParams.snippetId; - - this.editorOptions = { - mode: 'snippets', - advanced: { - behavioursEnabled: true, - enableSnippets: false, - autoScrollEditorIntoView: true, - }, - onLoad(editor) { - editor.$blockScrolling = Infinity; - editor.getSession().setUseWrapMode(true); - editor.setShowPrintMargin(false); - }, - }; - - this.saveChanges = () => { - this.snippet.$save((snippet) => { - notification.success('Saved.'); - if (this.snippetId === 'new') { - $location.path(`/query_snippets/${snippet.id}`).replace(); - } - }, () => { - notification.error('Failed saving snippet.'); - }); - }; - - this.delete = () => { - const doDelete = () => { - this.snippet.$delete(() => { - $location.path('/query_snippets'); - notification.success('Query snippet deleted.'); - }, () => { - notification.error('Failed deleting query snippet.'); - }); - }; - - const title = 'Delete Snippet'; - const message = `Are you sure you want to delete the "${this.snippet.trigger}" snippet?`; - const confirm = { class: 'btn-warning', title: 'Delete' }; - - AlertDialog.open(title, message, confirm).then(doDelete); - }; - - if (this.snippetId === 'new') { - this.snippet = new QuerySnippet({ description: '' }); - this.canEdit = true; - } else { - this.snippet = QuerySnippet.get({ id: this.snippetId }, (snippet) => { - this.canEdit = currentUser.canEdit(snippet); - }); - } -} - -export default function init(ngModule) { - ngModule.component('snippetPage', { - template, - controller: SnippetCtrl, - }); - - return { - '/query_snippets/:snippetId': { - template: '', - title: 'Query Snippets', - }, - }; -} - -init.init = true; diff --git a/client/app/pages/query-snippets/list.html b/client/app/pages/query-snippets/list.html deleted file mode 100644 index 4c3b640c0e..0000000000 --- a/client/app/pages/query-snippets/list.html +++ /dev/null @@ -1,42 +0,0 @@ - -
-
-

- New Snippet -

- - - - - - - - - - - - - - - - - - - - -
TriggerDescriptionSnippetCreated ByUpdated At
- {{snippet.trigger}} - - {{snippet.description}} - - {{snippet.snippet}} - - - {{snippet.user.name}} - - -
- -
-
-
diff --git a/client/app/pages/query-snippets/list.js b/client/app/pages/query-snippets/list.js deleted file mode 100644 index 39d087251a..0000000000 --- a/client/app/pages/query-snippets/list.js +++ /dev/null @@ -1,33 +0,0 @@ -import settingsMenu from '@/services/settingsMenu'; -import { Paginator } from '@/lib/pagination'; -import template from './list.html'; - -function SnippetsCtrl($location, currentUser, QuerySnippet) { - this.snippets = new Paginator([], { itemsPerPage: 20 }); - QuerySnippet.query((snippets) => { - this.snippets.updateRows(snippets); - }); -} - -export default function init(ngModule) { - settingsMenu.add({ - permission: 'create_query', - title: 'Query Snippets', - path: 'query_snippets', - order: 5, - }); - - ngModule.component('snippetsListPage', { - template, - controller: SnippetsCtrl, - }); - - return { - '/query_snippets': { - template: '', - title: 'Query Snippets', - }, - }; -} - -init.init = true; diff --git a/client/app/services/navigateTo.js b/client/app/services/navigateTo.js index 52a2d56739..87be326553 100644 --- a/client/app/services/navigateTo.js +++ b/client/app/services/navigateTo.js @@ -1,8 +1,17 @@ import { isString } from 'lodash'; -import { $location, $rootScope } from '@/services/ng'; +import { $location, $rootScope, $route } from '@/services/ng'; -export default function navigateTo(url, replace = false) { +export default function navigateTo(url, replace = false, reload = true) { if (isString(url)) { + // Allows changing the URL without reloading + // ANGULAR_REMOVE_ME Revisit when some React router will be used + if (!reload) { + const lastRoute = $route.current; + const un = $rootScope.$on('$locationChangeSuccess', () => { + $route.current = lastRoute; + un(); + }); + } $location.url(url); if (replace) { $location.replace(); diff --git a/client/app/services/policy/DefaultPolicy.js b/client/app/services/policy/DefaultPolicy.js index 9aa1ed3046..44d80e1d46 100644 --- a/client/app/services/policy/DefaultPolicy.js +++ b/client/app/services/policy/DefaultPolicy.js @@ -45,6 +45,10 @@ export default class DefaultPolicy { return currentUser.isAdmin; } + isCreateQuerySnippetEnabled() { + return true; + } + getDashboardRefreshIntervals() { const result = clientConfig.dashboardRefreshIntervals; return isArray(result) ? result : null;