From b0196f0cf01a57a9bae83995441e01e2f2d42031 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 19 May 2021 21:36:10 -0700 Subject: [PATCH] feat: Rebuilt member admin (#2139) --- app/components/ContextMenu/MenuItem.js | 8 +- .../Search => }/components/FilterOptions.js | 64 ++-- app/components/Flex.js | 2 + app/components/InputSearch.js | 5 +- app/components/Mask.js | 6 +- app/components/Sidebar/Main.js | 2 +- app/components/Sidebar/Settings.js | 4 +- app/components/Table.js | 250 +++++++++++++ app/routes/settings.js | 6 +- .../Search/components/CollectionFilter.js | 2 +- app/scenes/Search/components/DateFilter.js | 2 +- app/scenes/Search/components/FilterOption.js | 85 ----- app/scenes/Search/components/StatusFilter.js | 2 +- app/scenes/Search/components/UserFilter.js | 2 +- app/scenes/Settings/People.js | 350 +++++++++++------- app/scenes/Settings/components/PeopleTable.js | 92 +++++ .../Settings/components/UserStatusFilter.js | 54 +++ app/stores/BaseStore.js | 2 +- package.json | 1 + server/api/users.js | 71 +++- server/api/users.test.js | 27 +- shared/i18n/locales/en_US/translation.json | 19 +- yarn.lock | 5 + 23 files changed, 770 insertions(+), 291 deletions(-) rename app/{scenes/Search => }/components/FilterOptions.js (61%) create mode 100644 app/components/Table.js delete mode 100644 app/scenes/Search/components/FilterOption.js create mode 100644 app/scenes/Settings/components/PeopleTable.js create mode 100644 app/scenes/Settings/components/UserStatusFilter.js diff --git a/app/components/ContextMenu/MenuItem.js b/app/components/ContextMenu/MenuItem.js index 5fd3a4b0..1be7feb6 100644 --- a/app/components/ContextMenu/MenuItem.js +++ b/app/components/ContextMenu/MenuItem.js @@ -48,12 +48,13 @@ const MenuItem = ({ {(props) => ( {selected !== undefined && ( <> - {selected ? : } + {selected ? : }   )} @@ -64,9 +65,10 @@ const MenuItem = ({ ); }; -const Spacer = styled.div` +const Spacer = styled.svg` width: 24px; height: 24px; + flex-shrink: 0; `; export const MenuAnchor = styled.a` @@ -118,7 +120,7 @@ export const MenuAnchor = styled.a` `}; ${breakpoint("tablet")` - padding: 6px 12px; + padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")}; font-size: 15px; `}; `; diff --git a/app/scenes/Search/components/FilterOptions.js b/app/components/FilterOptions.js similarity index 61% rename from app/scenes/Search/components/FilterOptions.js rename to app/components/FilterOptions.js index f788d504..9221dda6 100644 --- a/app/scenes/Search/components/FilterOptions.js +++ b/app/components/FilterOptions.js @@ -5,7 +5,8 @@ import { useMenuState, MenuButton } from "reakit/Menu"; import styled from "styled-components"; import Button, { Inner } from "components/Button"; import ContextMenu from "components/ContextMenu"; -import FilterOption from "./FilterOption"; +import MenuItem from "components/ContextMenu/MenuItem"; +import HelpText from "components/HelpText"; type TFilterOption = {| key: string, @@ -35,7 +36,7 @@ const FilterOptions = ({ const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : ""; return ( - + {(props) => ( - - {options.map((option) => ( - { - onSelect(option.key); - menu.hide(); - }} - active={option.key === activeKey} - {...option} - {...menu} - /> - ))} - + {options.map((option) => ( + { + onSelect(option.key); + menu.hide(); + }} + selected={option.key === activeKey} + {...menu} + > + {option.note ? ( + + {option.label} + {option.note} + + ) : ( + option.label + )} + + ))} - + ); }; +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)` box-shadow: none; text-transform: none; border-color: transparent; - height: 28px; &:hover { background: transparent; @@ -84,14 +104,8 @@ const StyledButton = styled(Button)` } `; -const SearchFilter = styled.div` +const Wrapper = styled.div` margin-right: 8px; `; -const List = styled("ol")` - list-style: none; - margin: 0; - padding: 0 8px; -`; - export default FilterOptions; diff --git a/app/components/Flex.js b/app/components/Flex.js index c85ebc84..ca93ad23 100644 --- a/app/components/Flex.js +++ b/app/components/Flex.js @@ -25,6 +25,7 @@ type Props = {| className?: string, children?: React.Node, role?: string, + gap?: number, |}; const Flex = React.forwardRef((props: Props, ref) => { @@ -44,6 +45,7 @@ const Container = styled.div` align-items: ${({ align }) => align}; justify-content: ${({ justify }) => justify}; flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")}; + gap: ${({ gap }) => `${gap}px` || "initial"}; min-height: 0; min-width: 0; `; diff --git a/app/components/InputSearch.js b/app/components/InputSearch.js index 0d98753f..6c9180d3 100644 --- a/app/components/InputSearch.js +++ b/app/components/InputSearch.js @@ -3,13 +3,14 @@ import { SearchIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components"; -import Input from "./Input"; +import Input, { type Props as InputProps } from "./Input"; type Props = {| + ...InputProps, placeholder?: string, value?: string, onChange: (event: SyntheticInputEvent<>) => mixed, - onKeyDown: (event: SyntheticKeyboardEvent) => mixed, + onKeyDown?: (event: SyntheticKeyboardEvent) => mixed, |}; export default function InputSearch(props: Props) { diff --git a/app/components/Mask.js b/app/components/Mask.js index 96776bfc..ce407c74 100644 --- a/app/components/Mask.js +++ b/app/components/Mask.js @@ -8,6 +8,8 @@ import Flex from "components/Flex"; type Props = {| header?: boolean, height?: number, + minWidth?: number, + maxWidth?: number, |}; class Mask extends React.Component { @@ -17,9 +19,9 @@ class Mask extends React.Component { return false; } - constructor() { + constructor(props: Props) { super(); - this.width = randomInteger(75, 100); + this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100); } render() { diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index ecbcb74b..dd06a7b7 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -179,7 +179,7 @@ function MainSidebar() { /> {can.inviteUser && ( } label={`${t("Invite people")}…`} diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index 240d1812..9d9ffa52 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -96,10 +96,10 @@ function SettingsSidebar() { /> )} } exact={false} - label={t("People")} + label={t("Members")} /> , + 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 ( + <> + + + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((column) => ( + + + {column.render("Header")} + {column.isSorted && + (column.isSortedDesc ? ( + + ) : ( + + ))} + + + ))} + + ))} + + + {rows.map((row) => { + prepareRow(row); + return ( + + {row.cells.map((cell) => ( + + {cell.render("Cell")} + + ))} + + ); + })} + + {showPlaceholder && } + + {isEmpty ? ( + empty || {t("No results")} + ) : ( + + {/* Note: the page > 0 check shouldn't be needed here but is */} + {canPreviousPage && page > 0 && ( + + )} + {canNextPage && ( + + )} + + )} + + ); +} + +export const Placeholder = ({ + columns, + rows = 3, +}: { + columns: number, + rows?: number, +}) => { + return ( + + {new Array(rows).fill().map((_, row) => ( + + {new Array(columns).fill().map((_, col) => ( + + + + ))} + + ))} + + ); +}; + +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); diff --git a/app/routes/settings.js b/app/routes/settings.js index 79e178c4..c52b9611 100644 --- a/app/routes/settings.js +++ b/app/routes/settings.js @@ -1,6 +1,6 @@ // @flow 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 Groups from "scenes/Settings/Groups"; import ImportExport from "scenes/Settings/ImportExport"; @@ -20,8 +20,7 @@ export default function SettingsRoutes() { - - + @@ -29,6 +28,7 @@ export default function SettingsRoutes() { + ); } diff --git a/app/scenes/Search/components/CollectionFilter.js b/app/scenes/Search/components/CollectionFilter.js index 028a9c66..f755f389 100644 --- a/app/scenes/Search/components/CollectionFilter.js +++ b/app/scenes/Search/components/CollectionFilter.js @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import FilterOptions from "./FilterOptions"; +import FilterOptions from "components/FilterOptions"; import useStores from "hooks/useStores"; type Props = {| diff --git a/app/scenes/Search/components/DateFilter.js b/app/scenes/Search/components/DateFilter.js index f4c47773..fa166248 100644 --- a/app/scenes/Search/components/DateFilter.js +++ b/app/scenes/Search/components/DateFilter.js @@ -1,7 +1,7 @@ // @flow import * as React from "react"; import { useTranslation } from "react-i18next"; -import FilterOptions from "./FilterOptions"; +import FilterOptions from "components/FilterOptions"; type Props = {| dateFilter: ?string, diff --git a/app/scenes/Search/components/FilterOption.js b/app/scenes/Search/components/FilterOption.js deleted file mode 100644 index f51cc392..00000000 --- a/app/scenes/Search/components/FilterOption.js +++ /dev/null @@ -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 ( - - {(props) => ( - - - - )} - - ); -}; - -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; diff --git a/app/scenes/Search/components/StatusFilter.js b/app/scenes/Search/components/StatusFilter.js index 8c6aaf62..f1b92e74 100644 --- a/app/scenes/Search/components/StatusFilter.js +++ b/app/scenes/Search/components/StatusFilter.js @@ -1,7 +1,7 @@ // @flow import * as React from "react"; import { useTranslation } from "react-i18next"; -import FilterOptions from "./FilterOptions"; +import FilterOptions from "components/FilterOptions"; type Props = {| includeArchived?: boolean, diff --git a/app/scenes/Search/components/UserFilter.js b/app/scenes/Search/components/UserFilter.js index f96997e4..9fab2c0c 100644 --- a/app/scenes/Search/components/UserFilter.js +++ b/app/scenes/Search/components/UserFilter.js @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import FilterOptions from "./FilterOptions"; +import FilterOptions from "components/FilterOptions"; import useStores from "hooks/useStores"; type Props = {| diff --git a/app/scenes/Settings/People.js b/app/scenes/Settings/People.js index 25d7f406..af2c48de 100644 --- a/app/scenes/Settings/People.js +++ b/app/scenes/Settings/People.js @@ -1,161 +1,245 @@ // @flow -import invariant from "invariant"; -import { observable } from "mobx"; -import { observer, inject } from "mobx-react"; +import { sortBy } from "lodash"; +import { observer } from "mobx-react"; import { PlusIcon, UserIcon } from "outline-icons"; import * as React from "react"; -import { withTranslation, type TFunction, Trans } from "react-i18next"; -import { type Match } from "react-router-dom"; -import AuthStore from "stores/AuthStore"; -import PoliciesStore from "stores/PoliciesStore"; -import UsersStore from "stores/UsersStore"; +import { Trans, useTranslation } from "react-i18next"; +import { useHistory, useLocation } from "react-router-dom"; +import scrollIntoView from "smooth-scroll-into-view-if-needed"; import Invite from "scenes/Invite"; -import Bubble from "components/Bubble"; +import { Action } from "components/Actions"; import Button from "components/Button"; -import Empty from "components/Empty"; +import Flex from "components/Flex"; import Heading from "components/Heading"; import HelpText from "components/HelpText"; +import InputSearch from "components/InputSearch"; import Modal from "components/Modal"; -import PaginatedList from "components/PaginatedList"; import Scene from "components/Scene"; -import Tab from "components/Tab"; -import Tabs, { Separator } from "components/Tabs"; -import UserListItem from "./components/UserListItem"; +import PeopleTable from "./components/PeopleTable"; +import UserStatusFilter from "./components/UserStatusFilter"; +import useCurrentTeam from "hooks/useCurrentTeam"; +import useQuery from "hooks/useQuery"; +import useStores from "hooks/useStores"; -type Props = { - auth: AuthStore, - users: UsersStore, - policies: PoliciesStore, - match: Match, - t: TFunction, -}; +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 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; -@observer -class People extends React.Component { - @observable inviteModalOpen: boolean = false; + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); - componentDidMount() { - const { team } = this.props.auth; - if (team) { - this.props.users.fetchCounts(team.id); - } - } + try { + const response = await users.fetchPage({ + offset: page * limit, + limit, + sort, + direction, + query, + filter, + }); - handleInviteModalOpen = () => { - this.inviteModalOpen = true; - }; + setTotalPages(Math.ceil(response.pagination.total / limit)); + setUserIds(response.data.map((u) => u.id)); + } finally { + setIsLoading(false); + } + }; - handleInviteModalClose = () => { - this.inviteModalOpen = false; - }; + fetchData(); + }, [query, sort, filter, page, direction, users]); - 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; + 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") { - users = this.props.users.admins; + filtered = users.admins.filter((u) => userIds.includes(u.id)); } else if (filter === "suspended") { - users = this.props.users.suspended; + filtered = users.suspended.filter((u) => userIds.includes(u.id)); } else if (filter === "invited") { - users = this.props.users.invited; + filtered = users.invited.filter((u) => userIds.includes(u.id)); } else if (filter === "viewers") { - users = this.props.users.viewers; + filtered = users.viewers.filter((u) => userIds.includes(u.id)); } - const can = policies.abilities(team.id); - const { counts } = this.props.users; + // 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, + ]); - return ( - }> - {t("People")} - - - Everyone that has signed into Outline appears here. It’s possible - that there are other users who have access through{" "} - {team.signinMethods} but haven’t signed in yet. - - - {can.inviteUser && ( - - )} + const handleInviteModalOpen = React.useCallback(() => { + setInviteModalOpen(true); + }, []); - - - {t("Active")} - - - {t("Admins")} - - {can.update && ( - - {t("Suspended")} - - )} - - {t("Viewers")} - - - {t("Everyone")} - + 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 ( + } + actions={ + <> {can.inviteUser && ( - <> - - - {t("Invited")} - - - )} - - {t("No people to see here.")}} - fetch={this.fetchPage} - renderItem={(item) => ( - + + + )} + + } + > + {t("Members")} + + + Everyone that has signed into Outline appears here. It’s possible that + there are other users who have access through {team.signinMethods} but + haven’t signed in yet. + + + + - {can.inviteUser && ( - - - - )} - - ); - } + + + + {can.inviteUser && ( + + + + )} + + ); } -export default inject( - "auth", - "users", - "policies" -)(withTranslation()(People)); +export default observer(People); diff --git a/app/scenes/Settings/components/PeopleTable.js b/app/scenes/Settings/components/PeopleTable.js new file mode 100644 index 00000000..898aacc3 --- /dev/null +++ b/app/scenes/Settings/components/PeopleTable.js @@ -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(() => import("components/Table")); + +type Props = {| + ...$Diff, + 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 }) => ( + + {value}{" "} + {currentUser.id === row.original.id && `(${t("You")})`} + + )), + }, + 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 &&