Fix paging for too large page numbers (#2097)

On some pages with pagination, the user is led to believe that no data is available if a page with page number which it too high is accessed. However, since we show the page number to the outside and the user can access it through the URL, we must also provide appropriate handling. The underlying data can change and so can the number of pages. Now, if a bookmark was saved from an older version, the link should still lead to a destination.
This commit is contained in:
Florian Scholdei
2022-08-02 10:30:07 +02:00
committed by GitHub
parent 27dbcbf28d
commit 6c82142643
12 changed files with 189 additions and 154 deletions

View File

@@ -0,0 +1,2 @@
- type: fixed
description: Fix paging for too large page numbers ([#2097](https://github.com/scm-manager/scm-manager/pull/2097))

View File

@@ -4996,7 +4996,7 @@ exports[`Storyshots MarkdownView Custom code renderer 1`] = `
}
}
>
To render plantuml as images within markdown, please install the scm-markdown-plantuml-plguin
To render plantuml as images within markdown, please install the scm-markdown-plantuml-plugin
</h4>
<pre>
actor Foo1

View File

@@ -101,7 +101,7 @@ storiesOf("MarkdownView", module)
return (
<div>
<h4 style={{ border: "1px dashed lightgray", padding: "2px" }}>
To render plantuml as images within markdown, please install the scm-markdown-plantuml-plguin
To render plantuml as images within markdown, please install the scm-markdown-plantuml-plugin
</h4>
<pre>{value}</pre>
</div>

View File

@@ -22,8 +22,9 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { useParams } from "react-router-dom";
import { Redirect, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { RepositoryRoleCollection } from "@scm-manager/ui-types";
import {
CreateButton,
ErrorNotification,
@@ -32,11 +33,33 @@ import {
Notification,
Subtitle,
Title,
urls
urls,
} from "@scm-manager/ui-components";
import PermissionRoleTable from "../components/PermissionRoleTable";
import { useRepositoryRoles } from "@scm-manager/ui-api";
type RepositoryRolesPageProps = {
data?: RepositoryRoleCollection;
page: number;
baseUrl: string;
};
const RepositoryRolesPage: FC<RepositoryRolesPageProps> = ({ data, page, baseUrl }) => {
const [t] = useTranslation("users");
const roles = data?._embedded?.repositoryRoles;
if (!data || !roles || roles.length === 0) {
return <Notification type="info">{t("repositoryRole.overview.noPermissionRoles")}</Notification>;
}
return (
<>
<PermissionRoleTable baseUrl={baseUrl} roles={roles} />
<LinkPaginator collection={data} page={page} />
</>
);
};
type Props = {
baseUrl: string;
};
@@ -44,29 +67,9 @@ type Props = {
const RepositoryRoles: FC<Props> = ({ baseUrl }) => {
const params = useParams();
const page = urls.getPageFromMatch({ params });
const { isLoading: loading, error, data: list } = useRepositoryRoles({ page: page - 1 });
const { isLoading: loading, error, data } = useRepositoryRoles({ page: page - 1 });
const [t] = useTranslation("admin");
const roles = list?._embedded.repositoryRoles;
const canAddRoles = !!list?._links.create;
const renderPermissionsTable = () => {
if (list && roles && roles.length > 0) {
return (
<>
<PermissionRoleTable baseUrl={baseUrl} roles={roles} />
<LinkPaginator collection={list} page={page} />
</>
);
}
return <Notification type="info">{t("repositoryRole.overview.noPermissionRoles")}</Notification>;
};
const renderCreateButton = () => {
if (canAddRoles) {
return <CreateButton label={t("repositoryRole.overview.createButton")} link={`${baseUrl}/create`} />;
}
return null;
};
const canAddRoles = !!data?._links.create;
if (error) {
return <ErrorNotification error={error} />;
@@ -76,12 +79,18 @@ const RepositoryRoles: FC<Props> = ({ baseUrl }) => {
return <Loading />;
}
if (data && data.pageTotal < page && page > 1) {
return <Redirect to={`${baseUrl}/${data.pageTotal}`} />;
}
return (
<>
<Title title={t("repositoryRole.title")} />
<Subtitle subtitle={t("repositoryRole.overview.title")} />
{renderPermissionsTable()}
{renderCreateButton()}
<RepositoryRolesPage data={data} page={page} baseUrl={baseUrl} />
{canAddRoles ? (
<CreateButton label={t("repositoryRole.overview.createButton")} link={`${baseUrl}/create`} />
) : null}
</>
);
};

View File

@@ -21,34 +21,33 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import GroupRow from "./GroupRow";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Group } from "@scm-manager/ui-types";
import GroupRow from "./GroupRow";
type Props = WithTranslation & {
type Props = {
groups: Group[];
};
class GroupTable extends React.Component<Props> {
render() {
const { groups, t } = this.props;
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("group.name")}</th>
<th className="is-hidden-mobile">{t("group.description")}</th>
</tr>
</thead>
<tbody>
{groups.map((group, index) => {
return <GroupRow key={index} group={group} />;
})}
</tbody>
</table>
);
}
}
const GroupTable: FC<Props> = ({ groups }) => {
const [t] = useTranslation("groups");
export default withTranslation("groups")(GroupTable);
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("group.name")}</th>
<th className="is-hidden-mobile">{t("group.description")}</th>
</tr>
</thead>
<tbody>
{groups.map((group, index) => {
return <GroupRow key={index} group={group} />;
})}
</tbody>
</table>
);
};
export default GroupTable;

View File

@@ -22,8 +22,10 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { Redirect, useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useLocation, useParams } from "react-router-dom";
import { useGroups } from "@scm-manager/ui-api";
import { Group, GroupCollection } from "@scm-manager/ui-types";
import {
CreateButton,
LinkPaginator,
@@ -31,32 +33,44 @@ import {
OverviewPageActions,
Page,
PageActions,
urls
urls,
} from "@scm-manager/ui-components";
import { GroupTable } from "./../components/table";
import { useGroups } from "@scm-manager/ui-api";
type GroupPageProps = {
data?: GroupCollection;
groups?: Group[];
page: number;
search?: string;
};
const GroupPage: FC<GroupPageProps> = ({ data, groups, page, search }) => {
const [t] = useTranslation("groups");
if (!data || !groups || groups.length === 0) {
return <Notification type="info">{t("groups.noGroups")}</Notification>;
}
return (
<>
<GroupTable groups={groups} />
<LinkPaginator collection={data} page={page} filter={search} />
</>
);
};
const Groups: FC = () => {
const location = useLocation();
const params = useParams();
const search = urls.getQueryStringFromLocation(location);
const page = urls.getPageFromMatch({ params });
const { isLoading, error, data: list } = useGroups({ search, page: page - 1 });
const { isLoading, error, data } = useGroups({ search, page: page - 1 });
const [t] = useTranslation("groups");
const groups = list?._embedded.groups;
const canCreateGroups = !!list?._links.create;
const renderGroupTable = () => {
if (list && groups && groups.length > 0) {
return (
<>
<GroupTable groups={groups} />
<LinkPaginator collection={list} page={page} filter={urls.getQueryStringFromLocation(location)} />
</>
);
}
return <Notification type="info">{t("groups.noGroups")}</Notification>;
};
const groups = data?._embedded?.groups;
const canCreateGroups = !!data?._links.create;
if (data && data.pageTotal < page && page > 1) {
return <Redirect to={`/groups/${data.pageTotal}`} />;
}
return (
<Page
@@ -65,8 +79,8 @@ const Groups: FC = () => {
loading={isLoading || !groups}
error={error || undefined}
>
{renderGroupTable()}
{canCreateGroups ? <CreateButton label={t("groups.createButton")} link="/groups/create" /> : null}
<GroupPage data={data} groups={groups} page={page} search={search} />
{canCreateGroups ? <CreateButton link="/groups/create" label={t("groups.createButton")} /> : null}
<PageActions>
<OverviewPageActions
showCreateButton={canCreateGroups}

View File

@@ -22,46 +22,34 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { useRouteMatch } from "react-router-dom";
import { Redirect, useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Branch, ChangesetCollection, Repository } from "@scm-manager/ui-types";
import { useChangesets } from "@scm-manager/ui-api";
import { Branch, Repository } from "@scm-manager/ui-types";
import {
ChangesetList,
ErrorNotification,
LinkPaginator,
Loading,
Notification,
urls
urls,
} from "@scm-manager/ui-components";
import { useChangesets } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
branch?: Branch;
};
type ChangesetProps = Props & {
error: Error | null;
isLoading: boolean;
data?: ChangesetCollection;
};
export const usePage = () => {
const match = useRouteMatch();
return urls.getPageFromMatch(match);
};
const Changesets: FC<Props> = ({ repository, branch }) => {
const page = usePage();
const { isLoading, error, data } = useChangesets(repository, { branch, page: page - 1 });
return <ChangesetsPanel repository={repository} branch={branch} error={error} isLoading={isLoading} data={data} />;
type Props = {
repository: Repository;
branch?: Branch;
url: string;
};
export const ChangesetsPanel: FC<ChangesetProps> = ({ repository, error, isLoading, data }) => {
const [t] = useTranslation("repos");
const Changesets: FC<Props> = ({ repository, branch, url }) => {
const page = usePage();
const { isLoading, error, data } = useChangesets(repository, { branch, page: page - 1 });
const [t] = useTranslation("repos");
const changesets = data?._embedded?.changesets;
if (error) {
@@ -72,7 +60,11 @@ export const ChangesetsPanel: FC<ChangesetProps> = ({ repository, error, isLoadi
return <Loading />;
}
if (!changesets || changesets.length === 0) {
if (data && data.pageTotal < page && page > 1) {
return <Redirect to={`${urls.unescapeUrlForRoute(url)}/${data.pageTotal}`} />;
}
if (!data || !changesets || changesets.length === 0) {
return <Notification type="info">{t("changesets.noChangesets")}</Notification>;
}
@@ -82,7 +74,7 @@ export const ChangesetsPanel: FC<ChangesetProps> = ({ repository, error, isLoadi
<ChangesetList repository={repository} changesets={changesets} />
</div>
<div className="panel-footer">
<LinkPaginator page={page} collection={data} />
<LinkPaginator collection={data} page={page} />
</div>
</div>
);

View File

@@ -59,8 +59,7 @@ const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranc
const onSelectBranch = (branch?: Branch) => {
if (branch) {
const url = `${baseUrl}/branch/${encodeURIComponent(branch.name)}/changesets/`;
history.push(url);
history.push(`${baseUrl}/branch/${encodeURIComponent(branch.name)}/changesets/`);
} else {
history.push(`${baseUrl}/changesets/`);
}
@@ -75,7 +74,7 @@ const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranc
switchViewLink={evaluateSwitchViewLink()}
/>
<Route path={`${url}/:page?`}>
<Changesets repository={repository} branch={branches?.filter(b => b.name === selectedBranch)[0]} />
<Changesets repository={repository} branch={branches?.filter(b => b.name === selectedBranch)[0]} url={url} />
</Route>
</>
);

View File

@@ -34,7 +34,7 @@ import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
type Props = {
type: string;
hits: HitType[];
hits?: HitType[];
};
const hitComponents: { [name: string]: FC<HitProps> } = {
@@ -69,12 +69,12 @@ const HitComponent: FC<HitComponentProps> = ({ hit, type }) => (
const NoHits: FC = () => {
const [t] = useTranslation("commons");
return <Notification>{t("search.noHits")}</Notification>;
return <Notification type="info">{t("search.noHits")}</Notification>;
};
const Hits: FC<Props> = ({ type, hits }) => (
<div className="panel-block">
{hits.length > 0 ? (
{hits && hits.length > 0 ? (
hits.map((hit, c) => <HitComponent key={`${type}_${c}_${hit.score}`} hit={hit} type={type} />)
) : (
<NoHits />

View File

@@ -26,6 +26,7 @@ import React, { FC } from "react";
import { QueryResult } from "@scm-manager/ui-types";
import Hits from "./Hits";
import { LinkPaginator } from "@scm-manager/ui-components";
import { Redirect, useLocation } from "react-router-dom";
type Props = {
result: QueryResult;
@@ -35,9 +36,21 @@ type Props = {
};
const Results: FC<Props> = ({ result, type, page, query }) => {
const location = useLocation();
const hits = result?._embedded?.hits;
let pathname = location.pathname;
if (!pathname.endsWith("/")) {
pathname = pathname.substring(0, pathname.lastIndexOf("/") + 1);
}
if (result && result.pageTotal < page && page > 1) {
return <Redirect to={`${pathname}${result.pageTotal}${location.search}`} />;
}
return (
<div className="panel">
<Hits type={type} hits={result._embedded.hits} />
<Hits type={type} hits={hits} />
<div className="panel-footer">
<LinkPaginator collection={result} page={page} filter={query} />
</div>

View File

@@ -21,35 +21,34 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types";
import UserRow from "./UserRow";
type Props = WithTranslation & {
type Props = {
users: User[];
};
class UserTable extends React.Component<Props> {
render() {
const { users, t } = this.props;
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("user.name")}</th>
<th className="is-hidden-mobile">{t("user.displayName")}</th>
<th className="is-hidden-mobile">{t("user.mail")}</th>
</tr>
</thead>
<tbody>
{users.map((user, index) => {
return <UserRow key={index} user={user} />;
})}
</tbody>
</table>
);
}
}
const UserTable: FC<Props> = ({ users }) => {
const [t] = useTranslation("users");
export default withTranslation("users")(UserTable);
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("user.name")}</th>
<th className="is-hidden-mobile">{t("user.displayName")}</th>
<th className="is-hidden-mobile">{t("user.mail")}</th>
</tr>
</thead>
<tbody>
{users.map((user, index) => {
return <UserRow key={index} user={user} />;
})}
</tbody>
</table>
);
};
export default UserTable;

View File

@@ -22,8 +22,10 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { Redirect, useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useLocation, useParams } from "react-router-dom";
import { useUsers } from "@scm-manager/ui-api";
import { User, UserCollection } from "@scm-manager/ui-types";
import {
CreateButton,
LinkPaginator,
@@ -31,10 +33,31 @@ import {
OverviewPageActions,
Page,
PageActions,
urls
urls,
} from "@scm-manager/ui-components";
import { UserTable } from "./../components/table";
import { useUsers } from "@scm-manager/ui-api";
type UserPageProps = {
data?: UserCollection;
users?: User[];
page: number;
search?: string;
};
const UserPage: FC<UserPageProps> = ({ data, users, page, search }) => {
const [t] = useTranslation("users");
if (!data || !users || users.length === 0) {
return <Notification type="info">{t("users.noUsers")}</Notification>;
}
return (
<>
<UserTable users={users} />
<LinkPaginator collection={data} page={page} filter={search} />
</>
);
};
const Users: FC = () => {
const location = useLocation();
@@ -42,28 +65,13 @@ const Users: FC = () => {
const search = urls.getQueryStringFromLocation(location);
const page = urls.getPageFromMatch({ params });
const { isLoading, error, data } = useUsers({ page: page - 1, search });
const users = data?._embedded.users;
const [t] = useTranslation("users");
const users = data?._embedded?.users;
const canAddUsers = !!data?._links.create;
const renderUserTable = () => {
if (data && users && users.length > 0) {
return (
<>
<UserTable users={users} />
<LinkPaginator collection={data} page={page} filter={urls.getQueryStringFromLocation(location)} />
</>
);
}
return <Notification type="info">{t("users.noUsers")}</Notification>;
};
const renderCreateButton = () => {
if (canAddUsers) {
return <CreateButton link="/users/create" label={t("users.createButton")} />;
}
return null;
};
if (data && data.pageTotal < page && page > 1) {
return <Redirect to={`/users/${data.pageTotal}`} />;
}
return (
<Page
@@ -72,8 +80,8 @@ const Users: FC = () => {
loading={isLoading || !users}
error={error || undefined}
>
{renderUserTable()}
{renderCreateButton()}
<UserPage data={data} users={users} page={page} search={search} />
{canAddUsers ? <CreateButton link="/users/create" label={t("users.createButton")} /> : null}
<PageActions>
<OverviewPageActions
showCreateButton={canAddUsers}