feat: Rebuilt member admin (#2139)

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

View File

@@ -48,12 +48,13 @@ const MenuItem = ({
{(props) => (
<MenuAnchor
{...props}
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
&nbsp;
</>
)}
@@ -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;
`};
`;

View File

@@ -0,0 +1,111 @@
// @flow
import { find } from "lodash";
import * as React from "react";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import Button, { Inner } from "components/Button";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import HelpText from "components/HelpText";
type TFilterOption = {|
key: string,
label: string,
note?: string,
|};
type Props = {|
options: TFilterOption[],
activeKey: ?string,
defaultLabel?: string,
selectedPrefix?: string,
className?: string,
onSelect: (key: ?string) => void,
|};
const FilterOptions = ({
options,
activeKey = "",
defaultLabel,
selectedPrefix = "",
className,
onSelect,
}: Props) => {
const menu = useMenuState({ modal: true });
const selected = find(options, { key: activeKey }) || options[0];
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
return (
<Wrapper>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
neutral
disclosure
small
>
{activeKey ? selectedLabel : defaultLabel}
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} {...menu}>
{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>
</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;
&:hover {
background: transparent;
}
${Inner} {
line-height: 28px;
}
`;
const Wrapper = styled.div`
margin-right: 8px;
`;
export default FilterOptions;

View File

@@ -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;
`;

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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")}`}

View File

@@ -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"

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

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