mirror of
https://github.com/valitydev/redash.git
synced 2024-11-07 01:25:16 +00:00
[Feature] Migrate Group List and Details pages to React (#3411)
This commit is contained in:
parent
8679b8756e
commit
33b8bd27eb
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -73,6 +73,10 @@ strong {
|
||||
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resize-vertical {
|
||||
resize: vertical !important;
|
||||
transition: height 0s !important;
|
||||
|
@ -146,6 +146,8 @@
|
||||
Width
|
||||
-----------------------------------------------------------*/
|
||||
.w-100 { width: 100% !important; }
|
||||
.w-50 { width: 50% !important; }
|
||||
.w-25 { width: 25% !important; }
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
|
79
client/app/components/PreviewCard.jsx
Normal file
79
client/app/components/PreviewCard.jsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// PreviewCard
|
||||
|
||||
export function PreviewCard({ imageUrl, title, body, children, className, ...props }) {
|
||||
return (
|
||||
<div {...props} className={className + ' w-100 d-flex align-items-center'}>
|
||||
<img src={imageUrl} width="32" height="32" className="profile__image--settings m-r-5" alt="Logo/Avatar" />
|
||||
<div className="flex-fill">
|
||||
<div>{title}</div>
|
||||
{body && <div className="text-muted">{body}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? <a href={'users/' + user.id}>{user.name}</a> : user.name;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
|
||||
{children}
|
||||
</PreviewCard>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? <a href={'data_sources/' + dataSource.id}>{dataSource.name}</a> : dataSource.name;
|
||||
return <PreviewCard {...props} imageUrl={imageUrl} title={title}>{children}</PreviewCard>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
184
client/app/components/SelectItemsDialog.jsx
Normal file
184
client/app/components/SelectItemsDialog.jsx
Normal file
@ -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<Items[]> 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<any>
|
||||
};
|
||||
|
||||
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 (
|
||||
<List.Item
|
||||
className={classNames('p-l-10', 'p-r-10', { clickable: !isDisabled, disabled: isDisabled }, className)}
|
||||
onClick={isDisabled ? null : () => this.toggleItem(item)}
|
||||
>
|
||||
{content}
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dialog, dialogTitle, inputPlaceholder, selectedItemsTitle } = this.props;
|
||||
const { loading, saveInProgress, items, selected } = this.state;
|
||||
const hasResults = items.length > 0;
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
width="80%"
|
||||
title={dialogTitle}
|
||||
okText="Save"
|
||||
okButtonProps={{
|
||||
loading: saveInProgress,
|
||||
disabled: selected.length === 0,
|
||||
}}
|
||||
onOk={() => this.save()}
|
||||
>
|
||||
<div className="d-flex align-items-center m-b-10">
|
||||
<div className="w-50 m-r-10">
|
||||
<Input.Search
|
||||
defaultValue={this.state.searchTerm}
|
||||
onChange={event => this.search(event.target.value)}
|
||||
placeholder={inputPlaceholder}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="w-50 m-l-10">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-stretch" style={{ minHeight: '30vh', maxHeight: '50vh' }}>
|
||||
<div className="w-50 m-r-10 scrollbox">
|
||||
{loading && <LoadingState className="" />}
|
||||
{!loading && !hasResults && (
|
||||
<BigMessage icon="fa-search" message="No items match your search." className="" />
|
||||
)}
|
||||
{!loading && hasResults && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
renderItem={item => this.renderItem(item, false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-50 m-l-10 scrollbox">
|
||||
{(selected.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={selected}
|
||||
renderItem={item => this.renderItem(item, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default wrapDialog(SelectItemsDialog);
|
39
client/app/components/groups/CreateGroupDialog.jsx
Normal file
39
client/app/components/groups/CreateGroupDialog.jsx
Normal file
@ -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 (
|
||||
<Modal {...dialog.props} title="Create a New Group" okText="Create" onOk={() => this.save()}>
|
||||
<Input
|
||||
className="form-control"
|
||||
defaultValue={this.state.name}
|
||||
onChange={event => this.setState({ name: event.target.value })}
|
||||
onPressEnter={() => this.save()}
|
||||
placeholder="Group Name"
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default wrapDialog(CreateGroupDialog);
|
56
client/app/components/groups/DeleteGroupButton.jsx
Normal file
56
client/app/components/groups/DeleteGroupButton.jsx
Normal file
@ -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 = (
|
||||
<Button {...props} type="danger" onClick={event => deleteGroup(event, group, onClick)}>{children}</Button>
|
||||
);
|
||||
|
||||
if (isString(title) && (title !== '')) {
|
||||
return <Tooltip placement="top" title={title} mouseLeaveDelay={0}>{button}</Tooltip>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
72
client/app/components/groups/DetailsPageSidebar.jsx
Normal file
72
client/app/components/groups/DetailsPageSidebar.jsx
Normal file
@ -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 (
|
||||
<React.Fragment>
|
||||
<Sidebar.Menu items={items} selected={controller.params.currentPage} />
|
||||
<Sidebar.PageSizeSelect
|
||||
options={controller.pageSizeOptions}
|
||||
value={controller.itemsPerPage}
|
||||
onChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
|
||||
/>
|
||||
{canAddMembers && (
|
||||
<Button className="w-100 m-t-5" type="primary" onClick={onAddMembersClick}>
|
||||
<i className="fa fa-plus m-r-5" />Add Members
|
||||
</Button>
|
||||
)}
|
||||
{canAddDataSources && (
|
||||
<Button className="w-100 m-t-5" type="primary" onClick={onAddDataSourcesClick}>
|
||||
<i className="fa fa-plus m-r-5" />Add Data Sources
|
||||
</Button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<React.Fragment>
|
||||
<Divider dashed className="m-t-10 m-b-10" />
|
||||
<DeleteGroupButton className="w-100 m-b-15" group={group} onClick={onGroupDeleted}>Delete Group</DeleteGroupButton>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
44
client/app/components/groups/GroupName.jsx
Normal file
44
client/app/components/groups/GroupName.jsx
Normal file
@ -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 (
|
||||
<h3 {...props}>
|
||||
<EditInPlace
|
||||
className="edit-in-place"
|
||||
isEditable={canEdit}
|
||||
ignoreBlanks
|
||||
editor="input"
|
||||
onDone={name => updateGroupName(group, name, onChange)}
|
||||
value={group.name}
|
||||
/>
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
GroupName.propTypes = {
|
||||
group: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
$save: PropTypes.func.isRequired,
|
||||
}),
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
GroupName.defaultProps = {
|
||||
group: null,
|
||||
onChange: () => {},
|
||||
};
|
25
client/app/components/groups/ListItemAddon.jsx
Normal file
25
client/app/components/groups/ListItemAddon.jsx
Normal file
@ -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 <i className="fa fa-remove" />;
|
||||
}
|
||||
if (alreadyInGroup) {
|
||||
return <Tooltip title="Already in this group"><i className="fa fa-check" /></Tooltip>;
|
||||
}
|
||||
return isSelected ? <i className="fa fa-check" /> : <i className="fa fa-angle-double-right" />;
|
||||
}
|
||||
|
||||
ListItemAddon.propTypes = {
|
||||
isSelected: PropTypes.bool,
|
||||
isStaged: PropTypes.bool,
|
||||
alreadyInGroup: PropTypes.bool,
|
||||
};
|
||||
|
||||
ListItemAddon.defaultProps = {
|
||||
isSelected: false,
|
||||
isStaged: false,
|
||||
alreadyInGroup: false,
|
||||
};
|
@ -1,12 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{$ctrl.title}}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form">
|
||||
<input type="text" ng-model="$ctrl.group.name" placeholder="Group Name" class="form-control" autofocus/>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" ng-click="$ctrl.close()">Cancel</button>
|
||||
<button class="btn btn-primary" ng-click="$ctrl.ok()">{{$ctrl.saveButtonText}}</button>
|
||||
</div>
|
@ -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;
|
@ -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: `
|
||||
<h2 class="m-t-0">
|
||||
<edit-in-place class="edit-in-place" is-editable="$ctrl.canEdit()" on-done="$ctrl.saveName"
|
||||
ignore-blanks="true" value="$ctrl.group.name" editor="'input'"></edit-in-place>
|
||||
<button class="btn btn-xs btn-danger" ng-if="$ctrl.canEdit()" ng-click="$ctrl.deleteGroup()">Delete this group</button>
|
||||
</h2>
|
||||
`,
|
||||
replace: true,
|
||||
controller,
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
@ -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
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
<Table
|
||||
className="table-data"
|
||||
className={classNames('table-data', { 'ant-table-headerless': !showHeader })}
|
||||
columns={columns}
|
||||
showHeader={showHeader}
|
||||
dataSource={rows}
|
||||
rowKey={row => row.key}
|
||||
pagination={false}
|
||||
|
@ -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;
|
||||
}
|
||||
|
261
client/app/pages/groups/GroupDataSources.jsx
Normal file
261
client/app/pages/groups/GroupDataSources.jsx
Normal file
@ -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) => (
|
||||
<DataSourcePreviewCard dataSource={datasource} withLink />
|
||||
), {
|
||||
title: 'Name',
|
||||
field: 'name',
|
||||
width: null,
|
||||
}),
|
||||
Columns.custom((text, datasource) => {
|
||||
const menu = (
|
||||
<Menu
|
||||
selectedKeys={[datasource.view_only ? 'viewonly' : 'full']}
|
||||
onClick={item => this.setDataSourcePermissions(item.domEvent, datasource, item.key)}
|
||||
>
|
||||
<Menu.Item key="full">Full Access</Menu.Item>
|
||||
<Menu.Item key="viewonly">View Only</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown trigger={['click']} overlay={menu}>
|
||||
<Button className="w-100" onClick={cancelEvent()}>
|
||||
{datasource.view_only ? 'View Only' : 'Full Access'} <Icon type="down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}, {
|
||||
width: '1%',
|
||||
className: 'p-r-0',
|
||||
isAvailable: () => currentUser.isAdmin,
|
||||
}),
|
||||
Columns.custom((text, datasource) => (
|
||||
<Button className="w-100" type="danger" onClick={event => this.removeGroupDataSource(event, datasource)}>Remove</Button>
|
||||
), {
|
||||
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: (
|
||||
<DataSourcePreviewCard dataSource={item}>
|
||||
<ListItemAddon isSelected={isSelected} alreadyInGroup={alreadyInGroup} />
|
||||
</DataSourcePreviewCard>
|
||||
),
|
||||
isDisabled: alreadyInGroup,
|
||||
className: isSelected || alreadyInGroup ? 'selected' : '',
|
||||
};
|
||||
},
|
||||
renderStagedItem: (item, { isSelected }) => ({
|
||||
content: (
|
||||
<DataSourcePreviewCard dataSource={item}>
|
||||
<ListItemAddon isSelected={isSelected} isStaged />
|
||||
</DataSourcePreviewCard>
|
||||
),
|
||||
}),
|
||||
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 = (
|
||||
<Sidebar
|
||||
controller={controller}
|
||||
group={this.group}
|
||||
items={this.sidebarMenu}
|
||||
canAddDataSources={currentUser.isAdmin}
|
||||
onAddDataSourcesClick={this.addDataSources}
|
||||
onGroupDeleted={() => navigateTo('/groups', true)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-test="Group">
|
||||
<GroupName className="d-block m-t-0 m-b-15" group={this.group} onChange={() => this.forceUpdate()} />
|
||||
<div className="row">
|
||||
<div className="col-md-3 list-control-t">{sidebar}</div>
|
||||
<div className="list-content col-md-9">
|
||||
{!controller.isLoaded && <LoadingState className="" />}
|
||||
{controller.isLoaded && controller.isEmpty && (
|
||||
<div className="text-center">
|
||||
There are no data sources in this group yet.
|
||||
{currentUser.isAdmin && (
|
||||
<div className="m-t-5">
|
||||
<a href="javascript:void(0)" onClick={this.addDataSources}>Click here</a>
|
||||
{' '} to add data sources.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
controller.isLoaded && !controller.isEmpty && (
|
||||
<div className="table-responsive">
|
||||
<ItemsTable
|
||||
items={controller.pageItems}
|
||||
columns={this.listColumns}
|
||||
showHeader={false}
|
||||
onRowClick={this.onTableRowClick}
|
||||
context={this.actions}
|
||||
orderByField={controller.orderByField}
|
||||
orderByReverse={controller.orderByReverse}
|
||||
toggleSorting={controller.toggleSorting}
|
||||
/>
|
||||
<Paginator
|
||||
totalCount={controller.totalItemsCount}
|
||||
itemsPerPage={controller.itemsPerPage}
|
||||
page={controller.page}
|
||||
onChange={page => controller.updatePagination({ page })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="col-md-3 list-control-r-b">{sidebar}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: '<settings-screen><page-group-data-sources on-error="handleError"></page-group-data-sources></settings-screen>',
|
||||
controller($scope, $exceptionHandler) {
|
||||
'ngInject';
|
||||
|
||||
$scope.handleError = $exceptionHandler;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
226
client/app/pages/groups/GroupMembers.jsx
Normal file
226
client/app/pages/groups/GroupMembers.jsx
Normal file
@ -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) => (
|
||||
<UserPreviewCard user={user} withLink />
|
||||
), {
|
||||
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 <Button className="w-100" type="danger" onClick={event => this.removeGroupMember(event, user)}>Remove</Button>;
|
||||
}, {
|
||||
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: (
|
||||
<UserPreviewCard user={item}>
|
||||
<ListItemAddon isSelected={isSelected} alreadyInGroup={alreadyInGroup} />
|
||||
</UserPreviewCard>
|
||||
),
|
||||
isDisabled: alreadyInGroup,
|
||||
className: isSelected || alreadyInGroup ? 'selected' : '',
|
||||
};
|
||||
},
|
||||
renderStagedItem: (item, { isSelected }) => ({
|
||||
content: (
|
||||
<UserPreviewCard user={item}>
|
||||
<ListItemAddon isSelected={isSelected} isStaged />
|
||||
</UserPreviewCard>
|
||||
),
|
||||
}),
|
||||
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 = (
|
||||
<Sidebar
|
||||
controller={controller}
|
||||
group={this.group}
|
||||
items={this.sidebarMenu}
|
||||
canAddMembers={currentUser.isAdmin}
|
||||
onAddMembersClick={this.addMembers}
|
||||
onGroupDeleted={() => navigateTo('/groups', true)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-test="Group">
|
||||
<GroupName className="d-block m-t-0 m-b-15" group={this.group} onChange={() => this.forceUpdate()} />
|
||||
<div className="row">
|
||||
<div className="col-md-3 list-control-t">{sidebar}</div>
|
||||
<div className="list-content col-md-9">
|
||||
{!controller.isLoaded && <LoadingState className="" />}
|
||||
{controller.isLoaded && controller.isEmpty && (
|
||||
<div className="text-center">
|
||||
There are no members in this group yet.
|
||||
{currentUser.isAdmin && (
|
||||
<div className="m-t-5">
|
||||
<a href="javascript:void(0)" onClick={this.addMembers}>Click here</a>
|
||||
{' '} to add members.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
controller.isLoaded && !controller.isEmpty && (
|
||||
<div className="table-responsive">
|
||||
<ItemsTable
|
||||
items={controller.pageItems}
|
||||
columns={this.listColumns}
|
||||
showHeader={false}
|
||||
onRowClick={this.onTableRowClick}
|
||||
context={this.actions}
|
||||
orderByField={controller.orderByField}
|
||||
orderByReverse={controller.orderByReverse}
|
||||
toggleSorting={controller.toggleSorting}
|
||||
/>
|
||||
<Paginator
|
||||
totalCount={controller.totalItemsCount}
|
||||
itemsPerPage={controller.itemsPerPage}
|
||||
page={controller.page}
|
||||
onChange={page => controller.updatePagination({ page })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="col-md-3 list-control-r-b">{sidebar}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: '<settings-screen><page-group-members on-error="handleError"></page-group-members></settings-screen>',
|
||||
controller($scope, $exceptionHandler) {
|
||||
'ngInject';
|
||||
|
||||
$scope.handleError = $exceptionHandler;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
168
client/app/pages/groups/GroupsList.jsx
Normal file
168
client/app/pages/groups/GroupsList.jsx
Normal file
@ -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) => (
|
||||
<div>
|
||||
<a href={'groups/' + group.id}>{group.name}</a>
|
||||
{(group.type === 'builtin') && <span className="label label-default m-l-10">built-in</span>}
|
||||
</div>
|
||||
), {
|
||||
field: 'name',
|
||||
width: null,
|
||||
}),
|
||||
Columns.custom((text, group) => (
|
||||
<Button.Group>
|
||||
<Button href={`groups/${group.id}`} onClick={e => e.stopPropagation()}>Members</Button>
|
||||
{currentUser.isAdmin && (
|
||||
<Button href={`groups/${group.id}/data_sources`} onClick={e => e.stopPropagation()}>Data Sources</Button>
|
||||
)}
|
||||
</Button.Group>
|
||||
), {
|
||||
width: '1%',
|
||||
className: 'text-nowrap',
|
||||
}),
|
||||
Columns.custom((text, group) => {
|
||||
const canRemove = group.type !== 'builtin';
|
||||
return (
|
||||
<DeleteGroupButton
|
||||
className="w-100"
|
||||
disabled={!canRemove}
|
||||
group={group}
|
||||
title={canRemove ? null : 'Cannot delete built-in group'}
|
||||
onClick={() => this.onGroupDeleted()}
|
||||
>
|
||||
Delete
|
||||
</DeleteGroupButton>
|
||||
);
|
||||
}, {
|
||||
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 (
|
||||
<div data-test="GroupList">
|
||||
{currentUser.isAdmin && (
|
||||
<div className="m-b-15">
|
||||
<Button type="primary" onClick={this.createGroup}>
|
||||
<i className="fa fa-plus m-r-5" />
|
||||
New Group
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!controller.isLoaded && <LoadingState className="" />}
|
||||
{controller.isLoaded && controller.isEmpty && <EmptyState className="" />}
|
||||
{
|
||||
controller.isLoaded && !controller.isEmpty && (
|
||||
<div className="table-responsive">
|
||||
<ItemsTable
|
||||
items={controller.pageItems}
|
||||
columns={this.listColumns}
|
||||
showHeader={false}
|
||||
onRowClick={this.onTableRowClick}
|
||||
context={this.actions}
|
||||
orderByField={controller.orderByField}
|
||||
orderByReverse={controller.orderByReverse}
|
||||
toggleSorting={controller.toggleSorting}
|
||||
/>
|
||||
<Paginator
|
||||
totalCount={controller.totalItemsCount}
|
||||
itemsPerPage={controller.itemsPerPage}
|
||||
page={controller.page}
|
||||
onChange={page => controller.updatePagination({ page })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: '<settings-screen><page-groups-list on-error="handleError"></page-groups-list></settings-screen>',
|
||||
controller($scope, $exceptionHandler) {
|
||||
'ngInject';
|
||||
|
||||
$scope.handleError = $exceptionHandler;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
@ -1,55 +0,0 @@
|
||||
<settings-screen>
|
||||
<group-name group="group"></group-name>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<ul class="tab-nav">
|
||||
<li role="presentation"><a href="groups/{{group.id}}">Members</a></li>
|
||||
<li role="presentation" class="active"><a href="groups/{{group.id}}/data_sources">Data Sources</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-lg-8">
|
||||
<ui-select ng-model="newDataSource.selected" on-select="addDataSource($item)">
|
||||
<ui-select-match placeholder="Add Data Source"></ui-select-match>
|
||||
<ui-select-choices repeat="dataSource in foundDataSources | filter:$select.search"
|
||||
refresh="findDataSource($select.search)"
|
||||
refresh-delay="0">
|
||||
<div>
|
||||
{{dataSource.name}}
|
||||
</div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row m-t-5">
|
||||
<div class="col-lg-12">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="dataSource in dataSources">
|
||||
<td> {{dataSource.name}}</td>
|
||||
<td width="280px" class="text-right">
|
||||
<div class="btn-group" uib-dropdown>
|
||||
<button type="button" class="btn btn-sm btn-default dropdown-toggle" uib-dropdown-toggle ng-if="dataSource.view_only">
|
||||
View Only <span class="caret"></span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-success dropdown-toggle" uib-dropdown-toggle ng-if="!dataSource.view_only">
|
||||
Full Access <span class="caret"></span>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu" role="menu" uib-dropdown-menu>
|
||||
<li><a ng-click="changePermission(dataSource, false)"><small ng-if="!dataSource.view_only"><span class="fa fa-check"/></small> Full Access<br/></a></li>
|
||||
<li><a ng-click="changePermission(dataSource, true)"><small ng-if="dataSource.view_only"><span class="fa fa-check"/></small> View Only</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="pull-right btn btn-sm btn-danger" ng-click="removeDataSource(dataSource)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
@ -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;
|
@ -1,24 +0,0 @@
|
||||
<settings-screen>
|
||||
<div class="row" data-test="GroupList">
|
||||
<div class="col-md-12">
|
||||
<p ng-if="currentUser.hasPermission('admin')">
|
||||
<a ng-click="newGroup()" class="btn btn-default"><i class="fa fa-plus"></i> New Group</a>
|
||||
</p>
|
||||
<table class="table table-border table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="row in groups.getPageRows()">
|
||||
<td>
|
||||
<a href="groups/{{row.id}}">{{row.name}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<paginator paginator="groups"></paginator>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
@ -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;
|
@ -1,53 +0,0 @@
|
||||
<settings-screen>
|
||||
<div data-test="Group">
|
||||
<group-name group="group"></group-name>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<ul class="tab-nav">
|
||||
<li role="presentation" class="active">
|
||||
<a href="groups/{{group.id}}">Members</a>
|
||||
</li>
|
||||
<li role="presentation" ng-if="currentUser.isAdmin">
|
||||
<a href="groups/{{group.id}}/data_sources">Data Sources</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8" ng-if="currentUser.isAdmin">
|
||||
<ui-select ng-model="newMember.selected" on-select="addMember($item)">
|
||||
<ui-select-match placeholder="Add New Member"></ui-select-match>
|
||||
<ui-select-choices repeat="user in foundUsers | filter:$select.search" refresh="findUser($select.search)" refresh-delay="0"
|
||||
ui-disable-choice="user.alreadyMember">
|
||||
<div class="d-flex align-items-center">
|
||||
<img ng-src="{{ user.profile_image_url }}" class="profile__image" height="24px">
|
||||
<span ng-class="{'text-muted': user.is_disabled}">{{user.name}}</span>
|
||||
<small ng-if="user.alreadyMember">(already member in this group)</small>
|
||||
</div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row m-t-5">
|
||||
<div class="col-lg-12">
|
||||
<table class="table table-condensed table-hover" ng-show="members">
|
||||
<tbody>
|
||||
<tr ng-repeat="member in members">
|
||||
<td width="50px">
|
||||
<img ng-src="{{ member.profile_image_url }}" class="profile__image" height="40px" />
|
||||
</td>
|
||||
<td ng-class="{'text-muted': member.is_disabled}">
|
||||
<a href="users/{{member.id}}">{{member.name}}</a>
|
||||
<button class="pull-right btn btn-sm btn-danger" ng-click="removeMember(member)" ng-if="currentUser.isAdmin && (group.type != 'builtin' || currentUser.id != member.id)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div ng-hide="members.length">
|
||||
No members.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
@ -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;
|
@ -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) => (
|
||||
<div className="d-flex align-items-center">
|
||||
<img src={user.profile_image_url} height="32px" className="profile__image--settings m-r-5" alt={user.name} />
|
||||
<div>
|
||||
<a href={'users/' + user.id} className="{'text-muted': user.is_disabled}">{user.name}</a>
|
||||
<div className="text-muted">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<UserPreviewCard user={user} withLink />
|
||||
), {
|
||||
title: 'Name',
|
||||
field: 'name',
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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');
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user