From 33b8bd27eb6b4f309a6f00cf223cee8b67de6b70 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Fri, 22 Feb 2019 14:47:48 +0200 Subject: [PATCH] [Feature] Migrate Group List and Details pages to React (#3411) --- client/app/assets/less/ant.less | 32 ++- client/app/assets/less/inc/base.less | 4 + client/app/assets/less/inc/generics.less | 2 + client/app/components/PreviewCard.jsx | 79 ++++++ client/app/components/SelectItemsDialog.jsx | 184 ++++++++++++ .../components/groups/CreateGroupDialog.jsx | 39 +++ .../components/groups/DeleteGroupButton.jsx | 56 ++++ .../components/groups/DetailsPageSidebar.jsx | 72 +++++ client/app/components/groups/GroupName.jsx | 44 +++ .../app/components/groups/ListItemAddon.jsx | 25 ++ .../components/groups/edit-group-dialog.html | 12 - .../components/groups/edit-group-dialog.js | 41 --- client/app/components/groups/group-name.js | 37 --- .../app/components/items-list/ItemsList.jsx | 5 +- .../items-list/classes/ItemsFetcher.js | 2 + .../items-list/classes/ItemsSource.js | 4 +- .../items-list/components/ItemsTable.jsx | 8 +- client/app/lib/utils.js | 19 +- client/app/pages/groups/GroupDataSources.jsx | 261 ++++++++++++++++++ client/app/pages/groups/GroupMembers.jsx | 226 +++++++++++++++ client/app/pages/groups/GroupsList.jsx | 168 +++++++++++ client/app/pages/groups/data-sources.html | 55 ---- client/app/pages/groups/data-sources.js | 56 ---- client/app/pages/groups/list.html | 24 -- client/app/pages/groups/list.js | 44 --- client/app/pages/groups/show.html | 53 ---- client/app/pages/groups/show.js | 61 ---- client/app/pages/users/UsersList.jsx | 9 +- client/app/services/group.js | 17 ++ client/app/services/navigateTo.js | 5 +- client/app/services/ng.js | 2 + cypress/integration/group/edit_group_spec.js | 2 +- 32 files changed, 1247 insertions(+), 401 deletions(-) create mode 100644 client/app/components/PreviewCard.jsx create mode 100644 client/app/components/SelectItemsDialog.jsx create mode 100644 client/app/components/groups/CreateGroupDialog.jsx create mode 100644 client/app/components/groups/DeleteGroupButton.jsx create mode 100644 client/app/components/groups/DetailsPageSidebar.jsx create mode 100644 client/app/components/groups/GroupName.jsx create mode 100644 client/app/components/groups/ListItemAddon.jsx delete mode 100644 client/app/components/groups/edit-group-dialog.html delete mode 100644 client/app/components/groups/edit-group-dialog.js delete mode 100644 client/app/components/groups/group-name.js create mode 100644 client/app/pages/groups/GroupDataSources.jsx create mode 100644 client/app/pages/groups/GroupMembers.jsx create mode 100644 client/app/pages/groups/GroupsList.jsx delete mode 100644 client/app/pages/groups/data-sources.html delete mode 100644 client/app/pages/groups/data-sources.js delete mode 100644 client/app/pages/groups/list.html delete mode 100644 client/app/pages/groups/list.js delete mode 100644 client/app/pages/groups/show.html delete mode 100644 client/app/pages/groups/show.js diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index dc38205a..a4b8eb2c 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -21,6 +21,10 @@ @import '~antd/lib/grid/style/index'; @import '~antd/lib/switch/style/index'; @import '~antd/lib/drawer/style/index'; +@import '~antd/lib/divider/style/index'; +@import '~antd/lib/dropdown/style/index'; +@import '~antd/lib/menu/style/index'; +@import '~antd/lib/list/style/index'; @import 'inc/ant-variables'; // Remove bold in labels for Ant checkboxes and radio buttons @@ -51,7 +55,6 @@ } // Button overrides - .@{btn-prefix-cls} { transition-duration: 150ms; } @@ -180,6 +183,31 @@ } } } + + // Custom styles + + &-headerless &-tbody > tr:first-child > td { + border-top: @border-width-base @border-style-base @border-color-split; + } +} + +// List + +.@{list-prefix-cls} { + &-item { + // custom rule + &.selected { + background-color: #F6F8F9; + } + + &.disabled { + background-color: fade(#F6F8F9, 40%); + + & > * { + opacity: 0.4; + } + } + } } // styling for short modals (no lines) @@ -210,4 +238,4 @@ .ant-popover { z-index: 1000; // make sure it doesn't cover drawer -} \ No newline at end of file +} diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index c3642b65..46865650 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -73,6 +73,10 @@ strong { } +.clickable { + cursor: pointer; +} + .resize-vertical { resize: vertical !important; transition: height 0s !important; diff --git a/client/app/assets/less/inc/generics.less b/client/app/assets/less/inc/generics.less index da230213..0ad99bd5 100755 --- a/client/app/assets/less/inc/generics.less +++ b/client/app/assets/less/inc/generics.less @@ -146,6 +146,8 @@ Width -----------------------------------------------------------*/ .w-100 { width: 100% !important; } +.w-50 { width: 50% !important; } +.w-25 { width: 25% !important; } /* -------------------------------------------------------- diff --git a/client/app/components/PreviewCard.jsx b/client/app/components/PreviewCard.jsx new file mode 100644 index 00000000..7c86161d --- /dev/null +++ b/client/app/components/PreviewCard.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +// PreviewCard + +export function PreviewCard({ imageUrl, title, body, children, className, ...props }) { + return ( +
+ Logo/Avatar +
+
{title}
+ {body &&
{body}
} +
+ {children} +
+ ); +} + +PreviewCard.propTypes = { + imageUrl: PropTypes.string.isRequired, + title: PropTypes.node.isRequired, + body: PropTypes.node, + className: PropTypes.string, + children: PropTypes.node, +}; + +PreviewCard.defaultProps = { + body: null, + className: '', + children: null, +}; + +// UserPreviewCard + +export function UserPreviewCard({ user, withLink, children, ...props }) { + const title = withLink ? {user.name} : user.name; + return ( + + {children} + + ); +} + +UserPreviewCard.propTypes = { + user: PropTypes.shape({ + profile_image_url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + }).isRequired, + withLink: PropTypes.bool, + children: PropTypes.node, +}; + +UserPreviewCard.defaultProps = { + withLink: false, + children: null, +}; + +// DataSourcePreviewCard + +export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) { + const imageUrl = `/static/images/db-logos/${dataSource.type}.png`; + const title = withLink ? {dataSource.name} : dataSource.name; + return {children}; +} + +DataSourcePreviewCard.propTypes = { + dataSource: PropTypes.shape({ + name: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + }).isRequired, + withLink: PropTypes.bool, + children: PropTypes.node, +}; + +DataSourcePreviewCard.defaultProps = { + withLink: false, + children: null, +}; diff --git a/client/app/components/SelectItemsDialog.jsx b/client/app/components/SelectItemsDialog.jsx new file mode 100644 index 00000000..359198ac --- /dev/null +++ b/client/app/components/SelectItemsDialog.jsx @@ -0,0 +1,184 @@ +import { filter, debounce, find } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Modal from 'antd/lib/modal'; +import Input from 'antd/lib/input'; +import List from 'antd/lib/list'; +import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; +import { BigMessage } from '@/components/BigMessage'; + +import LoadingState from '@/components/items-list/components/LoadingState'; +import { toastr } from '@/services/ng'; + +class SelectItemsDialog extends React.Component { + static propTypes = { + dialog: DialogPropType.isRequired, + dialogTitle: PropTypes.string, + inputPlaceholder: PropTypes.string, + selectedItemsTitle: PropTypes.string, + searchItems: PropTypes.func.isRequired, // (searchTerm: string): Promise if `searchTerm === ''` load all + itemKey: PropTypes.func, // (item) => string|number - return key of item (by default `id`) + // left list + // (item, { isSelected }) => { + // content: node, // item contents + // className: string = '', // additional class for item wrapper + // isDisabled: bool = false, // is item clickable or disabled + // } + renderItem: PropTypes.func, + // right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used + renderStagedItem: PropTypes.func, + save: PropTypes.func, // (selectedItems[]) => Promise + }; + + static defaultProps = { + dialogTitle: 'Add Items', + inputPlaceholder: 'Search...', + selectedItemsTitle: 'Selected items', + itemKey: item => item.id, + renderItem: () => '', + renderStagedItem: null, // use `renderItem` by default + save: items => items, + }; + + state = { + searchTerm: '', + loading: false, + items: [], + selected: [], + saveInProgress: false, + }; + + // eslint-disable-next-line react/sort-comp + loadItems = (searchTerm = '') => { + this.setState({ searchTerm, loading: true }, () => { + this.props.searchItems(searchTerm) + .then((items) => { + // If another search appeared while loading data - just reject this set + if (this.state.searchTerm === searchTerm) { + this.setState({ items, loading: false }); + } + }) + .catch(() => { + if (this.state.searchTerm === searchTerm) { + this.setState({ items: [], loading: false }); + } + }); + }); + }; + + search = debounce(this.loadItems, 200); + + componentDidMount() { + this.loadItems(); + } + + isSelected(item) { + const key = this.props.itemKey(item); + return !!find(this.state.selected, i => this.props.itemKey(i) === key); + } + + toggleItem(item) { + if (this.isSelected(item)) { + const key = this.props.itemKey(item); + this.setState(({ selected }) => ({ + selected: filter(selected, i => this.props.itemKey(i) !== key), + })); + } else { + this.setState(({ selected }) => ({ + selected: [...selected, item], + })); + } + } + + save() { + this.setState({ saveInProgress: true }, () => { + const selectedItems = this.state.selected; + Promise.resolve(this.props.save(selectedItems)) + .then(() => { + this.props.dialog.close(selectedItems); + }) + .catch(() => { + this.setState({ saveInProgress: false }); + toastr.error('Failed to save some of selected items.'); + }); + }); + } + + renderItem(item, isStagedList) { + const { renderItem, renderStagedItem } = this.props; + const isSelected = this.isSelected(item); + const render = isStagedList ? (renderStagedItem || renderItem) : renderItem; + + const { content, className, isDisabled } = render(item, { isSelected }); + + return ( + this.toggleItem(item)} + > + {content} + + ); + } + + render() { + const { dialog, dialogTitle, inputPlaceholder, selectedItemsTitle } = this.props; + const { loading, saveInProgress, items, selected } = this.state; + const hasResults = items.length > 0; + return ( + this.save()} + > +
+
+ this.search(event.target.value)} + placeholder={inputPlaceholder} + autoFocus + /> +
+
+
{selectedItemsTitle}
+
+
+ +
+
+ {loading && } + {!loading && !hasResults && ( + + )} + {!loading && hasResults && ( + this.renderItem(item, false)} + /> + )} +
+
+ {(selected.length > 0) && ( + this.renderItem(item, true)} + /> + )} +
+
+
+ ); + } +} + +export default wrapDialog(SelectItemsDialog); diff --git a/client/app/components/groups/CreateGroupDialog.jsx b/client/app/components/groups/CreateGroupDialog.jsx new file mode 100644 index 00000000..ca2695cb --- /dev/null +++ b/client/app/components/groups/CreateGroupDialog.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import Modal from 'antd/lib/modal'; +import Input from 'antd/lib/input'; +import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; +import { Group } from '@/services/group'; + +class CreateGroupDialog extends React.Component { + static propTypes = { + dialog: DialogPropType.isRequired, + }; + + state = { + name: '', + }; + + save = () => { + this.props.dialog.close(new Group({ + name: this.state.name, + })); + }; + + render() { + const { dialog } = this.props; + return ( + this.save()}> + this.setState({ name: event.target.value })} + onPressEnter={() => this.save()} + placeholder="Group Name" + autoFocus + /> + + ); + } +} + +export default wrapDialog(CreateGroupDialog); diff --git a/client/app/components/groups/DeleteGroupButton.jsx b/client/app/components/groups/DeleteGroupButton.jsx new file mode 100644 index 00000000..a1715d3c --- /dev/null +++ b/client/app/components/groups/DeleteGroupButton.jsx @@ -0,0 +1,56 @@ +import { isString } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from 'antd/lib/button'; +import Modal from 'antd/lib/modal'; +import Tooltip from 'antd/lib/tooltip'; +import { toastr } from '@/services/ng'; + +function deleteGroup(event, group, onGroupDeleted) { + // prevent default click action on table rows + event.preventDefault(); + event.stopPropagation(); + + Modal.confirm({ + title: 'Delete Group', + content: 'Are you sure you want to delete this group?', + okText: 'Yes', + okType: 'danger', + cancelText: 'No', + onOk: () => { + group.$delete(() => { + toastr.success('Group deleted successfully.'); + onGroupDeleted(); + }); + }, + }); +} + +export default function DeleteGroupButton({ group, title, onClick, children, ...props }) { + if (!group) { + return null; + } + const button = ( + + ); + + if (isString(title) && (title !== '')) { + return {button}; + } + + return button; +} + +DeleteGroupButton.propTypes = { + group: PropTypes.object, // eslint-disable-line react/forbid-prop-types + title: PropTypes.string, + onClick: PropTypes.func, + children: PropTypes.node, +}; + +DeleteGroupButton.defaultProps = { + group: null, + title: null, + onClick: () => {}, + children: null, +}; diff --git a/client/app/components/groups/DetailsPageSidebar.jsx b/client/app/components/groups/DetailsPageSidebar.jsx new file mode 100644 index 00000000..0b21ed08 --- /dev/null +++ b/client/app/components/groups/DetailsPageSidebar.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from 'antd/lib/button'; +import Divider from 'antd/lib/divider'; + +import * as Sidebar from '@/components/items-list/components/Sidebar'; +import { ControllerType } from '@/components/items-list/ItemsList'; +import DeleteGroupButton from './DeleteGroupButton'; + +import { currentUser } from '@/services/auth'; + +export default function DetailsPageSidebar({ + controller, group, items, + canAddMembers, onAddMembersClick, + canAddDataSources, onAddDataSourcesClick, + onGroupDeleted, +}) { + const canRemove = group && currentUser.isAdmin && (group.type !== 'builtin'); + + return ( + + + controller.updatePagination({ itemsPerPage })} + /> + {canAddMembers && ( + + )} + {canAddDataSources && ( + + )} + {canRemove && ( + + + Delete Group + + )} + + ); +} + +DetailsPageSidebar.propTypes = { + controller: ControllerType.isRequired, + group: PropTypes.object, // eslint-disable-line react/forbid-prop-types + items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + + canAddMembers: PropTypes.bool, + onAddMembersClick: PropTypes.func, + + canAddDataSources: PropTypes.bool, + onAddDataSourcesClick: PropTypes.func, + + onGroupDeleted: PropTypes.func, +}; + +DetailsPageSidebar.defaultProps = { + group: null, + + canAddMembers: false, + onAddMembersClick: null, + + canAddDataSources: false, + onAddDataSourcesClick: null, + + onGroupDeleted: null, +}; diff --git a/client/app/components/groups/GroupName.jsx b/client/app/components/groups/GroupName.jsx new file mode 100644 index 00000000..fef5a5c3 --- /dev/null +++ b/client/app/components/groups/GroupName.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { EditInPlace } from '@/components/EditInPlace'; +import { currentUser } from '@/services/auth'; + +function updateGroupName(group, name, onChange) { + group.name = name; + group.$save(); + onChange(); +} + +export default function GroupName({ group, onChange, ...props }) { + if (!group) { + return null; + } + + const canEdit = currentUser.isAdmin && (group.type !== 'builtin'); + + return ( +

+ updateGroupName(group, name, onChange)} + value={group.name} + /> +

