feat: Rebuilt member admin (#2139)
This commit is contained in:
@ -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 />}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -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;
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
@ -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;
|
@ -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;
|
||||||
`;
|
`;
|
||||||
|
@ -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) {
|
||||||
|
@ -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() {
|
||||||
|
@ -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")}…`}
|
||||||
|
@ -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
250
app/components/Table.js
Normal 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);
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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 = {|
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
|
@ -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,
|
||||||
|
@ -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 = {|
|
||||||
|
@ -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. It’s possible
|
<>
|
||||||
that there are other users who have access through{" "}
|
|
||||||
{team.signinMethods} but haven’t 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. It’s possible that
|
||||||
renderItem={(item) => (
|
there are other users who have access through {team.signinMethods} but
|
||||||
<UserListItem
|
haven’t 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));
|
|
||||||
|
92
app/scenes/Settings/components/PeopleTable.js
Normal file
92
app/scenes/Settings/components/PeopleTable.js
Normal 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;
|
54
app/scenes/Settings/components/UserStatusFilter.js
Normal file
54
app/scenes/Settings/components/UserStatusFilter.js
Normal 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;
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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) })
|
||||||
),
|
),
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "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.",
|
"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.": "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.",
|
||||||
"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",
|
||||||
|
@ -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"
|
||||||
|
Reference in New Issue
Block a user