diff --git a/src/components/Exercises/ExerciseGroups/ExerciseGroups.js b/src/components/Exercises/ExerciseGroups/ExerciseGroups.js index 4ad7eb612..b64aa376c 100644 --- a/src/components/Exercises/ExerciseGroups/ExerciseGroups.js +++ b/src/components/Exercises/ExerciseGroups/ExerciseGroups.js @@ -1,6 +1,5 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import { Table, Modal } from 'react-bootstrap'; @@ -8,7 +7,7 @@ import Box from '../../widgets/Box'; import Icon, { GroupIcon, LoadingIcon } from '../../icons'; import GroupsNameContainer from '../../../containers/GroupsNameContainer'; import Button from '../../widgets/TheButton'; -import GroupTree from '../../Groups/GroupTree'; +import GroupsTreeContainer from '../../../containers/GroupsTreeContainer'; import { arrayToObject, identity } from '../../../helpers/common'; class ExerciseGroups extends Component { @@ -52,12 +51,12 @@ class ExerciseGroups extends Component { ); }; - buttonsCreator = attachedGroupsIds => groupId => { - return {attachedGroupsIds[groupId] ? this.detachButton(groupId) : this.attachButton(groupId)}; + buttonsCreator = attachedGroupsIds => group => { + return {attachedGroupsIds[group.id] ? this.detachButton(group.id) : this.attachButton(group.id)}; }; render() { - const { groupsIds = [], rootGroupId, groups, showButtons = false } = this.props; + const { groupsIds = [], showButtons = false } = this.props; return ( } @@ -100,9 +99,7 @@ class ExerciseGroups extends Component { - true))} /> @@ -122,8 +119,6 @@ ExerciseGroups.propTypes = { detachingGroupId: PropTypes.string, attachExerciseToGroup: PropTypes.func.isRequired, detachExerciseFromGroup: PropTypes.func.isRequired, - rootGroupId: PropTypes.string.isRequired, - groups: ImmutablePropTypes.map, }; export default ExerciseGroups; diff --git a/src/components/Groups/GroupTree/GroupTree.js b/src/components/Groups/GroupTree/GroupTree.js deleted file mode 100644 index 24f002d67..000000000 --- a/src/components/Groups/GroupTree/GroupTree.js +++ /dev/null @@ -1,208 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage, injectIntl } from 'react-intl'; -import { Link } from 'react-router-dom'; -import { OverlayTrigger, Tooltip } from 'react-bootstrap'; - -import Button, { TheButtonGroup } from '../../widgets/TheButton'; -import { TreeView, TreeViewItem } from '../../widgets/TreeView'; -import { isReady, getJsData } from '../../../redux/helpers/resourceManager'; -import GroupsName from '../GroupsName'; -import { computeVisibleGroupsMap, computeEditableGroupsMap } from '../../helpers/group.js'; -import { getLocalizedResourceName } from '../../../helpers/localizedData'; -import { GroupIcon, AssignmentsIcon, EditIcon } from '../../icons'; -import withLinks from '../../../helpers/withLinks'; - -const conditionalEmphasize = (content, condition) => (condition ? {content} : content); - -class GroupTree extends Component { - renderLoading = level => ( - - } - /> - - ); - - renderButtons = (groupId, permissionHints, isRoot, archived, directlyArchived) => { - const { - buttonsCreator = null, - links: { GROUP_EDIT_URI_FACTORY, GROUP_INFO_URI_FACTORY, GROUP_DETAIL_URI_FACTORY }, - } = this.props; - return buttonsCreator ? ( - buttonsCreator(groupId, isRoot, permissionHints, archived, directlyArchived) - ) : ( - - {permissionHints && permissionHints.update && ( - - - - }> - - - - - )} - - - - - }> - - - - - - {permissionHints && permissionHints.viewDetail && ( - - - - }> - - - - - )} - - ); - }; - - renderChildGroups = (childGroups, visibleGroupsMap, level) => { - const { - isOpen = false, - groups, - intl: { locale }, - } = this.props; - return childGroups - .filter(id => visibleGroupsMap[id]) - .sort((id1, id2) => { - const name1 = getLocalizedResourceName(groups.get(id1), locale); - const name2 = getLocalizedResourceName(groups.get(id2), locale); - return name1 !== undefined && name2 !== undefined ? name1.localeCompare(name2, locale) : 0; - }) - .map(id => ( - - )); - }; - - render() { - const { - id, - level = 0, - isOpen = false, - groups, - onlyEditable = false, - currentGroupId = null, - visibleGroupsMap = null, - ancestralPath = null, - forceRootButtons = false, - } = this.props; - - const onAncestralPath = ancestralPath && ancestralPath.length > 0; - const group = onAncestralPath ? groups.get(ancestralPath[0]) : groups.get(id); - if (!group || !isReady(group)) { - return this.renderLoading(level); - } - - const { - id: groupId, - name, - localizedTexts, - childGroups, - primaryAdminsIds, - organizational, - archived, - directlyArchived, - public: isPublic, - permissionHints, - } = getJsData(group); - - const actualVisibleGroupsMap = - visibleGroupsMap !== null - ? visibleGroupsMap - : onlyEditable - ? computeEditableGroupsMap(groups) - : computeVisibleGroupsMap(groups); - - return ( - - {level !== 0 || onAncestralPath ? ( - , - currentGroupId === groupId - )} - id={groupId} - level={level} - admins={primaryAdminsIds} - organizational={organizational} - archived={archived} - isPublic={isPublic} - forceOpen={onAncestralPath} - isOpen={currentGroupId === groupId || isOpen} - actions={ - (currentGroupId !== groupId || forceRootButtons) && permissionHints.viewDetail - ? // this is inacurate, but public groups are visible to students who cannot see detail until they join - this.renderButtons(groupId, permissionHints, currentGroupId === groupId, archived, directlyArchived) - : undefined - }> - {onAncestralPath - ? [ - , - ] - : this.renderChildGroups(childGroups, actualVisibleGroupsMap, level)} - - ) : ( - this.renderChildGroups(childGroups, actualVisibleGroupsMap, 0) - )} - - ); - } -} - -GroupTree.propTypes = { - id: PropTypes.string.isRequired, - groups: PropTypes.object.isRequired, - level: PropTypes.number, - isOpen: PropTypes.bool, - onlyEditable: PropTypes.bool, - currentGroupId: PropTypes.string, - visibleGroupsMap: PropTypes.object, - ancestralPath: PropTypes.array, - buttonsCreator: PropTypes.func, - forceRootButtons: PropTypes.bool, - links: PropTypes.object, - intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired, -}; - -export default withLinks(injectIntl(GroupTree)); diff --git a/src/components/Groups/GroupTree/index.js b/src/components/Groups/GroupTree/index.js deleted file mode 100644 index 0e1f2f375..000000000 --- a/src/components/Groups/GroupTree/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import GroupTree from './GroupTree'; -export default GroupTree; diff --git a/src/components/Groups/GroupsTree/GroupsTree.js b/src/components/Groups/GroupsTree/GroupsTree.js new file mode 100644 index 000000000..7d01fcdf0 --- /dev/null +++ b/src/components/Groups/GroupsTree/GroupsTree.js @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; + +import GroupsTreeNode from './GroupsTreeNode'; +import Button, { TheButtonGroup } from '../../widgets/TheButton'; + +import { GroupIcon, AssignmentsIcon, EditIcon } from '../../icons'; +import withLinks from '../../../helpers/withLinks'; +import { hasPermissions } from '../../../helpers/common'; + +import './styles.css'; + +const defaultButtonsCreator = ( + group, + selectedGroupId, + { GROUP_EDIT_URI_FACTORY, GROUP_INFO_URI_FACTORY, GROUP_DETAIL_URI_FACTORY } +) => + group.id !== selectedGroupId ? ( + + {hasPermissions(group, 'update') && ( + + + + }> + + + + + )} + + + + + }> + + + + + + {hasPermissions(group, 'viewDetail') && ( + + + + }> + + + + + )} + + ) : null; + +const GroupsTree = React.memo( + ({ + groups, + selectedGroupId = null, + autoloadAuthors = false, + isExpanded = false, + buttonsCreator = defaultButtonsCreator, + }) => ( +
    + {groups.map(group => ( + + ))} +
+ ) +); + +GroupsTree.propTypes = { + groups: PropTypes.array.isRequired, + selectedGroupId: PropTypes.string, + autoloadAuthors: PropTypes.bool, + isExpanded: PropTypes.bool, + buttonsCreator: PropTypes.func, + links: PropTypes.object, + intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired, +}; + +export default withLinks(injectIntl(GroupsTree)); diff --git a/src/components/Groups/GroupsTree/GroupsTreeNode.js b/src/components/Groups/GroupsTree/GroupsTreeNode.js new file mode 100644 index 000000000..8781fe5ad --- /dev/null +++ b/src/components/Groups/GroupsTree/GroupsTreeNode.js @@ -0,0 +1,173 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; +import Collapse from 'react-collapse'; +import { defaultMemoize } from 'reselect'; + +import GroupsTree from './GroupsTree'; +import GroupsName from '../GroupsName'; +import UsersNameContainer from '../../../containers/UsersNameContainer'; +import Icon, { GroupIcon, LoadingIcon } from '../../icons'; +import withLinks from '../../../helpers/withLinks'; +import { isRegularObject } from '../../../helpers/common'; + +/** + * Assemble the right CSS classes for the list item. + * @param {bool} clickable whether the item can be clicked on + * @param {bool} archived whether the group i + */ +const prepareClassList = defaultMemoize((clickable, archived) => { + const classes = []; + if (clickable) { + classes.push('clickable'); + } + if (archived) { + classes.push('text-muted'); + } + return classes.join(' '); +}); + +const DEFAULT_ICON = ['far', 'square']; + +const clickEventDisipator = ev => ev.stopPropagation(); + +const GroupsTreeNode = React.memo( + ({ group, selectedGroupId = null, autoloadAuthors = false, isExpanded = false, buttonsCreator, links }) => { + const { + id, + localizedTexts, + primaryAdmins = [], + organizational, + archived, + isPublic, + childGroups = [], + alwaysVisible = false, + } = group; + + const leafNode = childGroups.length === 0; + const clickable = !leafNode && !alwaysVisible; + const [isOpen, setOpen] = useState(isExpanded); + + return ( +
  • + setOpen(!isOpen) : undefined}> + + + + + + + {primaryAdmins && primaryAdmins.length > 0 && ( + + ( + + {primaryAdmins.map(admin => ( + + {isRegularObject(admin) ? ( + + {admin.firstName} {admin.lastName} + + ) : autoloadAuthors ? ( + + ) : ( + + )} + + ))} + + ) + + )} + {organizational && ( + + + + }> + + + )} + {archived && ( + + + + }> + + + )} + {isPublic && ( + + + + }> + + + )} + + {buttonsCreator && ( + + {buttonsCreator(group, selectedGroupId, links)} + + )} + + + {!leafNode && ( + + + + )} +
  • + ); + } +); + +GroupsTreeNode.propTypes = { + group: PropTypes.shape({ + id: PropTypes.string, + localizedTexts: PropTypes.array.isRequired, + primaryAdmins: PropTypes.array, + organizational: PropTypes.bool, + archived: PropTypes.bool, + isPublic: PropTypes.bool, + childGroups: PropTypes.array, + alwaysVisible: PropTypes.bool, + }), + selectedGroupId: PropTypes.string, + autoloadAuthors: PropTypes.bool, + isExpanded: PropTypes.bool, + buttonsCreator: PropTypes.func, + links: PropTypes.object, +}; + +export default withLinks(GroupsTreeNode); diff --git a/src/components/Groups/GroupsTree/index.js b/src/components/Groups/GroupsTree/index.js new file mode 100644 index 000000000..aaf262043 --- /dev/null +++ b/src/components/Groups/GroupsTree/index.js @@ -0,0 +1,2 @@ +import GroupsTree from './GroupsTree'; +export default GroupsTree; diff --git a/src/components/Groups/GroupsTree/styles.css b/src/components/Groups/GroupsTree/styles.css new file mode 100644 index 000000000..b42ba20cf --- /dev/null +++ b/src/components/Groups/GroupsTree/styles.css @@ -0,0 +1,17 @@ +ul.groupTree.nav.flex-column > * { + border-bottom: 0 !important; +} + +ul.groupTree.nav.flex-column ul.groupTree.nav.flex-column > * { + padding-left: 1.5rem; +} + +ul.groupTree.nav.flex-column > li > span { + display: block; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +ul.groupTree.nav.flex-column > li > span:hover { + background-color: rgba(0,0,0,.075); +} diff --git a/src/components/forms/FilterArchiveGroupsForm/FilterArchiveGroupsForm.js b/src/components/forms/FilterArchiveGroupsForm/FilterArchiveGroupsForm.js index adaa8acaa..e6de15bd5 100644 --- a/src/components/forms/FilterArchiveGroupsForm/FilterArchiveGroupsForm.js +++ b/src/components/forms/FilterArchiveGroupsForm/FilterArchiveGroupsForm.js @@ -39,7 +39,7 @@ const FilterArchiveGroupsForm = ({ } /> - + - -
    - , - success: , - }} - /> -
    + + , + success: , + }} + /> diff --git a/src/components/helpers/group.js b/src/components/helpers/group.js deleted file mode 100644 index db9a11a02..000000000 --- a/src/components/helpers/group.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defaultMemoize } from 'reselect'; - -const computeMap = (groups, filter) => { - const res = {}; - groups.filter(filter).forEach((group, id) => { - res[id] = true; - (group.getIn(['data', 'parentGroupsIds'], []) || []).forEach(parentId => { - res[parentId] = true; - }); - }); - - return res; -}; - -export const computeVisibleGroupsMap = defaultMemoize(groups => - computeMap(groups, group => group.getIn(['data', 'permissionHints', 'viewDetail'])) -); - -export const computeEditableGroupsMap = defaultMemoize(groups => - computeMap(groups, group => group.getIn(['data', 'permissionHints', 'update'])) -); diff --git a/src/components/widgets/TreeView/LevelGap.js b/src/components/widgets/TreeView/LevelGap.js deleted file mode 100644 index f7ed48352..000000000 --- a/src/components/widgets/TreeView/LevelGap.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -const LevelGap = ({ level = 0 }) => ( - -); - -LevelGap.propTypes = { - level: PropTypes.number, -}; - -export default LevelGap; diff --git a/src/components/widgets/TreeView/TreeView.js b/src/components/widgets/TreeView/TreeView.js deleted file mode 100644 index da0fea052..000000000 --- a/src/components/widgets/TreeView/TreeView.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Nav } from 'react-bootstrap'; - -const TreeView = ({ children }) => ; - -TreeView.propTypes = { - children: PropTypes.any.isRequired, -}; - -export default TreeView; diff --git a/src/components/widgets/TreeView/TreeViewInnerNode.js b/src/components/widgets/TreeView/TreeViewInnerNode.js deleted file mode 100644 index 003a8a9d6..000000000 --- a/src/components/widgets/TreeView/TreeViewInnerNode.js +++ /dev/null @@ -1,54 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import Collapse from 'react-collapse'; -import TreeViewLeaf from './TreeViewLeaf'; - -class TreeViewInnerNode extends Component { - state = { - isOpen: false, - isOpenInitial: null, - }; - - static getDerivedStateFromProps(props, state) { - if (state.isOpenInitial !== props.isOpen) { - return { - isOpen: props.isOpen || false, - isOpenInitial: props.isOpen, - }; - } else { - return null; - } - } - - toggleOpen = e => { - e.preventDefault(); - this.setState({ isOpen: !this.state.isOpen }); - }; - - isOpen = () => this.props.forceOpen || this.state.isOpen; - - render() { - const { loading, children, ...props } = this.props; - - return ( -
      - - {children} -
    - ); - } -} - -TreeViewInnerNode.propTypes = { - loading: PropTypes.bool, - isOpen: PropTypes.bool, - forceOpen: PropTypes.bool, - children: PropTypes.any, -}; - -export default TreeViewInnerNode; diff --git a/src/components/widgets/TreeView/TreeViewItem.js b/src/components/widgets/TreeView/TreeViewItem.js deleted file mode 100644 index 49992e429..000000000 --- a/src/components/widgets/TreeView/TreeViewItem.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import TreeViewLeaf from './TreeViewLeaf'; -import TreeViewInnerNode from './TreeViewInnerNode'; - -const TreeViewItem = props => - props.children && props.children.length >= 1 ? : ; - -TreeViewItem.propTypes = { - children: PropTypes.array, -}; - -export default TreeViewItem; diff --git a/src/components/widgets/TreeView/TreeViewLeaf.js b/src/components/widgets/TreeView/TreeViewLeaf.js deleted file mode 100644 index f8596aeaa..000000000 --- a/src/components/widgets/TreeView/TreeViewLeaf.js +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import { OverlayTrigger, Tooltip } from 'react-bootstrap'; - -import Icon, { LoadingIcon, GroupIcon } from '../../icons'; -import LevelGap from './LevelGap'; -import UsersNameContainer from '../../../containers/UsersNameContainer'; - -const TreeViewLeaf = ({ - id, - loading = false, - title, - admins, - organizational, - archived, - isPublic, - icon = ['far', 'square'], - onClick, - level, - actions, -}) => ( -
  • - {/* root group is not displayed */} - - {loading ? : } - - {title} - {admins && admins.length > 0 && ( - -    ( - - - {admins.map(a => ( - - ))} - - - ) - - )} - {organizational && ( - - - - }> - - - )} - {archived && ( - - - - }> - - - )} - {isPublic && ( - - - - }> - - - )} - {actions} -
  • -); - -TreeViewLeaf.propTypes = { - id: PropTypes.string, - loading: PropTypes.bool, - title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired, - admins: PropTypes.array, - organizational: PropTypes.bool, - archived: PropTypes.bool, - isPublic: PropTypes.bool, - icon: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - onClick: PropTypes.func, - level: PropTypes.number.isRequired, - actions: PropTypes.element, -}; - -export default TreeViewLeaf; diff --git a/src/components/widgets/TreeView/index.js b/src/components/widgets/TreeView/index.js deleted file mode 100644 index 0911bb74d..000000000 --- a/src/components/widgets/TreeView/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import TreeView from './TreeView'; -export default TreeView; -export { default as TreeView } from './TreeView'; -export { default as TreeViewItem } from './TreeViewItem'; diff --git a/src/containers/App/recodex.css b/src/containers/App/recodex.css index b8663a66b..766742500 100644 --- a/src/containers/App/recodex.css +++ b/src/containers/App/recodex.css @@ -228,7 +228,6 @@ table.table-hover td.clickable:hover, table.table-hover th.clickable:hover { background-color: rgba(128, 128, 128, 0.1); } - /* * AdminLTE Enhancements and Overrides */ diff --git a/src/containers/GroupsTreeContainer/GroupsTreeContainer.js b/src/containers/GroupsTreeContainer/GroupsTreeContainer.js new file mode 100644 index 000000000..1deafd335 --- /dev/null +++ b/src/containers/GroupsTreeContainer/GroupsTreeContainer.js @@ -0,0 +1,168 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { Nav } from 'react-bootstrap'; +import { defaultMemoize } from 'reselect'; + +import { groupsSelector, notArchivedGroupsSelector } from '../../redux/selectors/groups'; +import { usersSelector } from '../../redux/selectors/users'; +import { selectedInstance } from '../../redux/selectors/instances'; +import { isReady, getJsData } from '../../redux/helpers/resourceManager'; +import GroupsTree from '../../components/Groups/GroupsTree'; +import { LoadingIcon } from '../../components/icons'; + +import { identity, hasPermissions, isRegularObject } from '../../helpers/common'; +import { getLocalizedName } from '../../helpers/localizedData'; + +/** + * Helper function that prepares augmented group object. Child groups are transformed as well recursively + * (ids in childGroups are replaced with objects) and primaryAdmins property with sorted admin names is added. + */ +const prepareGroupObject = ( + groupId, + groups, + users, + locale, + processChildren, + showArchived, + filterGroups = () => true +) => { + const group = getJsData(groups.get(groupId)); + if (!group || (!showArchived && group.archived)) { + return null; + } + + // add admin names + if (users) { + group.primaryAdmins = group.primaryAdminsIds + .map(userId => { + const user = getJsData(users.get(userId)); + return user ? { id: user.id, ...user.name } : userId; + }) + .sort((u1, u2) => { + return isRegularObject(u1) && isRegularObject(u2) + ? u1.lastName.localeCompare(u2.lastName, locale) || u1.firstName.localeCompare(u2.firstName, locale) + : 0; + }); + } + + // add subgroups recursively + if (processChildren) { + group.childGroups = group.childGroups + .map(id => prepareGroupObject(id, groups, users, locale, processChildren, showArchived, filterGroups)) + .filter(identity) + .sort((g1, g2) => { + const name1 = getLocalizedName(g1, locale); + const name2 = getLocalizedName(g2, locale); + return name1 !== undefined && name2 !== undefined ? name1.localeCompare(name2, locale) : 0; + }); + + if (group.childGroups.length === 0 && !filterGroups(group)) { + return null; // the group did not pass the filter + } + } + + return group; +}; + +/** + * Prepares plain-js datastructure that could be fed to GroupsTree component. + * With memoization, this is basically a higher-level selector that creates hiarchial augmented group objects. + */ +const prepareGroupsTree = defaultMemoize( + (groups, users, locale, selectedGroupId, showArchived, onlyEditable, customFilter) => { + let group = prepareGroupObject(selectedGroupId, groups, users, locale, true, showArchived, group => { + return ( + hasPermissions(group, onlyEditable ? 'update' : 'viewDetail') && + (!customFilter || customFilter(group, selectedGroupId)) + ); + }); + + if (!group) { + return []; + } + + // if a nested group is selected, add all its ancestors properly + while (group.parentGroupId) { + const parent = prepareGroupObject(group.parentGroupId, groups, users, locale, false, true); + if (!parent) { + break; + } + + parent.alwaysVisible = true; + parent.childGroups = [group]; + group = parent; + } + + return group.parentGroupId ? [group] : group.childGroups; // root group is not displayed, only its children + } +); + +class GroupsTreeContainer extends Component { + render() { + const { + selectedGroupId = null, + showArchived = false, + onlyEditable = false, + groupsFilter = null, + instance, + groups, + users, + intl: { locale }, + ...props + } = this.props; + + const rootGroupId = instance && instance.getIn(['data', 'rootGroupId'], null); + + if (!isReady(groups.get(selectedGroupId || rootGroupId))) { + return ( + + ); + } + + const groupsTree = + rootGroupId && + prepareGroupsTree( + groups, + users, + locale, + selectedGroupId || rootGroupId, + showArchived, + onlyEditable, + groupsFilter + ); + + return groupsTree ? ( + + ) : ( + + ); + } +} + +GroupsTreeContainer.propTypes = { + selectedGroupId: PropTypes.string, + showArchived: PropTypes.bool, + onlyEditable: PropTypes.bool, + groupsFilter: PropTypes.func, + autoloadAuthors: PropTypes.bool, + isExpanded: PropTypes.bool, + buttonsCreator: PropTypes.func, + instance: ImmutablePropTypes.map, + groups: ImmutablePropTypes.map, + users: ImmutablePropTypes.map, + intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired, +}; + +export default connect((state, { showArchived }) => ({ + instance: selectedInstance(state), + groups: showArchived ? groupsSelector(state) : notArchivedGroupsSelector(state), + users: usersSelector(state), +}))(injectIntl(GroupsTreeContainer)); diff --git a/src/containers/GroupsTreeContainer/index.js b/src/containers/GroupsTreeContainer/index.js new file mode 100644 index 000000000..d407d2b86 --- /dev/null +++ b/src/containers/GroupsTreeContainer/index.js @@ -0,0 +1,2 @@ +import GroupsTreeContainer from './GroupsTreeContainer'; +export default GroupsTreeContainer; diff --git a/src/helpers/common.js b/src/helpers/common.js index e987377b6..7432ff31f 100644 --- a/src/helpers/common.js +++ b/src/helpers/common.js @@ -71,6 +71,13 @@ export const encodeNumId = id => { * Array/Object Helpers */ +/** + * Check whether given value is a regular object, but not array nor null. + * @param {*} obj value to be tested + * @returns {boolean} + */ +export const isRegularObject = obj => typeof obj === 'object' && !Array.isArray(obj) && obj !== null; + /** * Check whether given object is an empty object {}. * @param {*} obj diff --git a/src/locales/cs.json b/src/locales/cs.json index bc2259e73..7cf719d3d 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -944,6 +944,7 @@ "app.groups.joinGroupButton": "Stát se členem", "app.groups.leaveGroupButton": "Opustit skupinu", "app.groups.removeFromGroup": "Odebrat ze skupiny", + "app.groupsTree.noGroups": "Nejsou zde žádné skupiny, na které by stačilo vaše oprávnění.", "app.hardwareGroupMetadata.cpuTimeOverlay": "Omezení přesného (CPU) časového limitu", "app.hardwareGroupMetadata.description": "Interní popis:", "app.hardwareGroupMetadata.id": "Interní identifikátor:", diff --git a/src/locales/en.json b/src/locales/en.json index 3f51791fd..4c45ef8aa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -944,6 +944,7 @@ "app.groups.joinGroupButton": "Join group", "app.groups.leaveGroupButton": "Leave group", "app.groups.removeFromGroup": "Remove from group", + "app.groupsTree.noGroups": "There are no groups currently visible to you.", "app.hardwareGroupMetadata.cpuTimeOverlay": "Precise (CPU) time limit constraints", "app.hardwareGroupMetadata.description": "Internal Description:", "app.hardwareGroupMetadata.id": "Internal Identifier:", diff --git a/src/pages/Archive/Archive.js b/src/pages/Archive/Archive.js index c4fb357cc..1cd0d294d 100644 --- a/src/pages/Archive/Archive.js +++ b/src/pages/Archive/Archive.js @@ -4,11 +4,12 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { FormattedMessage, injectIntl } from 'react-intl'; import { Link } from 'react-router-dom'; +import { defaultMemoize } from 'reselect'; import Page from '../../components/layout/Page'; import Box from '../../components/widgets/Box'; import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; -import GroupTree from '../../components/Groups/GroupTree'; +import GroupsTreeContainer from '../../containers/GroupsTreeContainer'; import withLinks from '../../helpers/withLinks'; import FilterArchiveGroupsForm from '../../components/forms/FilterArchiveGroupsForm/FilterArchiveGroupsForm'; import { getLocalizedName } from '../../helpers/localizedData'; @@ -17,10 +18,11 @@ import { ArchiveIcon, GroupIcon, SuccessOrFailureIcon, AssignmentsIcon } from '. import { fetchAllGroups } from '../../redux/modules/groups'; import { fetchInstancesIfNeeded } from '../../redux/modules/instances'; +import { fetchByIds } from '../../redux/modules/users'; import { selectedInstanceId } from '../../redux/selectors/auth'; import { selectedInstance } from '../../redux/selectors/instances'; -import { groupsSelector } from '../../redux/selectors/groups'; -import { getJsData } from '../../redux/helpers/resourceManager'; +import { getGroupsAdmins } from '../../redux/selectors/groups'; +import { hasPermissions } from '../../helpers/common'; // lowercase and remove accents and this kind of stuff const normalizeString = str => @@ -29,62 +31,30 @@ const normalizeString = str => .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); -const getVisibleArchiveGroupsMap = (groups, showAll, search, locale, rootGroup) => { - const result = {}; - const groupArray = groups.toArray(); - - // first mark all possibly visible - groupArray.forEach(groupObj => { - const group = getJsData(groupObj); - if (group) { - const rootGroupIncludes = - rootGroup === null ? true : group.parentGroupsIds.includes(rootGroup) || rootGroup === group.id; - if (showAll || group.archived) { - result[group.id] = rootGroupIncludes; - } - } - }); - - // then remove that not matching search pattern - groupArray.forEach(groupObj => { - const group = getJsData(groupObj); - if (result[group.id] && search && search !== '') { - const name = getLocalizedName(group, locale); - result[group.id] = normalizeString(name).indexOf(normalizeString(search)) !== -1; - } - }); - - // and finally add parent groups of selected ones - groupArray.forEach(groupObj => { - const group = getJsData(groupObj); - if (result[group.id]) { - group.parentGroupsIds.forEach(parentGroupId => { - result[parentGroupId] = true; - }); - } - }); - - return result; -}; +const prepareGroupsFilter = defaultMemoize((search, showAll, locale) => { + search = normalizeString(search); + return group => (showAll || group.archived) && (!search || getLocalizedName(group, locale).includes(search)); +}); class Archive extends Component { - state = { showAll: false, search: '', rootGroup: null }; + state = { showAll: false, search: '', selectedGroup: null }; static customLoadGroups = true; // Marker for the App async load, that we will load groups ourselves. static loadAsync = (params, dispatch, { instanceId }) => - Promise.all([dispatch(fetchInstancesIfNeeded(instanceId)), dispatch(fetchAllGroups({ archived: true }))]); + Promise.all([ + dispatch(fetchInstancesIfNeeded(instanceId)), + dispatch(fetchAllGroups({ archived: true })).then(({ value: groups }) => + dispatch(fetchByIds(getGroupsAdmins(groups))) + ), + ]); componentDidMount() { this.props.loadAsync(this.props.instanceId); } - buttonsCreator = (groupId, isRoot, permissionHints, archived, directlyArchived) => { - const { - instanceId, - loadAsync, - links: { GROUP_INFO_URI_FACTORY, GROUP_DETAIL_URI_FACTORY }, - } = this.props; + buttonsCreator = (group, selectedGroupId, { GROUP_INFO_URI_FACTORY, GROUP_DETAIL_URI_FACTORY }) => { + const { instanceId, loadAsync } = this.props; return ( @@ -93,29 +63,29 @@ class Archive extends Component { size="xs" onClick={ev => { ev.stopPropagation(); - this.setState({ rootGroup: isRoot ? null : groupId }); + this.setState({ selectedGroup: selectedGroupId !== group.id ? group.id : null }); }}> - - {isRoot ? ( - - ) : ( + + {selectedGroupId !== group.id ? ( + ) : ( + )} - + - + - {permissionHints.archive && (!archived || directlyArchived) && ( - loadAsync(instanceId)} /> + {hasPermissions(group, 'archive') && (!group.archived || group.directlyArchived) && ( + loadAsync(instanceId)} /> )} ); @@ -124,7 +94,6 @@ class Archive extends Component { render() { const { instance, - groups, intl: { locale }, } = this.props; @@ -159,20 +128,11 @@ class Archive extends Component { /> {data.rootGroupId !== null && ( - )} @@ -188,7 +148,6 @@ Archive.propTypes = { links: PropTypes.object.isRequired, instanceId: PropTypes.string.isRequired, instance: ImmutablePropTypes.map, - groups: ImmutablePropTypes.map, intl: PropTypes.object, }; @@ -198,7 +157,6 @@ export default withLinks( return { instanceId: selectedInstanceId(state), instance: selectedInstance(state), - groups: groupsSelector(state), }; }, dispatch => ({ diff --git a/src/pages/Exercise/Exercise.js b/src/pages/Exercise/Exercise.js index 06f8aa87a..1c045577e 100644 --- a/src/pages/Exercise/Exercise.js +++ b/src/pages/Exercise/Exercise.js @@ -34,6 +34,8 @@ import { runtimeEnvironmentsSelector } from '../../redux/selectors/runtimeEnviro import { fetchReferenceSolutions, deleteReferenceSolution } from '../../redux/modules/referenceSolutions'; import { init, submitReferenceSolution, presubmitReferenceSolution } from '../../redux/modules/submission'; import { fetchHardwareGroups } from '../../redux/modules/hwGroups'; +import { fetchAllGroups } from '../../redux/modules/groups'; +import { fetchByIds } from '../../redux/modules/users'; import { exerciseSelector, exerciseForkedFromSelector, @@ -42,9 +44,8 @@ import { } from '../../redux/selectors/exercises'; import { referenceSolutionsSelector } from '../../redux/selectors/referenceSolutions'; -import { loggedInUserIdSelector, selectedInstanceId } from '../../redux/selectors/auth'; -import { instanceSelector } from '../../redux/selectors/instances'; -import { notArchivedGroupsSelector, groupDataAccessorSelector } from '../../redux/selectors/groups'; +import { loggedInUserIdSelector } from '../../redux/selectors/auth'; +import { notArchivedGroupsSelector, groupDataAccessorSelector, getGroupsAdmins } from '../../redux/selectors/groups'; import withLinks from '../../helpers/withLinks'; import { hasPermissions } from '../../helpers/common'; @@ -63,11 +64,16 @@ export const FORK_EXERCISE_FORM_INITIAL_VALUES = { class Exercise extends Component { state = { forkId: Math.random().toString() }; + static customLoadGroups = true; // Marker for the App async load, that we will load groups ourselves. + static loadAsync = ({ exerciseId }, dispatch, { userId }) => Promise.all([ dispatch(fetchExerciseIfNeeded(exerciseId)).then( ({ value: data }) => data && data.forkedFrom && dispatch(fetchExerciseIfNeeded(data.forkedFrom)) ), + dispatch(fetchAllGroups({ archived: true })).then(({ value: groups }) => + dispatch(fetchByIds(getGroupsAdmins(groups))) + ), dispatch(fetchRuntimeEnvironments()), dispatch(fetchReferenceSolutions(exerciseId)), dispatch(fetchHardwareGroups()), @@ -93,7 +99,6 @@ class Exercise extends Component { render() { const { userId, - instance, exercise, forkedFrom, runtimeEnvironments, @@ -162,20 +167,14 @@ class Exercise extends Component { - - {instance => ( - - )} - + {runtimes => ( @@ -278,7 +277,6 @@ class Exercise extends Component { Exercise.propTypes = { userId: PropTypes.string.isRequired, - instance: ImmutablePropTypes.map, match: PropTypes.shape({ params: PropTypes.shape({ exerciseId: PropTypes.string.isRequired, @@ -315,10 +313,8 @@ export default withLinks( } ) => { const userId = loggedInUserIdSelector(state); - const instanceId = selectedInstanceId(state); return { userId, - instance: instanceSelector(state, instanceId), exercise: exerciseSelector(exerciseId)(state), forkedFrom: exerciseForkedFromSelector(exerciseId)(state), runtimeEnvironments: runtimeEnvironmentsSelector(state), diff --git a/src/pages/GroupInfo/GroupInfo.js b/src/pages/GroupInfo/GroupInfo.js index 6d31b94a6..968e50120 100644 --- a/src/pages/GroupInfo/GroupInfo.js +++ b/src/pages/GroupInfo/GroupInfo.js @@ -39,7 +39,7 @@ import { getLocalizedName, transformLocalizedTextsFormData } from '../../helpers import { isReady } from '../../redux/helpers/resourceManager/index'; import Box from '../../components/widgets/Box'; import Callout from '../../components/widgets/Callout'; -import GroupTree from '../../components/Groups/GroupTree/GroupTree'; +import GroupsTreeContainer from '../../containers/GroupsTreeContainer'; import EditGroupForm, { EDIT_GROUP_FORM_EMPTY_INITIAL_VALUES } from '../../components/forms/EditGroupForm'; import AddSupervisor from '../../components/Groups/AddSupervisor'; import { BanIcon, GroupIcon } from '../../components/icons'; @@ -227,16 +227,11 @@ class GroupInfo extends Component { title={} unlimitedHeight extraPadding> -
    )} diff --git a/src/pages/Instance/Instance.js b/src/pages/Instance/Instance.js index 6c59fd616..10aa4ed1e 100644 --- a/src/pages/Instance/Instance.js +++ b/src/pages/Instance/Instance.js @@ -6,10 +6,9 @@ import { formValueSelector } from 'redux-form'; import { FormattedMessage, injectIntl } from 'react-intl'; import { Row, Col } from 'react-bootstrap'; import { Link } from 'react-router-dom'; -import { defaultMemoize } from 'reselect'; import Box from '../../components/widgets/Box'; -import GroupTree from '../../components/Groups/GroupTree'; +import GroupsTreeContainer from '../../containers/GroupsTreeContainer'; import Button from '../../components/widgets/TheButton'; import Page from '../../components/layout/Page'; import LicencesTableContainer from '../../containers/LicencesTableContainer'; @@ -20,37 +19,34 @@ import FetchManyResourceRenderer from '../../components/helpers/FetchManyResourc import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import NotVerifiedEmailCallout from '../../components/Users/NotVerifiedEmailCallout'; -import { fetchUser } from '../../redux/modules/users'; +import { fetchUser, fetchByIds } from '../../redux/modules/users'; import { fetchInstanceIfNeeded } from '../../redux/modules/instances'; import { instanceSelector, isAdminOfInstance } from '../../redux/selectors/instances'; import { createGroup, fetchAllGroups } from '../../redux/modules/groups'; -import { notArchivedGroupsSelector, fetchManyGroupsStatus } from '../../redux/selectors/groups'; +import { fetchManyGroupsStatus, getGroupsAdmins } from '../../redux/selectors/groups'; import { loggedInUserIdSelector } from '../../redux/selectors/auth'; import { isLoggedAsSuperAdmin, getUser } from '../../redux/selectors/users'; import { transformLocalizedTextsFormData, getLocalizedName } from '../../helpers/localizedData'; -import { resourceStatus } from '../../redux/helpers/resourceManager'; import withLinks from '../../helpers/withLinks'; import InstanceInfoTable from '../../components/Instances/InstanceDetail/InstanceInfoTable'; -const anyGroupVisible = defaultMemoize(groups => - Boolean(groups.size > 0 && groups.find(group => group.getIn(['data', 'permissionHints', 'viewDetail']))) -); - class Instance extends Component { - static loadAsync = ({ instanceId, fetchGroupsStatus = null }, dispatch) => { - const promises = [dispatch(fetchInstanceIfNeeded(instanceId))]; - if (fetchGroupsStatus === resourceStatus.FAILED) { - promises.push(dispatch(fetchAllGroups())); - } - return Promise.all(promises); - }; + static customLoadGroups = true; // Marker for the App async load, that we will load groups ourselves. + + static loadAsync = ({ instanceId }, dispatch) => + Promise.all([ + dispatch(fetchInstanceIfNeeded(instanceId)), + dispatch(fetchAllGroups({ archived: true })).then(({ value: groups }) => + dispatch(fetchByIds(getGroupsAdmins(groups))) + ), + ]); - componentDidMount = () => this.props.loadAsync(this.props.fetchGroupsStatus); + componentDidMount = () => this.props.loadAsync(); componentDidUpdate(prevProps) { if (this.props.match.params.instanceId !== prevProps.match.params.instanceId) { - this.props.loadAsync(this.props.fetchGroupsStatus); + this.props.loadAsync(); } } @@ -63,7 +59,6 @@ class Instance extends Component { user, refreshUser, instance, - groups, fetchGroupsStatus, createGroup, isAdmin, @@ -128,16 +123,7 @@ class Instance extends Component {
    {data.rootGroupId !== null && ( - {() => - anyGroupVisible(groups) ? ( - - ) : ( - - ) - } + {() => } )} @@ -183,7 +169,6 @@ Instance.propTypes = { userId: PropTypes.string.isRequired, user: ImmutablePropTypes.map, instance: ImmutablePropTypes.map, - groups: ImmutablePropTypes.map, fetchGroupsStatus: PropTypes.string, createGroup: PropTypes.func.isRequired, isAdmin: PropTypes.bool.isRequired, @@ -210,7 +195,6 @@ export default withLinks( userId, user: getUser(userId)(state), instance: instanceSelector(state, instanceId), - groups: notArchivedGroupsSelector(state), fetchGroupsStatus: fetchManyGroupsStatus(state), isAdmin: isAdminOfInstance(userId, instanceId)(state), isSuperAdmin: isLoggedAsSuperAdmin(state), @@ -238,7 +222,7 @@ export default withLinks( instanceId, }) ).then(() => Promise.all([dispatch(fetchAllGroups()), dispatch(fetchUser(userId))])), - loadAsync: fetchGroupsStatus => Instance.loadAsync({ instanceId, fetchGroupsStatus }, dispatch), + loadAsync: () => Instance.loadAsync({ instanceId }, dispatch), refreshUser: userId => dispatch(fetchUser(userId)), }) )(injectIntl(Instance)) diff --git a/src/redux/selectors/groups.js b/src/redux/selectors/groups.js index 333e11d90..cfc37fd00 100644 --- a/src/redux/selectors/groups.js +++ b/src/redux/selectors/groups.js @@ -147,3 +147,14 @@ export const groupsUserIsMemberSelector = createSelector( [notArchivedGroupsSelector, getParam, getLang], groupsUserIsMember ); + +/** + * Helper selector-related function that extracts unique admin IDs form given groups + * @param {Array} groups as JS objects + * @returns {String[]} unique user IDs + */ +export const getGroupsAdmins = groups => { + const ids = new Set(); // set ensures each id is represented once + groups.forEach(group => group && group.primaryAdminsIds && group.primaryAdminsIds.forEach(id => ids.add(id))); + return Array.from(ids); +};