+ ); +} + +GroupName.propTypes = { + group: PropTypes.shape({ + name: PropTypes.string.isRequired, + $save: PropTypes.func.isRequired, + }), + onChange: PropTypes.func, +}; + +GroupName.defaultProps = { + group: null, + onChange: () => {}, +}; diff --git a/client/app/components/groups/ListItemAddon.jsx b/client/app/components/groups/ListItemAddon.jsx new file mode 100644 index 00000000..ec4fd22a --- /dev/null +++ b/client/app/components/groups/ListItemAddon.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Tooltip from 'antd/lib/tooltip'; + +export default function ListItemAddon({ isSelected, isStaged, alreadyInGroup }) { + if (isStaged) { + return ; + } + if (alreadyInGroup) { + return ; + } + return isSelected ? : ; +} + +ListItemAddon.propTypes = { + isSelected: PropTypes.bool, + isStaged: PropTypes.bool, + alreadyInGroup: PropTypes.bool, +}; + +ListItemAddon.defaultProps = { + isSelected: false, + isStaged: false, + alreadyInGroup: false, +}; diff --git a/client/app/components/groups/edit-group-dialog.html b/client/app/components/groups/edit-group-dialog.html deleted file mode 100644 index 5db139f8..00000000 --- a/client/app/components/groups/edit-group-dialog.html +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/client/app/components/groups/edit-group-dialog.js b/client/app/components/groups/edit-group-dialog.js deleted file mode 100644 index f893924f..00000000 --- a/client/app/components/groups/edit-group-dialog.js +++ /dev/null @@ -1,41 +0,0 @@ -import template from './edit-group-dialog.html'; - -const EditGroupDialogComponent = { - template, - bindings: { - resolve: '<', - close: '&', - dismiss: '&', - }, - controller($location) { - 'ngInject'; - - this.group = this.resolve.group; - const newGroup = this.group.id === undefined; - - if (newGroup) { - this.saveButtonText = 'Create'; - this.title = 'Create a New Group'; - } else { - this.saveButtonText = 'Save'; - this.title = 'Edit Group'; - } - - this.ok = () => { - this.group.$save((group) => { - if (newGroup) { - $location.path(`/groups/${group.id}`).replace(); - this.close(); - } else { - this.close(); - } - }); - }; - }, -}; - -export default function init(ngModule) { - ngModule.component('editGroupDialog', EditGroupDialogComponent); -} - -init.init = true; diff --git a/client/app/components/groups/group-name.js b/client/app/components/groups/group-name.js deleted file mode 100644 index 23d0a9c7..00000000 --- a/client/app/components/groups/group-name.js +++ /dev/null @@ -1,37 +0,0 @@ -function controller($window, $location, toastr, currentUser) { - this.canEdit = () => currentUser.isAdmin && this.group.type !== 'builtin'; - - this.saveName = (name) => { - this.group.name = name; - this.group.$save(); - }; - - this.deleteGroup = () => { - if ($window.confirm('Are you sure you want to delete this group?')) { - this.group.$delete(() => { - $location.path('/groups').replace(); - toastr.success('Group deleted successfully.'); - }); - } - }; -} - -export default function init(ngModule) { - ngModule.component('groupName', { - bindings: { - group: '<', - }, - transclude: true, - template: ` -

-   - -

- `, - replace: true, - controller, - }); -} - -init.init = true; diff --git a/client/app/components/items-list/ItemsList.jsx b/client/app/components/items-list/ItemsList.jsx index 00fc633d..87b6ed7d 100644 --- a/client/app/components/items-list/ItemsList.jsx +++ b/client/app/components/items-list/ItemsList.jsx @@ -2,7 +2,7 @@ import { omit, debounce } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import hoistNonReactStatics from 'hoist-non-react-statics'; -import { $route } from '@/services/ng'; +import { $route, $routeParams } from '@/services/ng'; import { clientConfig } from '@/services/auth'; import { StateStorage } from './classes/StateStorage'; @@ -94,9 +94,10 @@ export function wrap(WrappedComponent, itemsSource, stateStorage) { // eslint-disable-next-line class-methods-use-this getState({ isLoaded, totalCount, pageItems, ...rest }) { const params = { - // Add some properties of current route (`$resolve`, title) + // Add some properties of current route (`$resolve`, title, route params) // ANGULAR_REMOVE_ME Revisit when some React router will be used title: $route.current.title, + ...$routeParams, ...omit($route.current.locals, ['$scope', '$template']), // Add to params all props except of own ones diff --git a/client/app/components/items-list/classes/ItemsFetcher.js b/client/app/components/items-list/classes/ItemsFetcher.js index 451c342a..d54dade6 100644 --- a/client/app/components/items-list/classes/ItemsFetcher.js +++ b/client/app/components/items-list/classes/ItemsFetcher.js @@ -43,6 +43,7 @@ export class PlainListFetcher extends ItemsFetcher { return { results: paginator.getItemsForPage(this._allItems), count: this._allItems.length, + allResults: this._allItems, }; } @@ -59,6 +60,7 @@ export class PlainListFetcher extends ItemsFetcher { return Promise.resolve({ results: paginator.getItemsForPage(this._allItems), count: this._allItems.length, + allResults: this._allItems, }); } } diff --git a/client/app/components/items-list/classes/ItemsSource.js b/client/app/components/items-list/classes/ItemsSource.js index 4a4d795b..ece41752 100644 --- a/client/app/components/items-list/classes/ItemsSource.js +++ b/client/app/components/items-list/classes/ItemsSource.js @@ -38,8 +38,9 @@ export class ItemsSource { const context = this.getCallbackContext(); return this._beforeUpdate().then(() => ( this._fetcher.fetch(changes, state, context) - .then(({ results, count }) => { + .then(({ results, count, allResults }) => { this._pageItems = results; + this._allItems = allResults || null; this._paginator.setTotalCount(count); return this._afterUpdate(); }) @@ -76,6 +77,7 @@ export class ItemsSource { selectedTags: this._selectedTags, totalCount: this._paginator.totalCount, pageItems: this._pageItems, + allItems: this._allItems, }; } diff --git a/client/app/components/items-list/components/ItemsTable.jsx b/client/app/components/items-list/components/ItemsTable.jsx index 664e79a0..200c9c56 100644 --- a/client/app/components/items-list/components/ItemsTable.jsx +++ b/client/app/components/items-list/components/ItemsTable.jsx @@ -1,6 +1,7 @@ import { isFunction, map, filter, extend, omit, identity } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; import Table from 'antd/lib/table'; import { FavoritesControl } from '@/components/FavoritesControl'; import { TimeAgo } from '@/components/TimeAgo'; @@ -73,6 +74,7 @@ export default class ItemsTable extends React.Component { render: PropTypes.func, // (prop, item) => text | node; `prop` is `item[field]` isAvailable: PropTypes.func, // return `true` to show column and `false` to hide; if omitted: show column })), + showHeader: PropTypes.bool, onRowClick: PropTypes.func, // (event, item) => void orderByField: PropTypes.string, @@ -83,6 +85,7 @@ export default class ItemsTable extends React.Component { static defaultProps = { items: [], columns: [], + showHeader: true, onRowClick: null, orderByField: null, @@ -136,10 +139,13 @@ export default class ItemsTable extends React.Component { }) ) : null; + const { showHeader } = this.props; + return ( row.key} pagination={false} diff --git a/client/app/lib/utils.js b/client/app/lib/utils.js index 01d3aaa6..170b4bdd 100644 --- a/client/app/lib/utils.js +++ b/client/app/lib/utils.js @@ -1,6 +1,4 @@ -import { each, extend } from 'lodash'; - -/* eslint-disable import/prefer-default-export */ +import { isFunction, each, extend } from 'lodash'; export function routesToAngularRoutes(routes, template) { const result = {}; @@ -16,3 +14,18 @@ export function routesToAngularRoutes(routes, template) { }); return result; } + +function doCancelEvent(event) { + event.stopPropagation(); + event.preventDefault(); +} + +export function cancelEvent(handler) { + if (isFunction(handler)) { + return (event, ...rest) => { + doCancelEvent(event); + return handler(...rest); + }; + } + return doCancelEvent; +} diff --git a/client/app/pages/groups/GroupDataSources.jsx b/client/app/pages/groups/GroupDataSources.jsx new file mode 100644 index 00000000..b33db6e8 --- /dev/null +++ b/client/app/pages/groups/GroupDataSources.jsx @@ -0,0 +1,261 @@ +import { filter, map, includes } from 'lodash'; +import React from 'react'; +import { react2angular } from 'react2angular'; +import Button from 'antd/lib/button'; +import Dropdown from 'antd/lib/dropdown'; +import Menu from 'antd/lib/menu'; +import Icon from 'antd/lib/icon'; + +import { Paginator } from '@/components/Paginator'; + +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 SelectItemsDialog from '@/components/SelectItemsDialog'; +import { DataSourcePreviewCard } from '@/components/PreviewCard'; + +import GroupName from '@/components/groups/GroupName'; +import ListItemAddon from '@/components/groups/ListItemAddon'; +import Sidebar from '@/components/groups/DetailsPageSidebar'; + +import { toastr } from '@/services/ng'; +import { currentUser } from '@/services/auth'; +import { Group } from '@/services/group'; +import { DataSource } from '@/services/data-source'; +import navigateTo from '@/services/navigateTo'; +import { routesToAngularRoutes, cancelEvent } from '@/lib/utils'; + +class GroupDataSources extends React.Component { + static propTypes = { + controller: ControllerType.isRequired, + }; + + groupId = parseInt(this.props.controller.params.groupId, 10); + + group = null; + + sidebarMenu = [ + { + key: 'users', + href: `groups/${this.groupId}`, + title: 'Members', + }, + { + key: 'datasources', + href: `groups/${this.groupId}/data_sources`, + title: 'Data Sources', + isAvailable: () => currentUser.isAdmin, + }, + ]; + + listColumns = [ + Columns.custom((text, datasource) => ( + + ), { + title: 'Name', + field: 'name', + width: null, + }), + Columns.custom((text, datasource) => { + const menu = ( + this.setDataSourcePermissions(item.domEvent, datasource, item.key)} + > + Full Access + View Only + + ); + + return ( + + + + ); + }, { + width: '1%', + className: 'p-r-0', + isAvailable: () => currentUser.isAdmin, + }), + Columns.custom((text, datasource) => ( + + ), { + width: '1%', + isAvailable: () => currentUser.isAdmin, + }), + ]; + + removeGroupDataSource = cancelEvent((datasource) => { + Group.removeDataSource({ id: this.groupId, dataSourceId: datasource.id }).$promise + .then(() => { + this.props.controller.updatePagination({ page: 1 }); + this.props.controller.update(); + }) + .catch(() => { + toastr.error('Failed to remove data source from group.'); + }); + }); + + componentDidMount() { + Group.get({ id: this.groupId }).$promise.then((group) => { + this.group = group; + this.forceUpdate(); + }); + } + + onTableRowClick = (event, item) => navigateTo('data_sources/' + item.id); + + setDataSourcePermissions = cancelEvent((datasource, permission) => { + const viewOnly = permission !== 'full'; + + Group.updateDataSource({ id: this.groupId, dataSourceId: datasource.id }, { view_only: viewOnly }).$promise + .then(() => { + datasource.view_only = viewOnly; + this.forceUpdate(); + }) + .catch(() => { + toastr.error('Failed change data source permissions.'); + }); + }); + + addDataSources = () => { + const allDataSources = DataSource.query().$promise; + const alreadyAddedDataSources = map(this.props.controller.allItems, ds => ds.id); + SelectItemsDialog.showModal({ + dialogTitle: 'Add Data Sources', + inputPlaceholder: 'Search data sources...', + selectedItemsTitle: 'New Data Sources', + searchItems: (searchTerm) => { + searchTerm = searchTerm.toLowerCase(); + return allDataSources.then(items => filter(items, ds => ds.name.toLowerCase().includes(searchTerm))); + }, + renderItem: (item, { isSelected }) => { + const alreadyInGroup = includes(alreadyAddedDataSources, item.id); + return { + content: ( + + + + ), + isDisabled: alreadyInGroup, + className: isSelected || alreadyInGroup ? 'selected' : '', + }; + }, + renderStagedItem: (item, { isSelected }) => ({ + content: ( + + + + ), + }), + save: (items) => { + const promises = map(items, ds => Group.addDataSource({ id: this.groupId, data_source_id: ds.id }).$promise); + return Promise.all(promises); + }, + }).result.finally(() => { + this.props.controller.update(); + }); + }; + + render() { + const { controller } = this.props; + const sidebar = ( + navigateTo('/groups', true)} + /> + ); + + return ( +
+ this.forceUpdate()} /> +
+
{sidebar}
+
+ {!controller.isLoaded && } + {controller.isLoaded && controller.isEmpty && ( +
+ There are no data sources in this group yet. + {currentUser.isAdmin && ( +
+ Click here + {' '} to add data sources. +
+ )} +
+ )} + { + controller.isLoaded && !controller.isEmpty && ( +
+ + controller.updatePagination({ page })} + /> +
+ ) + } +
+
{sidebar}
+
+
+ ); + } +} + +export default function init(ngModule) { + ngModule.component('pageGroupDataSources', react2angular(liveItemsList( + GroupDataSources, + new ResourceItemsSource({ + isPlainList: true, + getRequest(unused, { params: { groupId } }) { + return { id: groupId }; + }, + getResource() { + return Group.dataSources.bind(Group); + }, + getItemProcessor() { + return (item => new DataSource(item)); + }, + }), + new StateStorage({ orderByField: 'name' }), + ))); + + return routesToAngularRoutes([ + { + path: '/groups/:groupId/data_sources', + title: 'Group Data Sources', + key: 'datasources', + }, + ], { + reloadOnSearch: false, + template: '', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }); +} + +init.init = true; diff --git a/client/app/pages/groups/GroupMembers.jsx b/client/app/pages/groups/GroupMembers.jsx new file mode 100644 index 00000000..115b1e1c --- /dev/null +++ b/client/app/pages/groups/GroupMembers.jsx @@ -0,0 +1,226 @@ +import { includes, map } from 'lodash'; +import React from 'react'; +import { react2angular } from 'react2angular'; +import Button from 'antd/lib/button'; + +import { Paginator } from '@/components/Paginator'; + +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 SelectItemsDialog from '@/components/SelectItemsDialog'; +import { UserPreviewCard } from '@/components/PreviewCard'; + +import GroupName from '@/components/groups/GroupName'; +import ListItemAddon from '@/components/groups/ListItemAddon'; +import Sidebar from '@/components/groups/DetailsPageSidebar'; + +import { toastr } from '@/services/ng'; +import { currentUser } from '@/services/auth'; +import { Group } from '@/services/group'; +import { User } from '@/services/user'; +import navigateTo from '@/services/navigateTo'; +import { routesToAngularRoutes, cancelEvent } from '@/lib/utils'; + +class GroupMembers extends React.Component { + static propTypes = { + controller: ControllerType.isRequired, + }; + + groupId = parseInt(this.props.controller.params.groupId, 10); + + group = null; + + sidebarMenu = [ + { + key: 'users', + href: `groups/${this.groupId}`, + title: 'Members', + }, + { + key: 'datasources', + href: `groups/${this.groupId}/data_sources`, + title: 'Data Sources', + isAvailable: () => currentUser.isAdmin, + }, + ]; + + listColumns = [ + Columns.custom((text, user) => ( + + ), { + title: 'Name', + field: 'name', + width: null, + }), + Columns.custom((text, user) => { + if (!this.group) { + return null; + } + + // cannot remove self from built-in groups + if ((this.group.type === 'builtin') && (currentUser.id === user.id)) { + return null; + } + return ; + }, { + width: '1%', + isAvailable: () => currentUser.isAdmin, + }), + ]; + + removeGroupMember = cancelEvent((user) => { + Group.removeMember({ id: this.groupId, userId: user.id }).$promise + .then(() => { + this.props.controller.updatePagination({ page: 1 }); + this.props.controller.update(); + }) + .catch(() => { + toastr.error('Failed to remove member from group.'); + }); + }); + + componentDidMount() { + Group.get({ id: this.groupId }).$promise.then((group) => { + this.group = group; + this.forceUpdate(); + }); + } + + onTableRowClick = (event, item) => navigateTo('users/' + item.id); + + addMembers = () => { + const alreadyAddedUsers = map(this.props.controller.allItems, u => u.id); + SelectItemsDialog.showModal({ + dialogTitle: 'Add Members', + inputPlaceholder: 'Search users...', + selectedItemsTitle: 'New Members', + searchItems: searchTerm => User.query({ q: searchTerm }).$promise.then(({ results }) => results), + renderItem: (item, { isSelected }) => { + const alreadyInGroup = includes(alreadyAddedUsers, item.id); + return { + content: ( + + + + ), + isDisabled: alreadyInGroup, + className: isSelected || alreadyInGroup ? 'selected' : '', + }; + }, + renderStagedItem: (item, { isSelected }) => ({ + content: ( + + + + ), + }), + save: (items) => { + const promises = map(items, u => Group.addMember({ id: this.groupId }, { user_id: u.id }).$promise); + return Promise.all(promises); + }, + }).result.finally(() => { + this.props.controller.update(); + }); + }; + + render() { + const { controller } = this.props; + const sidebar = ( + navigateTo('/groups', true)} + /> + ); + + return ( +
+ this.forceUpdate()} /> +
+
{sidebar}
+
+ {!controller.isLoaded && } + {controller.isLoaded && controller.isEmpty && ( +
+ There are no members in this group yet. + {currentUser.isAdmin && ( +
+ Click here + {' '} to add members. +
+ )} +
+ )} + { + controller.isLoaded && !controller.isEmpty && ( +
+ + controller.updatePagination({ page })} + /> +
+ ) + } +
+
{sidebar}
+
+
+ ); + } +} + +export default function init(ngModule) { + ngModule.component('pageGroupMembers', react2angular(liveItemsList( + GroupMembers, + new ResourceItemsSource({ + isPlainList: true, + getRequest(unused, { params: { groupId } }) { + return { id: groupId }; + }, + getResource() { + return Group.members.bind(Group); + }, + getItemProcessor() { + return (item => new User(item)); + }, + }), + new StateStorage({ orderByField: 'name' }), + ))); + + return routesToAngularRoutes([ + { + path: '/groups/:groupId', + title: 'Group Members', + key: 'users', + }, + ], { + reloadOnSearch: false, + template: '', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }); +} + +init.init = true; diff --git a/client/app/pages/groups/GroupsList.jsx b/client/app/pages/groups/GroupsList.jsx new file mode 100644 index 00000000..17e17f86 --- /dev/null +++ b/client/app/pages/groups/GroupsList.jsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { react2angular } from 'react2angular'; + +import Button from 'antd/lib/button'; +import { Paginator } from '@/components/Paginator'; + +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 EmptyState from '@/components/items-list/components/EmptyState'; +import ItemsTable, { Columns } from '@/components/items-list/components/ItemsTable'; + +import CreateGroupDialog from '@/components/groups/CreateGroupDialog'; +import DeleteGroupButton from '@/components/groups/DeleteGroupButton'; + +import { Group } from '@/services/group'; +import settingsMenu from '@/services/settingsMenu'; +import { currentUser } from '@/services/auth'; +import navigateTo from '@/services/navigateTo'; +import { routesToAngularRoutes } from '@/lib/utils'; + +class GroupsList extends React.Component { + static propTypes = { + controller: ControllerType.isRequired, + }; + + listColumns = [ + Columns.custom((text, group) => ( +
+ {group.name} + {(group.type === 'builtin') && built-in} +
+ ), { + field: 'name', + width: null, + }), + Columns.custom((text, group) => ( + + + {currentUser.isAdmin && ( + + )} + + ), { + width: '1%', + className: 'text-nowrap', + }), + Columns.custom((text, group) => { + const canRemove = group.type !== 'builtin'; + return ( + this.onGroupDeleted()} + > + Delete + + ); + }, { + width: '1%', + className: 'text-nowrap p-l-0', + isAvailable: () => currentUser.isAdmin, + }), + ]; + + createGroup = () => { + CreateGroupDialog.showModal().result.then((group) => { + group.$save().then(newGroup => navigateTo(`/groups/${newGroup.id}`)); + }); + }; + + onTableRowClick = (event, item) => navigateTo('groups/' + item.id); + + onGroupDeleted = () => { + this.props.controller.updatePagination({ page: 1 }); + this.props.controller.update(); + }; + + render() { + const { controller } = this.props; + + return ( +
+ {currentUser.isAdmin && ( +
+ +
+ )} + + {!controller.isLoaded && } + {controller.isLoaded && controller.isEmpty && } + { + controller.isLoaded && !controller.isEmpty && ( +
+ + controller.updatePagination({ page })} + /> +
+ ) + } +
+ ); + } +} + +export default function init(ngModule) { + settingsMenu.add({ + permission: 'list_users', + title: 'Groups', + path: 'groups', + order: 3, + }); + + ngModule.component('pageGroupsList', react2angular(liveItemsList( + GroupsList, + new ResourceItemsSource({ + isPlainList: true, + getRequest() { + return {}; + }, + getResource() { + return Group.query.bind(Group); + }, + getItemProcessor() { + return (item => new Group(item)); + }, + }), + new StateStorage({ orderByField: 'name', itemsPerPage: 10 }), + ))); + + return routesToAngularRoutes([ + { + path: '/groups', + title: 'Groups', + key: 'groups', + }, + ], { + reloadOnSearch: false, + template: '', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }); +} + +init.init = true; diff --git a/client/app/pages/groups/data-sources.html b/client/app/pages/groups/data-sources.html deleted file mode 100644 index 41bd17ee..00000000 --- a/client/app/pages/groups/data-sources.html +++ /dev/null @@ -1,55 +0,0 @@ - - - -
-
- -
-
- - - -
- {{dataSource.name}} -
-
-
-
-
-
-
-
- - - - - - - - -
{{dataSource.name}} -
- - - - - -
- - -
- - - diff --git a/client/app/pages/groups/data-sources.js b/client/app/pages/groups/data-sources.js deleted file mode 100644 index fbe89951..00000000 --- a/client/app/pages/groups/data-sources.js +++ /dev/null @@ -1,56 +0,0 @@ -import { includes } from 'lodash'; -import template from './data-sources.html'; - -function GroupDataSourcesCtrl($scope, $routeParams, $http, Group, DataSource) { - $scope.group = Group.get({ id: $routeParams.groupId }); - $scope.dataSources = Group.dataSources({ id: $routeParams.groupId }); - $scope.newDataSource = {}; - - $scope.findDataSource = () => { - if ($scope.foundDataSources === undefined) { - DataSource.query((dataSources) => { - const existingIds = $scope.dataSources.map(m => m.id); - $scope.foundDataSources = dataSources.filter(ds => !includes(existingIds, ds.id)); - }); - } - }; - - $scope.addDataSource = (dataSource) => { - // Clear selection, to clear up the input control. - $scope.newDataSource.selected = undefined; - - $http.post(`api/groups/${$routeParams.groupId}/data_sources`, { data_source_id: dataSource.id }).success(() => { - dataSource.view_only = false; - $scope.dataSources.unshift(dataSource); - - if ($scope.foundDataSources) { - $scope.foundDataSources = $scope.foundDataSources.filter(ds => ds !== dataSource); - } - }); - }; - - $scope.changePermission = (dataSource, viewOnly) => { - $http.post(`api/groups/${$routeParams.groupId}/data_sources/${dataSource.id}`, { view_only: viewOnly }).success(() => { - dataSource.view_only = viewOnly; - }); - }; - - $scope.removeDataSource = (dataSource) => { - $http.delete(`api/groups/${$routeParams.groupId}/data_sources/${dataSource.id}`).success(() => { - $scope.dataSources = $scope.dataSources.filter(ds => dataSource !== ds); - }); - }; -} - -export default function init(ngModule) { - ngModule.controller('GroupDataSourcesCtrl', GroupDataSourcesCtrl); - - return { - '/groups/:groupId/data_sources': { - template, - controller: 'GroupDataSourcesCtrl', - }, - }; -} - -init.init = true; diff --git a/client/app/pages/groups/list.html b/client/app/pages/groups/list.html deleted file mode 100644 index 260fff9d..00000000 --- a/client/app/pages/groups/list.html +++ /dev/null @@ -1,24 +0,0 @@ - -
-
-

- New Group -

- - - - - - - - - - - -
Name
- {{row.name}} -
- -
-
-
diff --git a/client/app/pages/groups/list.js b/client/app/pages/groups/list.js deleted file mode 100644 index ec424169..00000000 --- a/client/app/pages/groups/list.js +++ /dev/null @@ -1,44 +0,0 @@ -import settingsMenu from '@/services/settingsMenu'; -import { Paginator } from '@/lib/pagination'; -import template from './list.html'; - -function GroupsCtrl($scope, $uibModal, currentUser, Group) { - $scope.currentUser = currentUser; - $scope.groups = new Paginator([], { itemsPerPage: 20 }); - Group.query((groups) => { - $scope.groups.updateRows(groups); - }); - - $scope.newGroup = () => { - $uibModal.open({ - component: 'editGroupDialog', - size: 'sm', - resolve: { - group() { - return new Group({}); - }, - }, - }); - }; -} - -export default function init(ngModule) { - settingsMenu.add({ - permission: 'list_users', - title: 'Groups', - path: 'groups', - order: 3, - }); - - ngModule.controller('GroupsCtrl', GroupsCtrl); - - return { - '/groups': { - template, - controller: 'GroupsCtrl', - title: 'Groups', - }, - }; -} - -init.init = true; diff --git a/client/app/pages/groups/show.html b/client/app/pages/groups/show.html deleted file mode 100644 index 027adfe7..00000000 --- a/client/app/pages/groups/show.html +++ /dev/null @@ -1,53 +0,0 @@ - -
- - -
-
- -
- -
- - - -
-   - {{user.name}} - (already member in this group) -
-
-
-
-
-
-
- - - - - - - -
- - - {{member.name}} - -
- -
- No members. -
-
-
-
-
\ No newline at end of file diff --git a/client/app/pages/groups/show.js b/client/app/pages/groups/show.js deleted file mode 100644 index d489037d..00000000 --- a/client/app/pages/groups/show.js +++ /dev/null @@ -1,61 +0,0 @@ -import { includes } from 'lodash'; -import template from './show.html'; - -function GroupCtrl($scope, $routeParams, $http, currentUser, Group, User) { - $scope.currentUser = currentUser; - $scope.group = Group.get({ id: $routeParams.groupId }); - $scope.members = Group.members({ id: $routeParams.groupId }); - $scope.newMember = {}; - - $scope.findUser = (search) => { - if (search === '') { - $scope.foundUsers = []; - return; - } - - User.query({ q: search }, (response) => { - const users = response.results; - const existingIds = $scope.members.map(m => m.id); - users.forEach((user) => { - user.alreadyMember = includes(existingIds, user.id); - }); - $scope.foundUsers = users; - }); - }; - - $scope.addMember = (user) => { - // Clear selection, to clear up the input control. - $scope.newMember.selected = undefined; - - $http.post(`api/groups/${$routeParams.groupId}/members`, { user_id: user.id }).success(() => { - $scope.members.unshift(user); - user.alreadyMember = true; - }); - }; - - $scope.removeMember = (member) => { - $http.delete(`api/groups/${$routeParams.groupId}/members/${member.id}`).success(() => { - $scope.members = $scope.members.filter(m => m !== member); - - if ($scope.foundUsers) { - $scope.foundUsers.forEach((user) => { - if (user.id === member.id) { - user.alreadyMember = false; - } - }); - } - }); - }; -} - -export default function init(ngModule) { - ngModule.controller('GroupCtrl', GroupCtrl); - return { - '/groups/:groupId': { - template, - controller: 'GroupCtrl', - }, - }; -} - -init.init = true; diff --git a/client/app/pages/users/UsersList.jsx b/client/app/pages/users/UsersList.jsx index 12f919b1..2a35728f 100644 --- a/client/app/pages/users/UsersList.jsx +++ b/client/app/pages/users/UsersList.jsx @@ -6,6 +6,7 @@ import { react2angular } from 'react2angular'; import Button from 'antd/lib/button'; import { Paginator } from '@/components/Paginator'; import DynamicComponent from '@/components/DynamicComponent'; +import { UserPreviewCard } from '@/components/PreviewCard'; import { wrap as itemsList, ControllerType } from '@/components/items-list/ItemsList'; import { ResourceItemsSource } from '@/components/items-list/classes/ItemsSource'; @@ -76,13 +77,7 @@ class UsersList extends React.Component { listColumns = [ Columns.custom.sortable((text, user) => ( -
- {user.name} -
- {user.name} -
{user.email}
-
-
+ ), { title: 'Name', field: 'name', diff --git a/client/app/services/group.js b/client/app/services/group.js index 6751f9dc..e10aca9e 100644 --- a/client/app/services/group.js +++ b/client/app/services/group.js @@ -4,12 +4,29 @@ function GroupService($resource) { const actions = { get: { method: 'GET', cache: false, isArray: false }, query: { method: 'GET', cache: false, isArray: true }, + members: { method: 'GET', cache: false, isArray: true, url: 'api/groups/:id/members', }, + addMember: { + method: 'POST', url: 'api/groups/:id/members', + }, + removeMember: { + method: 'DELETE', url: 'api/groups/:id/members/:userId', + }, + dataSources: { method: 'GET', cache: false, isArray: true, url: 'api/groups/:id/data_sources', }, + addDataSource: { + method: 'POST', url: 'api/groups/:id/data_sources', + }, + removeDataSource: { + method: 'DELETE', url: 'api/groups/:id/data_sources/:dataSourceId', + }, + updateDataSource: { + method: 'POST', url: 'api/groups/:id/data_sources/:dataSourceId', + }, }; return $resource('api/groups/:id', { id: '@id' }, actions); } diff --git a/client/app/services/navigateTo.js b/client/app/services/navigateTo.js index 532761df..52a2d567 100644 --- a/client/app/services/navigateTo.js +++ b/client/app/services/navigateTo.js @@ -1,9 +1,12 @@ import { isString } from 'lodash'; import { $location, $rootScope } from '@/services/ng'; -export default function navigateTo(url) { +export default function navigateTo(url, replace = false) { if (isString(url)) { $location.url(url); + if (replace) { + $location.replace(); + } $rootScope.$applyAsync(); } } diff --git a/client/app/services/ng.js b/client/app/services/ng.js index 3f96cd90..e48cd00e 100644 --- a/client/app/services/ng.js +++ b/client/app/services/ng.js @@ -2,6 +2,7 @@ export let $http = null; // eslint-disable-line import/no-mutable-exports export let $sanitize = null; // eslint-disable-line import/no-mutable-exports export let $location = null; // eslint-disable-line import/no-mutable-exports export let $route = null; // eslint-disable-line import/no-mutable-exports +export let $routeParams = null; // eslint-disable-line import/no-mutable-exports export let $q = null; // eslint-disable-line import/no-mutable-exports export let $rootScope = null; // eslint-disable-line import/no-mutable-exports export let $uibModal = null; // eslint-disable-line import/no-mutable-exports @@ -13,6 +14,7 @@ export default function init(ngModule) { $sanitize = $injector.get('$sanitize'); $location = $injector.get('$location'); $route = $injector.get('$route'); + $routeParams = $injector.get('$routeParams'); $q = $injector.get('$q'); $rootScope = $injector.get('$rootScope'); $uibModal = $injector.get('$uibModal'); diff --git a/cypress/integration/group/edit_group_spec.js b/cypress/integration/group/edit_group_spec.js index def48ee2..e51b6731 100644 --- a/cypress/integration/group/edit_group_spec.js +++ b/cypress/integration/group/edit_group_spec.js @@ -6,7 +6,7 @@ describe('Edit Group', () => { it('renders the page and takes a screenshot', () => { cy.getByTestId('Group').within(() => { - cy.get('h2').should('contain', 'admin'); + cy.get('h3').should('contain', 'admin'); cy.get('td').should('contain', 'Example Admin'); });