Added useFilterQuery. Converted websites and reports pages.

This commit is contained in:
Mike Cao 2023-09-26 23:20:29 -07:00
parent 6846355c63
commit 7e626dcd52
29 changed files with 373 additions and 499 deletions

View file

@ -1,46 +1,46 @@
import { createContext } from 'react';
import { SearchField } from 'react-basics';
import { useDataTable } from 'components/hooks/useDataTable';
import { Banner, Loading, SearchField } from 'react-basics';
import { useMessages } from 'components/hooks';
import Empty from 'components/common/Empty';
import Pager from 'components/common/Pager';
import styles from './DataTable.module.css';
import classNames from 'classnames';
const DEFAULT_SEARCH_DELAY = 1000;
const DEFAULT_SEARCH_DELAY = 600;
export const DataTableStyles = styles;
export const DataTableContext = createContext(null);
export function DataTable({
data = {},
params = {},
setParams,
isLoading,
error,
searchDelay,
showSearch = true,
showPaging = true,
children,
onChange,
}) {
const { formatMessage, labels, messages } = useMessages();
const dataTable = useDataTable();
const { query, setQuery, data, pageInfo, setPageInfo } = dataTable;
const { page, pageSize, count } = pageInfo || {};
const noResults = Boolean(query && data?.length === 0);
const { pageSize, count } = data;
const { query, page } = params;
const hasData = Boolean(!isLoading && data?.data?.length);
const noResults = Boolean(!isLoading && query && !hasData);
const handleChange = value => {
onChange?.({ query: value, page });
};
const handleSearch = value => {
setQuery(value);
handleChange(value);
const handleSearch = query => {
setParams({ ...params, query });
};
const handlePageChange = page => {
setPageInfo(state => ({ ...state, page }));
setParams({ ...params, page });
};
if (error) {
return <Banner variant="error">{formatMessage(messages.error)}</Banner>;
}
return (
<DataTableContext.Provider value={dataTable}>
{showSearch && (
<>
{(hasData || query || isLoading) && showSearch && (
<SearchField
className={styles.search}
value={query}
@ -50,8 +50,16 @@ export function DataTable({
placeholder={formatMessage(labels.search)}
/>
)}
{noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
<div className={styles.body}>{children}</div>
<div
className={classNames(styles.body, { [styles.status]: isLoading || noResults || !hasData })}
>
{hasData && typeof children === 'function' ? children(data) : children}
{isLoading && <Loading icon="dots" />}
{!isLoading && !hasData && !query && (
<Empty message={formatMessage(messages.noDataAvailable)} />
)}
{noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
</div>
{showPaging && (
<Pager
className={styles.pager}
@ -61,7 +69,7 @@ export function DataTable({
onPageChange={handlePageChange}
/>
)}
</DataTableContext.Provider>
</>
);
}

View file

@ -1,3 +1,12 @@
.table {
grid-template-rows: repeat(auto-fit, max-content);
}
.table td {
align-items: center;
max-height: max-content;
}
.search {
max-width: 300px;
margin: 20px 0;
@ -8,10 +17,22 @@
gap: 5px;
}
.body {
display: flex;
position: relative;
}
.body td {
align-items: center;
}
.pager {
margin-top: 20px;
margin: 20px 0;
}
.status {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { Button, Flexbox, Icon, Icons } from 'react-basics';
import { Button, Icon, Icons } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import styles from './Pager.module.css';
@ -25,21 +25,25 @@ export function Pager({ page, pageSize, count, onPageChange, className }) {
}
return (
<Flexbox justifyContent="center" className={classNames(styles.container, className)}>
<Button onClick={() => handlePageChange(-1)} disabled={firstPage}>
<Icon rotate={90}>
<Icons.ChevronDown />
</Icon>
</Button>
<Flexbox alignItems="center" className={styles.text}>
{formatMessage(labels.pageOf, { current: page, total: maxPage })}
</Flexbox>
<Button onClick={() => handlePageChange(1)} disabled={lastPage}>
<Icon rotate={270}>
<Icons.ChevronDown />
</Icon>
</Button>
</Flexbox>
<div className={classNames(styles.pager, className)}>
<div>{formatMessage(labels.numberOfRecords, { x: count })}</div>
<div className={styles.nav}>
<Button onClick={() => handlePageChange(-1)} disabled={firstPage}>
<Icon rotate={90}>
<Icons.ChevronDown />
</Icon>
</Button>
<div className={styles.text}>
{formatMessage(labels.pageOf, { current: page, total: maxPage })}
</div>
<Button onClick={() => handlePageChange(1)} disabled={lastPage}>
<Icon rotate={270}>
<Icons.ChevronDown />
</Icon>
</Button>
</div>
<div></div>
</div>
);
}

View file

@ -1,4 +1,27 @@
.pager {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
}
.nav {
display: flex;
align-items: center;
justify-content: center;
}
.text {
font-size: var(--font-size-md);
margin: 0 16px;
justify-content: center;
}
@media only screen and (max-width: 992px) {
.pager {
grid-template-columns: repeat(2, 1fr);
}
.nav {
justify-content: end;
}
}

View file

@ -1,13 +0,0 @@
import { useState } from 'react';
import { usePaging } from 'components/hooks/usePaging';
export function useDataTable(config = {}) {
const { initialData, initialQuery, initialPageInfo } = config;
const [data, setData] = useState(initialData ?? null);
const [query, setQuery] = useState(initialQuery ?? '');
const { pageInfo, setPageInfo } = usePaging(initialPageInfo);
return { data, setData, query, setQuery, pageInfo, setPageInfo };
}
export default useDataTable;

View file

@ -0,0 +1,16 @@
import { useState } from 'react';
import { useApi } from 'components/hooks/useApi';
export function useFilterQuery(key, fn, options) {
const [params, setParams] = useState({
query: '',
page: 1,
});
const { useQuery } = useApi();
const result = useQuery([...key, params], fn.bind(null, params), options);
return { ...result, params, setParams };
}
export default useFilterQuery;

View file

@ -1,9 +0,0 @@
import { useState } from 'react';
const DEFAULT_PAGE_INFO = { page: 1, pageSize: 10, total: 0 };
export function usePaging(initialPageInfo) {
const [pageInfo, setPageInfo] = useState(initialPageInfo ?? { ...DEFAULT_PAGE_INFO });
return { pageInfo, setPageInfo };
}

View file

@ -193,6 +193,10 @@ export const labels = defineMessages({
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
create: { id: 'label.create', defaultMessage: 'Create' },
search: { id: 'label.search', defaultMessage: 'Search' },
numberOfRecords: {
id: 'label.number-of-records',
defaultMessage: '{x} {x, plural, one {record} other {records}}',
},
});
export const messages = defineMessages({

View file

@ -1,28 +1,39 @@
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import { useMessages, useReports } from 'components/hooks';
import { useMessages, useApi } from 'components/hooks';
import Link from 'next/link';
import { Button, Icon, Icons, Text } from 'react-basics';
import ReportsTable from './ReportsTable';
import useFilterQuery from 'components/hooks/useFilterQuery';
import DataTable from 'components/common/DataTable';
function useReports() {
const { get, del, useMutation } = useApi();
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
const reports = useFilterQuery(['reports'], params => get(`/reports`, params));
const deleteReport = id => {
mutate(id, {
onSuccess: () => {
reports.refetch();
},
});
};
return { reports, deleteReport };
}
export function ReportsPage() {
const { formatMessage, labels } = useMessages();
const {
reports,
error,
isLoading,
deleteReport,
filter,
handleFilterChange,
handlePageChange,
handlePageSizeChange,
} = useReports();
const { reports, deleteReport } = useReports();
const hasData = (reports && reports?.data.length !== 0) || filter;
const handleDelete = async (id, callback) => {
await deleteReport(id);
await reports.refetch();
callback?.();
};
return (
<Page loading={isLoading} error={error}>
<>
<PageHeader title={formatMessage(labels.reports)}>
<Link href="/reports/create">
<Button variant="primary">
@ -33,22 +44,10 @@ export function ReportsPage() {
</Button>
</Link>
</PageHeader>
{hasData && (
<ReportsTable
data={reports}
showSearch={true}
showPaging={true}
onFilterChange={handleFilterChange}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onDelete={deleteReport}
filterValue={filter}
showDomain={true}
/>
)}
{!hasData && <EmptyPlaceholder />}
</Page>
<DataTable {...reports}>
{({ data }) => <ReportsTable data={data} showDomain={true} onDelete={handleDelete} />}
</DataTable>
</>
);
}

View file

@ -1,96 +1,74 @@
import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm';
import LinkButton from 'components/common/LinkButton';
import SettingsTable from 'components/common/SettingsTable';
import { useMessages } from 'components/hooks';
import useUser from 'components/hooks/useUser';
import { useState } from 'react';
import { Button, Flexbox, Icon, Icons, Modal, Text } from 'react-basics';
import {
Button,
Flexbox,
GridColumn,
GridTable,
Icon,
Icons,
Modal,
ModalTrigger,
Text,
} from 'react-basics';
import { REPORT_TYPES } from 'lib/constants';
export function ReportsTable({
data = [],
onDelete = () => {},
filterValue,
onFilterChange,
onPageChange,
onPageSizeChange,
showDomain,
}) {
const [report, setReport] = useState(null);
export function ReportsTable({ data = [], onDelete, showDomain }) {
const { formatMessage, labels } = useMessages();
const { user } = useUser();
const domainColumn = [
{
name: 'domain',
label: formatMessage(labels.domain),
},
];
const columns = [
{ name: 'name', label: formatMessage(labels.name) },
{ name: 'description', label: formatMessage(labels.description) },
{ name: 'type', label: formatMessage(labels.type) },
...(showDomain ? domainColumn : []),
{ name: 'action', label: ' ' },
];
const cellRender = (row, data, key) => {
if (key === 'type') {
return formatMessage(
labels[Object.keys(REPORT_TYPES).find(key => REPORT_TYPES[key] === row.type)],
);
}
return data[key];
};
const handleConfirm = () => {
onDelete(report.id);
const handleConfirm = (id, callback) => {
onDelete?.(id, callback);
};
return (
<>
<SettingsTable
columns={columns}
cellRender={cellRender}
data={data}
showSearch={true}
showPaging={true}
onFilterChange={onFilterChange}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
filterValue={filterValue}
>
<GridTable data={data}>
<GridColumn name="name" label={formatMessage(labels.name)} />
<GridColumn name="description" label={formatMessage(labels.description)} />
<GridColumn name="type" label={formatMessage(labels.type)}>
{row => {
const { id, userId: reportOwnerId, website } = row;
if (showDomain) {
row.domain = website.domain;
}
return formatMessage(
labels[Object.keys(REPORT_TYPES).find(key => REPORT_TYPES[key] === row.type)],
);
}}
</GridColumn>
{showDomain && (
<GridColumn name="domain" label={formatMessage(labels.domain)}>
{row => row.website.domain}
</GridColumn>
)}
<GridColumn name="action" label="" alignment="end">
{row => {
const { id, name, userId, website } = row;
return (
<Flexbox gap={10}>
<LinkButton href={`/reports/${id}`}>{formatMessage(labels.view)}</LinkButton>
{!showDomain || user.id === reportOwnerId || user.id === website?.userId}
<Button onClick={() => setReport(row)}>
<Icon>
<Icons.Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Button>
{(user.id === userId || user.id === website?.userId) && (
<ModalTrigger>
<Button>
<Icon>
<Icons.Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Button>
<Modal>
{close => (
<ConfirmDeleteForm
name={name}
onConfirm={handleConfirm.bind(null, id, close)}
onClose={close}
/>
)}
</Modal>
</ModalTrigger>
)}
</Flexbox>
);
}}
</SettingsTable>
{report && (
<Modal>
<ConfirmDeleteForm
name={report.name}
onConfirm={handleConfirm}
onClose={() => setReport(null)}
/>
</Modal>
)}
</>
</GridColumn>
</GridTable>
);
}

View file

@ -1,13 +1,13 @@
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import WebsiteAddForm from 'components/pages/settings/websites/WebsiteAddForm';
import WebsitesTable from 'components/pages/settings/websites/WebsitesTable';
import useApi from 'components/hooks/useApi';
import useMessages from 'components/hooks/useMessages';
import useUser from 'components/hooks/useUser';
import { ROLES } from 'lib/constants';
import { Button, Icon, Icons, Modal, ModalTrigger, Text, useToasts } from 'react-basics';
import { useRef, useState } from 'react';
import useApi from 'components/hooks/useApi';
import DataTable from 'components/common/DataTable';
import useFilterQuery from 'components/hooks/useFilterQuery';
export function WebsitesList({
showTeam,
@ -18,13 +18,10 @@ export function WebsitesList({
}) {
const { formatMessage, labels, messages } = useMessages();
const { user } = useUser();
const [params, setParams] = useState({});
const { get, useQuery } = useApi();
const count = useRef(0);
const q = useQuery(
['websites', includeTeams, onlyTeams, params],
() => {
count.current += 1;
const { get } = useApi();
const filterQuery = useFilterQuery(
['websites', { includeTeams, onlyTeams }],
params => {
return get(`/users/${user?.id}/websites`, {
includeTeams,
onlyTeams,
@ -33,46 +30,41 @@ export function WebsitesList({
},
{ enabled: !!user },
);
const { data, refetch, isLoading, error } = q;
const { refetch } = filterQuery;
const { showToast } = useToasts();
const handleChange = params => {
setParams(params);
};
const handleSave = async () => {
await refetch();
showToast({ message: formatMessage(messages.saved), variant: 'success' });
};
const addButton = (
<>
{user.role !== ROLES.viewOnly && (
<ModalTrigger>
<Button variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.addWebsite)}</Text>
</Button>
<Modal title={formatMessage(labels.addWebsite)}>
{close => <WebsiteAddForm onSave={handleSave} onClose={close} />}
</Modal>
</ModalTrigger>
)}
</>
<ModalTrigger>
<Button variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.addWebsite)}</Text>
</Button>
<Modal title={formatMessage(labels.addWebsite)}>
{close => <WebsiteAddForm onSave={handleSave} onClose={close} />}
</Modal>
</ModalTrigger>
);
return (
<Page loading={isLoading && count.current === 0} error={error}>
{showHeader && <PageHeader title={formatMessage(labels.websites)}>{addButton}</PageHeader>}
<WebsitesTable
data={data?.data}
showTeam={showTeam}
showEditButton={showEditButton}
onChange={handleChange}
/>
</Page>
<>
{showHeader && (
<PageHeader title={formatMessage(labels.websites)}>
{user.role !== ROLES.viewOnly && addButton}
</PageHeader>
)}
<DataTable {...filterQuery}>
{({ data }) => (
<WebsitesTable data={data} showTeam={showTeam} showEditButton={showEditButton} />
)}
</DataTable>
</>
);
}

View file

@ -1,154 +1,58 @@
import Link from 'next/link';
import { Button, Text, Icon, Icons, GridTable, GridColumn } from 'react-basics';
import SettingsTable from 'components/common/SettingsTable';
import Empty from 'components/common/Empty';
import { Button, Text, Icon, Icons, GridTable, GridColumn, Flexbox } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import useUser from 'components/hooks/useUser';
import DataTable, { DataTableStyles } from 'components/common/DataTable';
export function WebsitesTable({
data = [],
showTeam,
showEditButton,
openExternal = false,
onChange,
}) {
export function WebsitesTable({ data = [], showTeam, showEditButton }) {
const { formatMessage, labels } = useMessages();
const { user } = useUser();
const showTable = data.length !== 0;
return (
<DataTable onChange={onChange}>
{showTable && (
<GridTable data={data}>
<GridColumn name="name" label={formatMessage(labels.name)} />
<GridColumn name="domain" label={formatMessage(labels.domain)} />
{showTeam && (
<GridColumn name="teamName" label={formatMessage(labels.teamName)}>
{row => row.teamWebsite[0]?.team.name}
</GridColumn>
)}
{showTeam && (
<GridColumn name="owner" label={formatMessage(labels.owner)}>
{row => row.user.username}
</GridColumn>
)}
<GridColumn name="action" label=" " className={DataTableStyles.action}>
{row => {
const {
id,
user: { id: ownerId },
} = row;
return (
<>
{showEditButton && (!showTeam || ownerId === user.id) && (
<Link href={`/settings/websites/${id}`}>
<Button>
<Icon>
<Icons.Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</Button>
</Link>
)}
<Link href={`/websites/${id}`} target={openExternal ? '_blank' : null}>
<Button>
<Icon>
<Icons.External />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
</Button>
</Link>
</>
);
}}
</GridColumn>
</GridTable>
<GridTable data={data}>
<GridColumn name="name" label={formatMessage(labels.name)} />
<GridColumn name="domain" label={formatMessage(labels.domain)} />
{showTeam && (
<GridColumn name="teamName" label={formatMessage(labels.teamName)}>
{row => row.teamWebsite[0]?.team.name}
</GridColumn>
)}
</DataTable>
);
}
{showTeam && (
<GridColumn name="owner" label={formatMessage(labels.owner)}>
{row => row.user.username}
</GridColumn>
)}
<GridColumn name="action" label=" " alignment="end">
{row => {
const {
id,
user: { id: ownerId },
} = row;
export function WebsitesTable2({
data = [],
filterValue,
onFilterChange,
onPageChange,
onPageSizeChange,
showTeam,
showEditButton,
openExternal = false,
}) {
const { formatMessage, labels } = useMessages();
const { user } = useUser();
const showTable = data && (filterValue || data?.data?.length !== 0);
const teamColumns = [
{ name: 'teamName', label: formatMessage(labels.teamName) },
{ name: 'owner', label: formatMessage(labels.owner) },
];
const columns = [
{ name: 'name', label: formatMessage(labels.name) },
{ name: 'domain', label: formatMessage(labels.domain) },
...(showTeam ? teamColumns : []),
{ name: 'action', label: ' ' },
];
return (
<>
{showTable && (
<SettingsTable
columns={columns}
data={data}
showSearch={true}
showPaging={true}
onFilterChange={onFilterChange}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
filterValue={filterValue}
>
{row => {
const {
id,
teamWebsite,
user: { username, id: ownerId },
} = row;
if (showTeam) {
row.teamName = teamWebsite[0]?.team.name;
row.owner = username;
}
return (
<>
{showEditButton && (!showTeam || ownerId === user.id) && (
<Link href={`/settings/websites/${id}`}>
<Button>
<Icon>
<Icons.Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</Button>
</Link>
)}
<Link href={`/websites/${id}`} target={openExternal ? '_blank' : null}>
return (
<Flexbox gap={10}>
{showEditButton && (!showTeam || ownerId === user.id) && (
<Link href={`/settings/websites/${id}`}>
<Button>
<Icon>
<Icons.External />
<Icons.Edit />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
<Text>{formatMessage(labels.edit)}</Text>
</Button>
</Link>
</>
);
}}
</SettingsTable>
)}
{!showTable && <Empty />}
</>
)}
<Link href={`/websites/${id}`}>
<Button>
<Icon>
<Icons.External />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
</Button>
</Link>
</Flexbox>
);
}}
</GridColumn>
</GridTable>
);
}