feat: Rebuilt member admin (#2139)

This commit is contained in:
Tom Moor
2021-05-19 21:36:10 -07:00
committed by GitHub
parent 833bd51f4c
commit b0196f0cf0
23 changed files with 770 additions and 291 deletions

View File

@ -48,12 +48,13 @@ const MenuItem = ({
{(props) => ( {(props) => (
<MenuAnchor <MenuAnchor
{...props} {...props}
$toggleable={selected !== undefined}
as={onClick ? "button" : as} as={onClick ? "button" : as}
onClick={handleClick} onClick={handleClick}
> >
{selected !== undefined && ( {selected !== undefined && (
<> <>
{selected ? <CheckmarkIcon /> : <Spacer />} {selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
&nbsp; &nbsp;
</> </>
)} )}
@ -64,9 +65,10 @@ const MenuItem = ({
); );
}; };
const Spacer = styled.div` const Spacer = styled.svg`
width: 24px; width: 24px;
height: 24px; height: 24px;
flex-shrink: 0;
`; `;
export const MenuAnchor = styled.a` export const MenuAnchor = styled.a`
@ -118,7 +120,7 @@ export const MenuAnchor = styled.a`
`}; `};
${breakpoint("tablet")` ${breakpoint("tablet")`
padding: 6px 12px; padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px; font-size: 15px;
`}; `};
`; `;

View File

@ -5,7 +5,8 @@ import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import Button, { Inner } from "components/Button"; import Button, { Inner } from "components/Button";
import ContextMenu from "components/ContextMenu"; import ContextMenu from "components/ContextMenu";
import FilterOption from "./FilterOption"; import MenuItem from "components/ContextMenu/MenuItem";
import HelpText from "components/HelpText";
type TFilterOption = {| type TFilterOption = {|
key: string, key: string,
@ -35,7 +36,7 @@ const FilterOptions = ({
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : ""; const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
return ( return (
<SearchFilter> <Wrapper>
<MenuButton {...menu}> <MenuButton {...menu}>
{(props) => ( {(props) => (
<StyledButton <StyledButton
@ -50,30 +51,49 @@ const FilterOptions = ({
)} )}
</MenuButton> </MenuButton>
<ContextMenu aria-label={defaultLabel} {...menu}> <ContextMenu aria-label={defaultLabel} {...menu}>
<List>
{options.map((option) => ( {options.map((option) => (
<FilterOption <MenuItem
key={option.key} key={option.key}
onSelect={() => { onClick={() => {
onSelect(option.key); onSelect(option.key);
menu.hide(); menu.hide();
}} }}
active={option.key === activeKey} selected={option.key === activeKey}
{...option}
{...menu} {...menu}
/> >
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
))} ))}
</List>
</ContextMenu> </ContextMenu>
</SearchFilter> </Wrapper>
); );
}; };
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
`;
const Note = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 400;
color: ${(props) => props.theme.textTertiary};
`;
const StyledButton = styled(Button)` const StyledButton = styled(Button)`
box-shadow: none; box-shadow: none;
text-transform: none; text-transform: none;
border-color: transparent; border-color: transparent;
height: 28px;
&:hover { &:hover {
background: transparent; background: transparent;
@ -84,14 +104,8 @@ const StyledButton = styled(Button)`
} }
`; `;
const SearchFilter = styled.div` const Wrapper = styled.div`
margin-right: 8px; margin-right: 8px;
`; `;
const List = styled("ol")`
list-style: none;
margin: 0;
padding: 0 8px;
`;
export default FilterOptions; export default FilterOptions;

View File

@ -25,6 +25,7 @@ type Props = {|
className?: string, className?: string,
children?: React.Node, children?: React.Node,
role?: string, role?: string,
gap?: number,
|}; |};
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => { const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
@ -44,6 +45,7 @@ const Container = styled.div`
align-items: ${({ align }) => align}; align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify}; justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")}; flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
`; `;

View File

@ -3,13 +3,14 @@ import { SearchIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components"; import { useTheme } from "styled-components";
import Input from "./Input"; import Input, { type Props as InputProps } from "./Input";
type Props = {| type Props = {|
...InputProps,
placeholder?: string, placeholder?: string,
value?: string, value?: string,
onChange: (event: SyntheticInputEvent<>) => mixed, onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed, onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|}; |};
export default function InputSearch(props: Props) { export default function InputSearch(props: Props) {

View File

@ -8,6 +8,8 @@ import Flex from "components/Flex";
type Props = {| type Props = {|
header?: boolean, header?: boolean,
height?: number, height?: number,
minWidth?: number,
maxWidth?: number,
|}; |};
class Mask extends React.Component<Props> { class Mask extends React.Component<Props> {
@ -17,9 +19,9 @@ class Mask extends React.Component<Props> {
return false; return false;
} }
constructor() { constructor(props: Props) {
super(); super();
this.width = randomInteger(75, 100); this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
} }
render() { render() {

View File

@ -179,7 +179,7 @@ function MainSidebar() {
/> />
{can.inviteUser && ( {can.inviteUser && (
<SidebarLink <SidebarLink
to="/settings/people" to="/settings/members"
onClick={handleInviteModalOpen} onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />} icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`} label={`${t("Invite people")}`}

View File

@ -96,10 +96,10 @@ function SettingsSidebar() {
/> />
)} )}
<SidebarLink <SidebarLink
to="/settings/people" to="/settings/members"
icon={<UserIcon color="currentColor" />} icon={<UserIcon color="currentColor" />}
exact={false} exact={false}
label={t("People")} label={t("Members")}
/> />
<SidebarLink <SidebarLink
to="/settings/groups" to="/settings/groups"

