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) => (
+
+ ), {
+ 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() && (
+
+ )}
+
+ )}
+ {
+ 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 @@
-
-
-
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
-
-
-
-
-
- Trigger |
- Description |
- Snippet |
- Created By |
- Updated 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;