Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/components/AggregateGrid/AggregateGridPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useGetDataForAggregateGrid } from "../../connection/LibraryQueryHooks";
import { IMinimalBookInfo, ILangTagData } from "./AggregateGridInterfaces";
import { observer } from "mobx-react-lite";
import { useGetLoggedInUser, User } from "../../connection/LoggedInUser";
import { isLocalhost } from "../../connection/DataSource";
import {
Plugin,
Template,
Expand Down Expand Up @@ -75,7 +76,9 @@ export const AggregateGridPage: React.FunctionComponent<{
}
: loadingResult;
const user = useGetLoggedInUser();
if (!user) {
// On a local dev machine (loopback hostnames) we don't require login, so the grids can be
// worked on without signing in. Everywhere else remains login-only. See isLocalhost.
if (!user && !isLocalhost()) {
return <div>You must log in to see this page.</div>;
}
return (
Expand Down
14 changes: 13 additions & 1 deletion src/components/CountryGrid/CountryGridColumns.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { IGridColumn } from "../Grid/GridColumns";
import { applyUrlKeys } from "../Grid/gridUrlConfig";
import { TableFilterRow } from "@devexpress/dx-react-grid-material-ui";
import { Filter, Sorting } from "@devexpress/dx-react-grid";
import {
Expand All @@ -17,6 +18,17 @@ export interface ICountryGridRowData {
bookCount: number;
}

// Short, stable URL keys for every country-grid column (filters + sort/cols/widths).
// Must be unique within this grid and not equal a reserved param (sort/cols/widths).
const countryGridUrlKeys: { [name: string]: string } = {
name: "nm",
code: "cd",
knownLanguageCount: "klc",
blorgLanguageCount: "blc",
blorgLanguageTags: "blt",
bookCount: "bc",
};

// Define the function getCountryGridColumnsDefinitions
export function getCountryGridColumnsDefinitions(): IGridColumn[] {
const definitions: IGridColumn[] = [
Expand Down Expand Up @@ -131,7 +143,7 @@ export function getCountryGridColumnsDefinitions(): IGridColumn[] {
},
},
];
return definitions;
return applyUrlKeys(definitions, countryGridUrlKeys);
}

export function compareCountryGridRows(
Expand Down
120 changes: 36 additions & 84 deletions src/components/CountryGrid/CountryGridControlInternal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ import {
SortingState,
PagingState,
CustomPaging,
Filter as GridFilter,
Sorting,
} from "@devexpress/dx-react-grid";
import { TableCell, useTheme } from "@material-ui/core";
import {
Expand All @@ -36,8 +34,8 @@ import {
getCountryGridColumnsDefinitions,
adjustListDisplaysForFiltering,
} from "./CountryGridColumns";
import { IGridColumn } from "../Grid/GridColumns";
import { useStorageState } from "react-storage-hooks";
import { IGridColumn, getColumnsVisibleToUser } from "../Grid/GridColumns";
import { useGridConfigInUrl } from "../Grid/useGridConfigInUrl";
import { useGetLoggedInUser } from "../../connection/LoggedInUser";
import { observer } from "mobx-react-lite";
import { ICountryGridControlProps } from "./CountryGridControl";
Expand Down Expand Up @@ -67,10 +65,36 @@ const CountryGridControlInternal: React.FunctionComponent<ICountryGridControlPro
CachedTablesContext
);
const user = useGetLoggedInUser();
const [gridFilters, setGridFilters] = useState<GridFilter[]>([]);
const [countryGridColumnDefinitions] = useState(
getCountryGridColumnsDefinitions()
);
// The columns this user may see (some are moderator-/login-gated). Drives both the
// rendered `columns` set and which URL sort/filter config the hook is allowed to honor.
const visibleColumnDefinitions = useMemo(
() => getColumnsVisibleToUser(countryGridColumnDefinitions, user),
[countryGridColumnDefinitions, user]
);
const availableColumnNames = useMemo(
() => visibleColumnDefinitions.map((c) => c.name),
[visibleColumnDefinitions]
);
// Grid configuration (sort, column filters, column order/visibility, widths) lives
// in the URL so a view can be bookmarked/shared; column order & visibility also fall
// back to the user's personal localStorage preference. See useGridConfigInUrl.
const {
sortings,
setSortings,
gridFilters,
setGridFilters,
columnNamesInDisplayOrder,
setColumnNamesInDisplayOrder,
hiddenColumnNames,
setHiddenColumnNames,
columnWidths,
setColumnWidths,
} = useGridConfigInUrl(countryGridColumnDefinitions, "country-grid", {
availableColumnNames,
});

const { minimalBookInfo: bookData } = useContext(CachedBookDataContext);
const [totalRowCount, setTotalRowCount] = useState(0);
Expand Down Expand Up @@ -250,47 +274,8 @@ const CountryGridControlInternal: React.FunctionComponent<ICountryGridControlPro
ICountryGridRowData[]
>([]);
const [gridPage, setGridPage] = useState(0);
const [columns, setColumns] = useState<ReadonlyArray<IGridColumn>>([]);
const [sortings, setSortings] = useState<ReadonlyArray<Sorting>>([]);
const [
columnNamesInDisplayOrder,
setColumnNamesInDisplayOrder,
] = useStorageState<string[]>(
localStorage,
"country-grid-column-order",
countryGridColumnDefinitions.map((c) => c.name)
);

// when a new version adds a new column, the list of columns in order will not match
// the full list of columns. Instead of coping with this, the devexpress grid just locks down the new
// column as the first one. So here we detect added and removed columns, while preserving order.
useEffect(() => {
const newCompleteSetInDefaultOrder = countryGridColumnDefinitions.map(
(c) => c.name
);
const columnsThatNeedToBeAdded = newCompleteSetInDefaultOrder.filter(
(x) => !columnNamesInDisplayOrder.includes(x)
);
const columnsThatNeedToBeRemoved = columnNamesInDisplayOrder.filter(
(x) => !newCompleteSetInDefaultOrder.includes(x)
);
if (
columnsThatNeedToBeAdded.length ||
columnsThatNeedToBeRemoved.length
) {
const oldOrderWithNewOnesAtEnd = columnNamesInDisplayOrder.concat(
columnsThatNeedToBeAdded
);
const columnsWithAnyOldOnesRemoved = oldOrderWithNewOnesAtEnd.filter(
(n) => !columnsThatNeedToBeRemoved.includes(n)
);
setColumnNamesInDisplayOrder(columnsWithAnyOldOnesRemoved);
}
}, [
columnNamesInDisplayOrder,
setColumnNamesInDisplayOrder,
countryGridColumnDefinitions,
]);
// The columns this user may see; drives the grid's rendered column set.
const columns = visibleColumnDefinitions;

// Apply filtering and sorting to the rows, then set the page of rows to display.
// Also set the total row count and the export data.
Expand Down Expand Up @@ -324,25 +309,6 @@ const CountryGridControlInternal: React.FunctionComponent<ICountryGridControlPro
setExportData,
]);

const [hiddenColumnNames, setHiddenColumnNames] = useStorageState<
string[]
>(
localStorage,
"country-grid-column-hidden",
countryGridColumnDefinitions
.filter((c) => !c.defaultVisible)
.map((c) => c.name)
);

const defaultColumnWidths = useMemo(
() =>
countryGridColumnDefinitions.map((c) => ({
columnName: c.name,
width: "auto",
})),
[countryGridColumnDefinitions]
);

if (props.setExportColumnInfo) {
props.setExportColumnInfo(
columnNamesInDisplayOrder.filter((cn) =>
Expand All @@ -355,21 +321,6 @@ const CountryGridControlInternal: React.FunctionComponent<ICountryGridControlPro
);
}

const thisIsAModerator = user?.moderator;
useEffect(() => {
setColumns(
countryGridColumnDefinitions.filter(
// some columns we include only if we are logged in, or
// logged in with the right permissions
(col) =>
thisIsAModerator ||
(!col.moderatorOnly && !col.loggedInOnly) ||
(!col.moderatorOnly && col.loggedInOnly && user)
)
);
// todo? useEffect used to depend on router, though doesn't obviously use it.
}, [user, thisIsAModerator, countryGridColumnDefinitions]);

// note: this is an embedded function as a way to get at countryGridColumnDefinitions. It's important
// that we don't reconstruct it on every render, or else we'll lose cursor focus on each key press.
// Alternatives to this useMemo would include a ContextProvider, a higher-order function, or just
Expand Down Expand Up @@ -398,14 +349,14 @@ const CountryGridControlInternal: React.FunctionComponent<ICountryGridControlPro
/>

<FilteringState
defaultFilters={gridFilters}
filters={gridFilters}
onFiltersChange={(x) => {
setGridFilters(x);
}}
/>

<SortingState
defaultSorting={[]}
sorting={sortings}
onSortingChange={(sorting) => {
setSortings(sorting);
}}
Expand All @@ -425,12 +376,13 @@ const CountryGridControlInternal: React.FunctionComponent<ICountryGridControlPro
/>
<TableColumnResizing
resizingMode={"nextColumn"}
defaultColumnWidths={defaultColumnWidths}
columnWidths={columnWidths}
onColumnWidthsChange={setColumnWidths}
/>
<TableHeaderRow showSortingControls />

<TableColumnVisibility
defaultHiddenColumnNames={hiddenColumnNames}
hiddenColumnNames={hiddenColumnNames}
onHiddenColumnNamesChange={(names) =>
setHiddenColumnNames(names)
}
Expand Down
75 changes: 69 additions & 6 deletions src/components/Grid/GridColumns.tsx
Comment thread
hatton marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ import titleCase from "title-case";
import { IFilter, BooleanOptions } from "../../IFilter";
import { CachedTables } from "../../model/CacheProvider";
import { BlorgLink } from "../BlorgLink";
import { User } from "../../connection/LoggedInUser";

export interface IGridColumn extends DevExpressColumn {
moderatorOnly?: boolean;
loggedInOnly?: boolean;
defaultVisible?: boolean;
// Short, collision-free key used for this column's filter in the URL query string.
// Defaults to `name`. Set it when the name is long or would collide with another query
// param (e.g. the "title" column vs. this app's existing "title" search param).
urlKey?: string;
// A column definition specifies this if it needs a custom filter control
getCustomFilterComponent?: FunctionComponent<TableFilterRow.CellProps>;
// Given a BloomLibrary filter, modify it to include the value the user has set while using this column's filter control.
Expand All @@ -32,6 +37,22 @@ export interface IGridColumn extends DevExpressColumn {
getStringValue?: (b: Book) => string;
}

// The columns a given user is allowed to see: some are gated behind being logged in
// (loggedInOnly) or being a moderator (moderatorOnly). Every grid uses this both to build the
// rendered `columns` set and (via useGridConfigInUrl) to ignore URL sort/filter config that
// names a column this user cannot see. Keep the two in agreement by going through this one helper.
export function getColumnsVisibleToUser(
columns: ReadonlyArray<IGridColumn>,
user: User | undefined
): IGridColumn[] {
return columns.filter(
(col) =>
!!user?.moderator ||
(!col.moderatorOnly && !col.loggedInOnly) ||
(!col.moderatorOnly && !!col.loggedInOnly && !!user)
);
}

// For some tags, we want to give them their own column. So we don't want to show them in the tags column.
const kTagsToFilterOutOfTagsList = [
"topic:",
Expand All @@ -40,6 +61,47 @@ const kTagsToFilterOutOfTagsList = [
"computedLevel", // added this one only because it gets in the way
];

// Short, stable URL keys for every book-grid column (used for filters and inside
// sort/cols/widths). Must be unique and must not equal a reserved param
// (sort/cols/widths). "ti" avoids colliding with the app's existing "title" search param.
const bookGridUrlKeys: { [name: string]: string } = {
title: "ti",
languages: "lg",
languagecodes: "lc",
tags: "tg",
features: "ft",
country: "co",
incoming: "in",
level: "lv",
leveledReaderLevel: "lrl",
topic: "tp",
harvestState: "hs",
harvestLog: "hl",
harvestStartedAt: "hsa",
summary: "sm",
notes: "nt",
inCirculation: "ic",
draft: "dr",
"Is Rebrand": "rb",
license: "li",
copyright: "cr",
brandingProjectName: "bp",
pageCount: "pc",
phashOfFirstContentImage: "ph",
bookHashFromImages: "bh",
createdAt: "ca",
updatedAt: "ua",
credits: "cd",
publisher: "pb",
originalPublisher: "op",
uploader: "up",
keywords: "kw",
bookInstanceId: "bi",
analytics_startedCount: "asc",
analytics_finishedCount: "afc",
analytics_shellDownloads: "asd",
};

export function getBookGridColumnsDefinitions(): IGridColumn[] {
const definitions: IGridColumn[] = [
{
Expand Down Expand Up @@ -406,6 +468,7 @@ export function getBookGridColumnsDefinitions(): IGridColumn[] {
if (c.title === undefined) {
x.title = titleCase(c.name);
}
x.urlKey = bookGridUrlKeys[c.name] ?? c.urlKey;
return x;
})
.sort((a, b) => {
Expand Down Expand Up @@ -476,7 +539,9 @@ const ChoicesFilterCell: React.FunctionComponent<
choices: string[];
}
> = (props) => {
const [value, setValue] = useState(props.filter?.value || "");
// Controlled by the current filter (which the URL can change via back/forward), so the shown
// selection always matches the grid's active filter — no private copy that can go stale.
const value = props.filter?.value || "";
return (
<TableCell
css={css`
Expand Down Expand Up @@ -509,7 +574,6 @@ const ChoicesFilterCell: React.FunctionComponent<
width: 100%;
`}
onChange={(e) => {
setValue(e.target.value as string);
props.onFilter({
columnName: props.column.name,
operation: "contains",
Expand All @@ -531,9 +595,9 @@ const ChoicesFilterCell: React.FunctionComponent<
const TagExistsFilterCell: React.FunctionComponent<TableFilterRow.CellProps> = (
props
) => {
const [checked, setChecked] = useState(
props.filter?.value === "true" || false
);
// Controlled by the current filter (URL/back-forward can change it), so the checkbox never
// shows a stale state that disagrees with the grid's active filter.
const checked = props.filter?.value === "true";
return (
<TableCell padding="checkbox">
<Checkbox
Expand All @@ -548,7 +612,6 @@ const TagExistsFilterCell: React.FunctionComponent<TableFilterRow.CellProps> = (
// we're switching to the opposite of what `checked` was
value: !checked ? "true" : "false",
});
setChecked(!checked);
}}
/>
</TableCell>
Expand Down
Loading