[Feature] Migrate Group List and Details pages to React (#3411)

This commit is contained in:
Levko Kravets 2019-02-22 14:47:48 +02:00 committed by GitHub
parent 8679b8756e
commit 33b8bd27eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1247 additions and 401 deletions

View File

@ -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
}
}

View File

@ -73,6 +73,10 @@ strong {
}
.clickable {
cursor: pointer;
}
.resize-vertical {
resize: vertical !important;
transition: height 0s !important;

View File

@ -146,6 +146,8 @@
Width
-----------------------------------------------------------*/
.w-100 { width: 100% !important; }
.w-50 { width: 50% !important; }
.w-25 { width: 25% !important; }
/* --------------------------------------------------------

View 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,
};

View 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);

View 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);

View 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,
};

View 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,
};

View 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: () => {},
};

View 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,
};

View File

@ -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>

View File

@ -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;

View File

@ -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>&nbsp;
<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;

View File

@ -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

View File

@ -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,
});
}
}

View File

@ -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,
};
}

View File

@ -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}

View File

@ -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;
}

View 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;

View 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;

View 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;

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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">&nbsp;
<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>

View File

@ -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;

View File

@ -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',

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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');

View File

@ -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');
});