250
app/components/Table.js Normal file
View File

@ -0,0 +1,250 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTable, useSortBy, usePagination } from "react-table";
import styled from "styled-components";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import Mask from "components/Mask";
export type Props = {|
data: any[],
offset?: number,
isLoading: boolean,
empty?: React.Node,
currentPage?: number,
page: number,
pageSize?: number,
totalPages?: number,
defaultSort?: string,
topRef?: React.Ref<any>,
onChangePage: (index: number) => void,
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
columns: any,
|};
function Table({
data,
offset,
isLoading,
totalPages,
empty,
columns,
page,
pageSize = 50,
defaultSort = "name",
topRef,
onChangeSort,
onChangePage,
}: Props) {
const { t } = useTranslation();
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
canNextPage,
nextPage,
canPreviousPage,
previousPage,
state: { pageIndex, sortBy },
} = useTable(
{
columns,
data,
manualPagination: true,
manualSortBy: true,
autoResetSortBy: false,
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [{ id: defaultSort, desc: false }],
pageSize,
pageIndex: page,
},
},
useSortBy,
usePagination
);
React.useEffect(() => {
onChangePage(pageIndex);
}, [pageIndex]);
React.useEffect(() => {
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
sortBy.length && sortBy[0].desc ? "DESC" : "ASC"
);
}, [sortBy]);
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
console.log({ canNextPage, pageIndex, totalPages, rows, data });
return (
<>
<Anchor ref={topRef} />
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column) => (
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
<SortWrapper align="center" gap={4}>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row);
return (
<Row {...row.getRowProps()}>
{row.cells.map((cell) => (
<Cell
{...cell.getCellProps([
{
className: cell.column.className,
},
])}
>
{cell.render("Cell")}
</Cell>
))}
</Row>
);
})}
</tbody>
{showPlaceholder && <Placeholder columns={columns.length} />}
</InnerTable>
{isEmpty ? (
empty || <Empty>{t("No results")}</Empty>
) : (
<Pagination
justify={canPreviousPage ? "space-between" : "flex-end"}
gap={8}
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={previousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={nextPage} neutral>
{t("Next page")}
</Button>
)}
</Pagination>
)}
</>
);
}
export const Placeholder = ({
columns,
rows = 3,
}: {
columns: number,
rows?: number,
}) => {
return (
<tbody>
{new Array(rows).fill().map((_, row) => (
<Row key={row}>
{new Array(columns).fill().map((_, col) => (
<Cell key={col}>
<Mask minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
);
};
const Anchor = styled.div`
top: -32px;
position: relative;
`;
const Pagination = styled(Flex)`
margin: 0 0 32px;
`;
const DescSortIcon = styled(CollapsedIcon)`
&:hover {
fill: ${(props) => props.theme.text};
}
`;
const AscSortIcon = styled(DescSortIcon)`
transform: rotate(180deg);
`;
const InnerTable = styled.table`
border-collapse: collapse;
margin: 16px 0;
width: 100%;
`;
const SortWrapper = styled(Flex)`
height: 24px;
`;
const Cell = styled.td`
padding: 8px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
&:first-child {
font-size: 15px;
font-weight: 500;
}
&.actions,
&.right-aligned {
text-align: right;
vertical-align: bottom;
}
`;
const Row = styled.tr`
&:last-child {
${Cell} {
border-bottom: 0;
}
}
`;
const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
font-size: 14px;
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
z-index: 1;
`;
export default observer(Table);

View File

@ -1,6 +1,6 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import { Switch } from "react-router-dom"; import { Switch, Redirect } from "react-router-dom";
import Details from "scenes/Settings/Details"; import Details from "scenes/Settings/Details";
import Groups from "scenes/Settings/Groups"; import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport"; import ImportExport from "scenes/Settings/ImportExport";
@ -20,8 +20,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings" component={Profile} /> <Route exact path="/settings" component={Profile} />
<Route exact path="/settings/details" component={Details} /> <Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} /> <Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/people" component={People} /> <Route exact path="/settings/members" component={People} />
<Route exact path="/settings/people/:filter" component={People} />
<Route exact path="/settings/groups" component={Groups} /> <Route exact path="/settings/groups" component={Groups} />
<Route exact path="/settings/shares" component={Shares} /> <Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} /> <Route exact path="/settings/tokens" component={Tokens} />
@ -29,6 +28,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings/integrations/slack" component={Slack} /> <Route exact path="/settings/integrations/slack" component={Slack} />
<Route exact path="/settings/integrations/zapier" component={Zapier} /> <Route exact path="/settings/integrations/zapier" component={Zapier} />
<Route exact path="/settings/import-export" component={ImportExport} /> <Route exact path="/settings/import-export" component={ImportExport} />
<Redirect from="/settings/people" to="/settings/members" />
</Switch> </Switch>
); );
} }

View File

@ -2,7 +2,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions"; import FilterOptions from "components/FilterOptions";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
type Props = {| type Props = {|

View File

@ -1,7 +1,7 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions"; import FilterOptions from "components/FilterOptions";
type Props = {| type Props = {|
dateFilter: ?string, dateFilter: ?string,

View File

@ -1,85 +0,0 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
type Props = {|
label: string,
note?: string,
onSelect: () => void,
active: boolean,
|};
const FilterOption = ({ label, note, onSelect, active, ...rest }: Props) => {
return (
<MenuItem onClick={active ? undefined : onSelect} {...rest}>
{(props) => (
<ListItem>
<Button active={active} {...props}>
<Flex align="center" justify="space-between">
<span>
{label}
{note && <Description small>{note}</Description>}
</span>
{active && <Checkmark />}
</Flex>
</Button>
</ListItem>
)}
</MenuItem>
);
};
const Description = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
line-height: 1.2em;
`;
const Checkmark = styled(CheckmarkIcon)`
flex-shrink: 0;
padding-left: 4px;
fill: ${(props) => props.theme.text};
`;
const Button = styled.button`
display: flex;
flex-direction: column;
font-size: 16px;
padding: 8px;
margin: 0;
border: 0;
background: none;
color: ${(props) => props.theme.text};
text-align: left;
font-weight: ${(props) => (props.active ? "600" : "normal")};
justify-content: center;
width: 100%;
min-height: 42px;
${HelpText} {
font-weight: normal;
user-select: none;
}
&:hover {
background: ${(props) => props.theme.listItemHoverBackground};
}
${breakpoint("tablet")`
padding: 4px 8px;
font-size: 15px;
min-height: 32px;
`};
`;
const ListItem = styled("li")`
list-style: none;
max-width: 250px;
`;
export default FilterOption;

View File

@ -1,7 +1,7 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions"; import FilterOptions from "components/FilterOptions";
type Props = {| type Props = {|
includeArchived?: boolean, includeArchived?: boolean,

View File

@ -2,7 +2,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions"; import FilterOptions from "components/FilterOptions";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
type Props = {| type Props = {|

View File

@ -1,161 +1,245 @@
// @flow // @flow
import invariant from "invariant"; import { sortBy } from "lodash";
import { observable } from "mobx"; import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import { PlusIcon, UserIcon } from "outline-icons"; import { PlusIcon, UserIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { type Match } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import AuthStore from "stores/AuthStore"; import scrollIntoView from "smooth-scroll-into-view-if-needed";
import PoliciesStore from "stores/PoliciesStore";
import UsersStore from "stores/UsersStore";
import Invite from "scenes/Invite"; import Invite from "scenes/Invite";
import Bubble from "components/Bubble"; import { Action } from "components/Actions";
import Button from "components/Button"; import Button from "components/Button";
import Empty from "components/Empty"; import Flex from "components/Flex";
import Heading from "components/Heading"; import Heading from "components/Heading";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
import InputSearch from "components/InputSearch";
import Modal from "components/Modal"; import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Scene from "components/Scene"; import Scene from "components/Scene";
import Tab from "components/Tab"; import PeopleTable from "./components/PeopleTable";
import Tabs, { Separator } from "components/Tabs"; import UserStatusFilter from "./components/UserStatusFilter";
import UserListItem from "./components/UserListItem"; import useCurrentTeam from "hooks/useCurrentTeam";
import useQuery from "hooks/useQuery";
type Props = { import useStores from "hooks/useStores";
auth: AuthStore,
users: UsersStore,
policies: PoliciesStore,
match: Match,
t: TFunction,
};
@observer
class People extends React.Component<Props> {
@observable inviteModalOpen: boolean = false;
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.props.users.fetchCounts(team.id);
}
}
handleInviteModalOpen = () => {
this.inviteModalOpen = true;
};
handleInviteModalClose = () => {
this.inviteModalOpen = false;
};
fetchPage = (params) => {
return this.props.users.fetchPage({ ...params, includeSuspended: true });
};
render() {
const { auth, policies, match, t } = this.props;
const { filter } = match.params;
const currentUser = auth.user;
const team = auth.team;
invariant(currentUser, "User should exist");
invariant(team, "Team should exist");
let users = this.props.users.active;
if (filter === "all") {
users = this.props.users.all;
} else if (filter === "admins") {
users = this.props.users.admins;
} else if (filter === "suspended") {
users = this.props.users.suspended;
} else if (filter === "invited") {
users = this.props.users.invited;
} else if (filter === "viewers") {
users = this.props.users.viewers;
}
function People(props) {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const team = useCurrentTeam();
const { users, policies } = useStores();
const { t } = useTranslation();
const params = useQuery();
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState([]);
const [totalPages, setTotalPages] = React.useState(0);
const [userIds, setUserIds] = React.useState([]);
const can = policies.abilities(team.id); const can = policies.abilities(team.id);
const { counts } = this.props.users; const query = params.get("query") || "";
const filter = params.get("filter") || "";
const sort = params.get("sort") || "name";
const direction = (params.get("direction") || "asc").toUpperCase();
const page = parseInt(params.get("page") || 0, 10);
const limit = 25;
React.useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await users.fetchPage({
offset: page * limit,
limit,
sort,
direction,
query,
filter,
});
setTotalPages(Math.ceil(response.pagination.total / limit));
setUserIds(response.data.map((u) => u.id));
} finally {
setIsLoading(false);
}
};
fetchData();
}, [query, sort, filter, page, direction, users]);
React.useEffect(() => {
let filtered = users.orderedData;
if (!filter) {
filtered = users.active.filter((u) => userIds.includes(u.id));
} else if (filter === "all") {
filtered = users.orderedData.filter((u) => userIds.includes(u.id));
} else if (filter === "admins") {
filtered = users.admins.filter((u) => userIds.includes(u.id));
} else if (filter === "suspended") {
filtered = users.suspended.filter((u) => userIds.includes(u.id));
} else if (filter === "invited") {
filtered = users.invited.filter((u) => userIds.includes(u.id));
} else if (filter === "viewers") {
filtered = users.viewers.filter((u) => userIds.includes(u.id));
}
// sort the resulting data by the original order from the server
setData(sortBy(filtered, (item) => userIds.indexOf(item.id)));
}, [
filter,
users.active,
users.admins,
users.orderedData,
users.suspended,
users.invited,
users.viewers,
userIds,
]);
const handleInviteModalOpen = React.useCallback(() => {
setInviteModalOpen(true);
}, []);
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
const handleFilter = React.useCallback(
(filter) => {
if (filter) {
params.set("filter", filter);
params.delete("page");
} else {
params.delete("filter");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = React.useCallback(
(event) => {
const { value } = event.target;
if (value) {
params.set("query", event.target.value);
params.delete("page");
} else {
params.delete("query");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangeSort = React.useCallback(
(sort, direction) => {
if (sort) {
params.set("sort", sort);
} else {
params.delete("sort");
}
if (direction === "DESC") {
params.set("direction", direction.toLowerCase());
} else {
params.delete("direction");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangePage = React.useCallback(
(page) => {
if (page) {
params.set("page", page.toString());
} else {
params.delete("page");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
if (topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "instant",
block: "start",
});
}
},
[params, history, location.pathname]
);
return ( return (
<Scene title={t("People")} icon={<UserIcon color="currentColor" />}> <Scene
<Heading>{t("People")}</Heading> title={t("Members")}
<HelpText> icon={<UserIcon color="currentColor" />}
<Trans> actions={
Everyone that has signed into Outline appears here. Its possible <>
that there are other users who have access through{" "}
{team.signinMethods} but havent signed in yet.
</Trans>
</HelpText>
{can.inviteUser && ( {can.inviteUser && (
<Action>
<Button <Button
type="button" type="button"
data-on="click" data-on="click"
data-event-category="invite" data-event-category="invite"
data-event-action="peoplePage" data-event-action="peoplePage"
onClick={this.handleInviteModalOpen} onClick={handleInviteModalOpen}
icon={<PlusIcon />} icon={<PlusIcon />}
neutral
> >
{t("Invite people")} {t("Invite people")}
</Button> </Button>
</Action>
)} )}
<Tabs>
<Tab to="/settings/people" exact>
{t("Active")} <Bubble count={counts.active} />
</Tab>
<Tab to="/settings/people/admins" exact>
{t("Admins")} <Bubble count={counts.admins} />
</Tab>
{can.update && (
<Tab to="/settings/people/suspended" exact>
{t("Suspended")} <Bubble count={counts.suspended} />
</Tab>
)}
<Tab to="/settings/people/viewers" exact>
{t("Viewers")} <Bubble count={counts.viewers} />
</Tab>
<Tab to="/settings/people/all" exact>
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
</Tab>
{can.inviteUser && (
<>
<Separator />
<Tab to="/settings/people/invited" exact>
{t("Invited")} <Bubble count={counts.invited} />
</Tab>
</> </>
)} }
</Tabs> >
<PaginatedList <Heading>{t("Members")}</Heading>
items={users} <HelpText>
empty={<Empty>{t("No people to see here.")}</Empty>} <Trans>
fetch={this.fetchPage} Everyone that has signed into Outline appears here. Its possible that
renderItem={(item) => ( there are other users who have access through {team.signinMethods} but
<UserListItem havent signed in yet.
key={item.id} </Trans>
user={item} </HelpText>
showMenu={can.update && currentUser.id !== item.id} <Flex gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/> />
)} <UserStatusFilter activeKey={filter} onSelect={handleFilter} />
</Flex>
<PeopleTable
topRef={topRef}
data={data}
canManage={can.manage}
isLoading={isLoading}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
page={page}
totalPages={totalPages}
/> />
{can.inviteUser && ( {can.inviteUser && (
<Modal <Modal
title={t("Invite people")} title={t("Invite people")}
onRequestClose={this.handleInviteModalClose} onRequestClose={handleInviteModalClose}
isOpen={this.inviteModalOpen} isOpen={inviteModalOpen}
> >
<Invite onSubmit={this.handleInviteModalClose} /> <Invite onSubmit={handleInviteModalClose} />
</Modal> </Modal>
)} )}
</Scene> </Scene>
); );
}
} }
export default inject( export default observer(People);
"auth",
"users",
"policies"
)(withTranslation()<People>(People));

View File

@ -0,0 +1,92 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
import Flex from "components/Flex";
import { type Props as TableProps } from "components/Table";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import UserMenu from "menus/UserMenu";
const Table = React.lazy<TableProps>(() => import("components/Table"));
type Props = {|
...$Diff<TableProps, { columns: any }>,
data: User[],
canManage: boolean,
|};
function PeopleTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const columns = React.useMemo(
() =>
[
{
id: "name",
Header: t("Name"),
accessor: "name",
Cell: observer(({ value, row }) => (
<Flex align="center" gap={8}>
<Avatar src={row.original.avatarUrl} size={32} /> {value}{" "}
{currentUser.id === row.original.id && `(${t("You")})`}
</Flex>
)),
},
canManage
? {
id: "email",
Header: t("Email"),
accessor: "email",
Cell: observer(({ value }) => value),
}
: undefined,
{
id: "lastActiveAt",
Header: t("Last active"),
accessor: "lastActiveAt",
Cell: observer(
({ value }) => value && <Time dateTime={value} addSuffix />
),
},
{
id: "isAdmin",
Header: t("Role"),
accessor: "rank",
Cell: observer(({ row }) => (
<Badges>
{!row.original.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{row.original.isAdmin && <Badge primary>{t("Admin")}</Badge>}
{row.original.isViewer && <Badge>{t("Viewer")}</Badge>}
{row.original.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
)),
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
Cell: observer(
({ row, value }) =>
currentUser.id !== value && <UserMenu user={row.original} />
),
}
: undefined,
].filter((i) => i),
[t, canManage, currentUser]
);
return <Table columns={columns} {...rest} />;
}
const Badges = styled.div`
margin-left: -10px;
`;
export default PeopleTable;

View File

@ -0,0 +1,54 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "components/FilterOptions";
type Props = {|
activeKey: string,
onSelect: (key: ?string) => void,
|};
const UserStatusFilter = ({ activeKey, onSelect }: Props) => {
const { t } = useTranslation();
const options = React.useMemo(
() => [
{
key: "",
label: t("Active"),
},
{
key: "all",
label: t("Everyone"),
},
{
key: "admins",
label: t("Admins"),
},
{
key: "suspended",
label: t("Suspended"),
},
{
key: "invited",
label: t("Invited"),
},
{
key: "viewers",
label: t("Viewers"),
},
],
[t]
);
return (
<FilterOptions
options={options}
activeKey={activeKey}
onSelect={onSelect}
defaultLabel={t("Active")}
/>
);
};
export default UserStatusFilter;

View File

@ -175,7 +175,7 @@ export default class BaseStore<T: BaseModel> {
res.data.forEach(this.add); res.data.forEach(this.add);
this.isLoaded = true; this.isLoaded = true;
}); });
return res.data; return res;
} finally { } finally {
this.isFetching = false; this.isFetching = false;
} }

View File

@ -162,6 +162,7 @@
"react-keydown": "^1.7.3", "react-keydown": "^1.7.3",
"react-portal": "^4.0.0", "react-portal": "^4.0.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-table": "^7.7.0",
"react-virtualized-auto-sizer": "^1.0.2", "react-virtualized-auto-sizer": "^1.0.2",
"react-waypoint": "^9.0.2", "react-waypoint": "^9.0.2",
"react-window": "^1.8.6", "react-window": "^1.8.6",

View File

@ -14,28 +14,51 @@ const { can, authorize } = policy;
const router = new Router(); const router = new Router();
router.post("users.list", auth(), pagination(), async (ctx) => { router.post("users.list", auth(), pagination(), async (ctx) => {
let { let { sort = "createdAt", query, direction, filter } = ctx.body;
sort = "createdAt",
query,
direction,
includeSuspended = false,
} = ctx.body;
if (direction !== "ASC") direction = "DESC"; if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, User); ctx.assertSort(sort, User);
if (filter) {
ctx.assertIn(filter, [
"invited",
"viewers",
"admins",
"active",
"all",
"suspended",
]);
}
const actor = ctx.state.user; const actor = ctx.state.user;
let where = { let where = {
teamId: actor.teamId, teamId: actor.teamId,
}; };
if (!includeSuspended) { switch (filter) {
where = { case "invited": {
...where, where = { ...where, lastActiveAt: null };
suspendedAt: { break;
[Op.eq]: null, }
}, case "viewers": {
}; where = { ...where, isViewer: true };
break;
}
case "admins": {
where = { ...where, isAdmin: true };
break;
}
case "suspended": {
where = { ...where, suspendedAt: { [Op.ne]: null } };
break;
}
case "all": {
break;
}
default: {
where = { ...where, suspendedAt: { [Op.eq]: null } };
break;
}
} }
if (query) { if (query) {
@ -47,15 +70,23 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
}; };
} }
const users = await User.findAll({ const [users, total] = await Promise.all([
await User.findAll({
where, where,
order: [[sort, direction]], order: [[sort, direction]],
offset: ctx.state.pagination.offset, offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
}); }),
await User.count({
where,
}),
]);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: {
...ctx.state.pagination,
total,
},
data: users.map((user) => data: users.map((user) =>
presentUser(user, { includeDetails: can(actor, "readDetails", user) }) presentUser(user, { includeDetails: can(actor, "readDetails", user) })
), ),

View File

@ -35,7 +35,7 @@ describe("#users.list", () => {
expect(body.data[0].id).toEqual(user.id); expect(body.data[0].id).toEqual(user.id);
}); });
it("should allow including suspended", async () => { it("should allow filtering to suspended users", async () => {
const user = await buildUser({ name: "Tester" }); const user = await buildUser({ name: "Tester" });
await buildUser({ await buildUser({
name: "Tester", name: "Tester",
@ -46,14 +46,35 @@ describe("#users.list", () => {
const res = await server.post("/api/users.list", { const res = await server.post("/api/users.list", {
body: { body: {
query: "test", query: "test",
includeSuspended: true, filter: "suspended",
token: user.getJwtToken(), token: user.getJwtToken(),
}, },
}); });
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2); expect(body.data.length).toEqual(1);
});
it("should allow filtering to invited", async () => {
const user = await buildUser({ name: "Tester" });
await buildUser({
name: "Tester",
teamId: user.teamId,
lastActiveAt: null,
});
const res = await server.post("/api/users.list", {
body: {
query: "test",
filter: "invited",
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
}); });
it("should return teams paginated user list", async () => { it("should return teams paginated user list", async () => {

View File

@ -125,7 +125,7 @@
"Team": "Team", "Team": "Team",
"Details": "Details", "Details": "Details",
"Security": "Security", "Security": "Security",
"People": "People", "Members": "Members",
"Groups": "Groups", "Groups": "Groups",
"Share Links": "Share Links", "Share Links": "Share Links",
"Import": "Import", "Import": "Import",
@ -134,6 +134,8 @@
"Installation": "Installation", "Installation": "Installation",
"Unstar": "Unstar", "Unstar": "Unstar",
"Star": "Star", "Star": "Star",
"Previous page": "Previous page",
"Next page": "Next page",
"Could not import file": "Could not import file", "Could not import file": "Could not import file",
"Appearance": "Appearance", "Appearance": "Appearance",
"System": "System", "System": "System",
@ -147,7 +149,6 @@
"Show path to document": "Show path to document", "Show path to document": "Show path to document",
"Path to document": "Path to document", "Path to document": "Path to document",
"Group member options": "Group member options", "Group member options": "Group member options",
"Members": "Members",
"Remove": "Remove", "Remove": "Remove",
"Collection": "Collection", "Collection": "Collection",
"New document": "New document", "New document": "New document",
@ -365,10 +366,18 @@
"No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>", "No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>",
"Create a new document?": "Create a new document?", "Create a new document?": "Create a new document?",
"Clear filters": "Clear filters", "Clear filters": "Clear filters",
"Email": "Email",
"Last active": "Last active",
"Role": "Role",
"Viewer": "Viewer",
"Suspended": "Suspended",
"Shared": "Shared", "Shared": "Shared",
"by {{ name }}": "by {{ name }}", "by {{ name }}": "by {{ name }}",
"Last accessed": "Last accessed", "Last accessed": "Last accessed",
"Add to Slack": "Add to Slack", "Add to Slack": "Add to Slack",
"Active": "Active",
"Everyone": "Everyone",
"Admins": "Admins",
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.", "Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
"New group": "New group", "New group": "New group",
"All groups": "All groups", "All groups": "All groups",
@ -387,11 +396,7 @@
"Requesting Export": "Requesting Export", "Requesting Export": "Requesting Export",
"Export Data": "Export Data", "Export Data": "Export Data",
"Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.", "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.",
"Active": "Active", "Filter": "Filter",
"Admins": "Admins",
"Suspended": "Suspended",
"Everyone": "Everyone",
"No people to see here.": "No people to see here.",
"Profile saved": "Profile saved", "Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated", "Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture", "Unable to upload new profile picture": "Unable to upload new profile picture",

View File

@ -10792,6 +10792,11 @@ react-side-effect@^1.1.0:
dependencies: dependencies:
shallowequal "^1.0.1" shallowequal "^1.0.1"
react-table@^7.7.0:
version "7.7.0"
resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.7.0.tgz#e2ce14d7fe3a559f7444e9ecfe8231ea8373f912"
integrity sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA==
react-virtualized-auto-sizer@^1.0.2: react-virtualized-auto-sizer@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd" resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"