Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getModeratedItems } from './utils';
import { getModeratedItems } from './getModeratedItems';

describe('getModeratedItems', () => {
describe('without spaces', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import {
} from 'api/admin_publications/types';
import { IUserData } from 'api/users/types';

/**
* Derives the spaces, folders and projects a user moderates from the
* admin_publications tree view, based on the user's roles, grouped by type.
* Reusable wherever the tree view needs to be scoped to what a given user can
* manage (e.g. the users table, adding items to a space).
*/
export const getModeratedItems = (user: IUserData, treeView: TreeView) => {
const folderIds = user.attributes.roles
?.filter(
Expand Down
2 changes: 1 addition & 1 deletion front/app/components/admin/TreeView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const TreeView = ({
onRemove,
}: Props) => {
return (
<Box maxWidth="800px">
<Box>
{nodes.map((node) => (
<Box key={node.id}>
{node.type === 'space' && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';

import { Box, Title, Text } from '@citizenlab/cl2-component-library';

import { getModeratedItems } from 'api/admin_publications/getModeratedItems';
import { NodeType } from 'api/admin_publications/types';
import useTreeView from 'api/admin_publications/useTreeView';
import useDeleteProjectFolderModerator from 'api/project_folder_moderators/useDeleteProjectFolderModerator';
Expand All @@ -15,7 +16,6 @@ import { FormattedMessage } from 'utils/cl-intl';
import { isAdmin } from 'utils/permissions/roles';

import messages from './messages';
import { getModeratedItems } from './utils';

interface Props {
user: IUserData;
Expand Down
29 changes: 13 additions & 16 deletions front/app/containers/Admin/projects/all/Spaces/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,26 @@ import React from 'react';

import { Box } from '@citizenlab/cl2-component-library';

import useAuthUser from 'api/me/useAuthUser';

import useFeatureFlag from 'hooks/useFeatureFlag';

import { isAdmin, isSpaceModerator } from 'utils/permissions/roles';
import { usePermission } from 'utils/permissions';

import Table from './Table';

const Spaces = () => {
const spacesEnabled = useFeatureFlag({ name: 'spaces' });
const { data: user } = useAuthUser();

if (!spacesEnabled) return null;

if (isAdmin(user) || isSpaceModerator(user)) {
return (
<Box>
<Table />
</Box>
);
}

return null;
const canManageSpaces = usePermission({
item: 'space',
action: 'manage_projects_and_folders',
});

if (!spacesEnabled || !canManageSpaces) return null;

return (
<Box>
<Table />
</Box>
);
};

export default Spaces;
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React, { useState } from 'react';

import { Box, Button, Select } from '@citizenlab/cl2-component-library';

import { FolderNode, ProjectNode } from 'api/admin_publications/types';
import useUpdateProjectFolder from 'api/project_folders/useUpdateProjectFolder';
import useUpdateProject from 'api/projects/useUpdateProject';

import useLocalize from 'hooks/useLocalize';

import Modal from 'components/UI/Modal';

import { useIntl } from 'utils/cl-intl';

import messages from '../messages';

type NodeType = 'project' | 'folder';

interface Props {
spaceId: string;
// Projects and folders the current user is allowed to add to this space.
addableNodes: (ProjectNode | FolderNode)[];
opened: boolean;
onClose: () => void;
}

const AddToSpaceModal = ({ spaceId, addableNodes, opened, onClose }: Props) => {
const { formatMessage } = useIntl();
const localize = useLocalize();
const { mutate: updateProject, isLoading: isUpdatingProject } =
useUpdateProject();
const { mutate: updateFolder, isLoading: isUpdatingFolder } =
useUpdateProjectFolder();

const [nodeType, setNodeType] = useState<NodeType>('project');
const [selectedId, setSelectedId] = useState<string | null>(null);

const candidates = addableNodes.filter((node) => node.type === nodeType);

const handleClose = () => {
setNodeType('project');
setSelectedId(null);
onClose();
};

const handleAdd = () => {
if (!selectedId) return;

if (nodeType === 'project') {
updateProject(
{ projectId: selectedId, space_id: spaceId },
{ onSuccess: handleClose }
);
} else {
updateFolder(
{ projectFolderId: selectedId, space_id: spaceId },
{ onSuccess: handleClose }
);
}
};

const typeOptions = [
{ value: 'project', label: formatMessage(messages.project) },
{ value: 'folder', label: formatMessage(messages.folder) },
];

const candidateOptions = candidates.map((node) => ({
value: node.id,
label: localize(node.title_multiloc),
}));

return (
<Modal
opened={opened}
close={handleClose}
header={formatMessage(messages.addNewProjectOrFolder)}
>
<Box p="32px">
<Box display="flex" gap="16px" mb="32px">
<Box width="140px">
<Select
value={nodeType}
options={typeOptions}
dataCy="e2e-add-to-space-type-select"
onChange={(option) => {
setNodeType(option.value);
setSelectedId(null);
}}
/>
</Box>
<Box flex="1">
<Select
value={selectedId}
options={candidateOptions}
dataCy="e2e-add-to-space-item-select"
placeholder={formatMessage(
nodeType === 'project'
? messages.projectsPlaceholder
: messages.foldersPlaceholder
)}
onChange={(option) => setSelectedId(option.value ?? null)}
/>
</Box>
</Box>
<Box display="flex" justifyContent="flex-end" gap="12px">
<Button buttonStyle="secondary-outlined" onClick={handleClose}>
{formatMessage(messages.cancel)}
</Button>
<Button
onClick={handleAdd}
disabled={!selectedId}
processing={isUpdatingProject || isUpdatingFolder}
dataCy="e2e-add-to-space-submit"
>
{formatMessage(messages.addToSpace)}
</Button>
</Box>
</Box>
</Modal>
);
};

export default AddToSpaceModal;
Original file line number Diff line number Diff line change
@@ -1,30 +1,53 @@
import React from 'react';
import React, { useState } from 'react';

import { Title, Text } from '@citizenlab/cl2-component-library';
import {
Box,
Button,
Title,
Text,
Tooltip,
} from '@citizenlab/cl2-component-library';

import { SpaceNode } from 'api/admin_publications/types';
import { getModeratedItems } from 'api/admin_publications/getModeratedItems';
import {
FolderNode,
ProjectNode,
SpaceNode,
} from 'api/admin_publications/types';
import useTreeView from 'api/admin_publications/useTreeView';
import useAuthUser from 'api/me/useAuthUser';
import useUpdateProjectFolder from 'api/project_folders/useUpdateProjectFolder';
import useUpdateProject from 'api/projects/useUpdateProject';

import TreeView from 'components/admin/TreeView';

import { FormattedMessage } from 'utils/cl-intl';
import { isSpaceModerator } from 'utils/permissions/roles';
import { FormattedMessage, useIntl } from 'utils/cl-intl';
import { usePermission } from 'utils/permissions';
import { isAdmin } from 'utils/permissions/roles';
import { useParams } from 'utils/router';

import messages from '../messages';

import AddToSpaceModal from './AddToSpaceModal';

const ProjectsAndFolders = () => {
const { spaceId } = useParams({ strict: false });
const { formatMessage } = useIntl();
const { data: treeView } = useTreeView();
const { data: authUser } = useAuthUser();
const { mutate: updateFolder } = useUpdateProjectFolder();
const { mutate: updateProject } = useUpdateProject();
const { data: authUser } = useAuthUser();

const userIsSpaceModerator = isSpaceModerator(authUser);
if (!treeView || !spaceId) return null;
const [modalOpened, setModalOpened] = useState(false);

// Anyone who can access this page can add items, so the button isn't gated
// further. Removing items from a space is admin-only.
const canRemoveFromSpace = usePermission({
item: 'space',
action: 'remove_projects_and_folders',
});

if (!treeView || !spaceId || !authUser) return null;

const spaceNode = treeView.data.attributes.nodes.find(
(node): node is SpaceNode => node.type === 'space' && node.id === spaceId
Expand All @@ -33,32 +56,81 @@ const ProjectsAndFolders = () => {
const nodesInSpace = spaceNode?.children;
if (!nodesInSpace) return null;

// A project or folder can be added when it sits at the root of the tree (not
// in any space) and the current user is allowed to manage it: admins can
// manage everything, moderators only the items they moderate. This mirrors
// what the backend authorizes, so we never offer a project that would 401.
const { projectsUserModerates, foldersUserModerates } = getModeratedItems(
authUser.data,
treeView
);
const moderatedIds = new Set(
[...projectsUserModerates, ...foldersUserModerates].map((node) => node.id)
);
const userIsAdmin = isAdmin(authUser);

const addableNodes = treeView.data.attributes.nodes.filter(
(node): node is ProjectNode | FolderNode =>
(node.type === 'project' || node.type === 'folder') &&
(userIsAdmin || moderatedIds.has(node.id))
);
const hasAddableNodes = addableNodes.length > 0;

const handleRemove = async (
nodeId: string,
nodeType: 'project' | 'folder'
) => {
if (nodeType === 'project') {
await updateProject({ projectId: nodeId, space_id: null });
updateProject({ projectId: nodeId, space_id: null });
} else {
await updateFolder({ projectFolderId: nodeId, space_id: null });
updateFolder({ projectFolderId: nodeId, space_id: null });
}
};

return (
<>
<Title variant="h2" color="primary" mt="0px" mb="20px">
<FormattedMessage {...messages.projectsAndFoldersAdded} />
</Title>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
mb="20px"
>
<Title variant="h2" color="primary" mt="0px" mb="0px">
<FormattedMessage {...messages.projectsAndFoldersAdded} />
</Title>
<Tooltip
disabled={hasAddableNodes}
content={
<FormattedMessage {...messages.noAddableProjectsOrFolders} />
}
>
<Button
icon="plus"
buttonStyle="secondary-outlined"
dataCy="e2e-add-to-space-button"
disabled={!hasAddableNodes}
onClick={() => setModalOpened(true)}
>
{formatMessage(messages.addNewProjectOrFolder)}
</Button>
</Tooltip>
</Box>
<AddToSpaceModal
spaceId={spaceId}
addableNodes={addableNodes}
opened={modalOpened}
onClose={() => setModalOpened(false)}
/>
{nodesInSpace.length > 0 ? (
<TreeView
nodes={nodesInSpace}
lockedProjectTooltip={
userIsSpaceModerator ? undefined : messages.lockedProject
canRemoveFromSpace ? messages.lockedProject : undefined
}
removeButtonMessage={
userIsSpaceModerator ? undefined : messages.removeFromSpace
canRemoveFromSpace ? messages.removeFromSpace : undefined
}
onRemove={userIsSpaceModerator ? undefined : handleRemove}
onRemove={canRemoveFromSpace ? handleRemove : undefined}
/>
) : (
<Text>
Expand Down
Loading