feat: Rebuilt member admin (#2139)
This commit is contained in:
parent
833bd51f4c
commit
b0196f0cf0
|
@ -48,12 +48,13 @@ const MenuItem = ({
|
|||
{(props) => (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$toggleable={selected !== undefined}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{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;
|
||||
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;
|
||||
`};
|
||||
`;
|
||||
|
|
|
@ -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 (
|
||||
<SearchFilter>
|
||||
<Wrapper>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
|
@ -50,30 +51,49 @@ const FilterOptions = ({
|
|||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} {...menu}>
|
||||
<List>
|
||||
{options.map((option) => (
|
||||
<FilterOption
|
||||
key={option.key}
|
||||
onSelect={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
active={option.key === activeKey}
|
||||
{...option}
|
||||
{...menu}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={option.key === activeKey}
|
||||
{...menu}
|
||||
>
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</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)`
|
||||
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;
|
|
@ -25,6 +25,7 @@ type Props = {|
|
|||
className?: string,
|
||||
children?: React.Node,
|
||||
role?: string,
|
||||
gap?: number,
|
||||
|};
|
||||
|
||||
const Flex = React.forwardRef<Props, HTMLDivElement>((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;
|
||||
`;
|
||||
|
|
|
@ -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<HTMLInputElement>) => mixed,
|
||||
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
|};
|
||||
|
||||
export default function InputSearch(props: Props) {
|
||||
|
|
|
@ -8,6 +8,8 @@ import Flex from "components/Flex";
|
|||
type Props = {|
|
||||
header?: boolean,
|
||||
height?: number,
|
||||
minWidth?: number,
|
||||
maxWidth?: number,
|
||||
|};
|
||||
|
||||
class Mask extends React.Component<Props> {
|
||||
|
@ -17,9 +19,9 @@ class Mask extends React.Component<Props> {
|
|||
return false;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
constructor(props: Props) {
|
||||
super();
|
||||
this.width = randomInteger(75, 100);
|
||||
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -179,7 +179,7 @@ function MainSidebar() {
|
|||
/>
|
||||
{can.inviteUser && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
to="/settings/members"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
|
|
|
@ -96,10 +96,10 @@ function SettingsSidebar() {
|
|||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
to="/settings/members"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
label={t("Members")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
|
|
|
@ -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
|
||||
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() {
|
|||
<Route exact path="/settings" component={Profile} />
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
<Route exact path="/settings/people" component={People} />
|
||||
<Route exact path="/settings/people/:filter" component={People} />
|
||||
<Route exact path="/settings/members" component={People} />
|
||||
<Route exact path="/settings/groups" component={Groups} />
|
||||
<Route exact path="/settings/shares" component={Shares} />
|
||||
<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/zapier" component={Zapier} />
|
||||
<Route exact path="/settings/import-export" component={ImportExport} />
|
||||
<Redirect from="/settings/people" to="/settings/members" />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 = {|
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
import FilterOptions from "components/FilterOptions";
|
||||
|
||||
type Props = {|
|
||||
includeArchived?: boolean,
|
||||
|
|
|
@ -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 = {|
|
||||
|
|
|
@ -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<Props> {
|
||||
@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 (
|
||||
<Scene title={t("People")} icon={<UserIcon color="currentColor" />}>
|
||||
<Heading>{t("People")}</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
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 && (
|
||||
<Button
|
||||
type="button"
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Invite people")}…
|
||||
</Button>
|
||||
)}
|
||||
const handleInviteModalOpen = React.useCallback(() => {
|
||||
setInviteModalOpen(true);
|
||||
}, []);
|
||||
|
||||
<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>
|
||||
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 (
|
||||
<Scene
|
||||
title={t("Members")}
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.inviteUser && (
|
||||
<>
|
||||
<Separator />
|
||||
<Tab to="/settings/people/invited" exact>
|
||||
{t("Invited")} <Bubble count={counts.invited} />
|
||||
</Tab>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
<PaginatedList
|
||||
items={users}
|
||||
empty={<Empty>{t("No people to see here.")}</Empty>}
|
||||
fetch={this.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<UserListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
showMenu={can.update && currentUser.id !== item.id}
|
||||
/>
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("Invite people")}…
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("Members")}</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
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>
|
||||
<Flex gap={8}>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
{can.inviteUser && (
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
<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 && (
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject(
|
||||
"auth",
|
||||
"users",
|
||||
"policies"
|
||||
)(withTranslation()<People>(People));
|
||||
export default observer(People);
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
this.isLoaded = true;
|
||||
});
|
||||
return res.data;
|
||||
return res;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
|
|
|
@ -162,6 +162,7 @@
|
|||
"react-keydown": "^1.7.3",
|
||||
"react-portal": "^4.0.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-table": "^7.7.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-waypoint": "^9.0.2",
|
||||
"react-window": "^1.8.6",
|
||||
|
|
|
@ -14,28 +14,51 @@ const { can, authorize } = policy;
|
|||
const router = new Router();
|
||||
|
||||
router.post("users.list", auth(), pagination(), async (ctx) => {
|
||||
let {
|
||||
sort = "createdAt",
|
||||
query,
|
||||
direction,
|
||||
includeSuspended = false,
|
||||
} = ctx.body;
|
||||
let { sort = "createdAt", query, direction, filter } = ctx.body;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
ctx.assertSort(sort, User);
|
||||
|
||||
if (filter) {
|
||||
ctx.assertIn(filter, [
|
||||
"invited",
|
||||
"viewers",
|
||||
"admins",
|
||||
"active",
|
||||
"all",
|
||||
"suspended",
|
||||
]);
|
||||
}
|
||||
|
||||
const actor = ctx.state.user;
|
||||
|
||||
let where = {
|
||||
teamId: actor.teamId,
|
||||
};
|
||||
|
||||
if (!includeSuspended) {
|
||||
where = {
|
||||
...where,
|
||||
suspendedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
};
|
||||
switch (filter) {
|
||||
case "invited": {
|
||||
where = { ...where, lastActiveAt: null };
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
|
@ -47,15 +70,23 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
|
|||
};
|
||||
}
|
||||
|
||||
const users = await User.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
const [users, total] = await Promise.all([
|
||||
await User.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
await User.count({
|
||||
where,
|
||||
}),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
pagination: {
|
||||
...ctx.state.pagination,
|
||||
total,
|
||||
},
|
||||
data: users.map((user) =>
|
||||
presentUser(user, { includeDetails: can(actor, "readDetails", user) })
|
||||
),
|
||||
|
|
|
@ -35,7 +35,7 @@ describe("#users.list", () => {
|
|||
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" });
|
||||
await buildUser({
|
||||
name: "Tester",
|
||||
|
@ -46,14 +46,35 @@ describe("#users.list", () => {
|
|||
const res = await server.post("/api/users.list", {
|
||||
body: {
|
||||
query: "test",
|
||||
includeSuspended: true,
|
||||
filter: "suspended",
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
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 () => {
|
||||
|
|
|
@ -125,7 +125,7 @@
|
|||
"Team": "Team",
|
||||
"Details": "Details",
|
||||
"Security": "Security",
|
||||
"People": "People",
|
||||
"Members": "Members",
|
||||
"Groups": "Groups",
|
||||
"Share Links": "Share Links",
|
||||
"Import": "Import",
|
||||
|
@ -134,6 +134,8 @@
|
|||
"Installation": "Installation",
|
||||
"Unstar": "Unstar",
|
||||
"Star": "Star",
|
||||
"Previous page": "Previous page",
|
||||
"Next page": "Next page",
|
||||
"Could not import file": "Could not import file",
|
||||
"Appearance": "Appearance",
|
||||
"System": "System",
|
||||
|
@ -147,7 +149,6 @@
|
|||
"Show path to document": "Show path to document",
|
||||
"Path to document": "Path to document",
|
||||
"Group member options": "Group member options",
|
||||
"Members": "Members",
|
||||
"Remove": "Remove",
|
||||
"Collection": "Collection",
|
||||
"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>",
|
||||
"Create a new document?": "Create a new document?",
|
||||
"Clear filters": "Clear filters",
|
||||
"Email": "Email",
|
||||
"Last active": "Last active",
|
||||
"Role": "Role",
|
||||
"Viewer": "Viewer",
|
||||
"Suspended": "Suspended",
|
||||
"Shared": "Shared",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Last accessed": "Last accessed",
|
||||
"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.",
|
||||
"New group": "New group",
|
||||
"All groups": "All groups",
|
||||
|
@ -387,11 +396,7 @@
|
|||
"Requesting Export": "Requesting Export",
|
||||
"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.",
|
||||
"Active": "Active",
|
||||
"Admins": "Admins",
|
||||
"Suspended": "Suspended",
|
||||
"Everyone": "Everyone",
|
||||
"No people to see here.": "No people to see here.",
|
||||
"Filter": "Filter",
|
||||
"Profile saved": "Profile saved",
|
||||
"Profile picture updated": "Profile picture updated",
|
||||
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
||||
|
|
|
@ -10792,6 +10792,11 @@ react-side-effect@^1.1.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"
|
||||
|
|
Reference in New Issue