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> </h4>
<pre> <pre>
actor Foo1 actor Foo1

View File

@@ -101,7 +101,7 @@ storiesOf("MarkdownView", module)
return ( return (
<div> <div>
<h4 style={{ border: "1px dashed lightgray", padding: "2px" }}> <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> </h4>
<pre>{value}</pre> <pre>{value}</pre>
</div> </div>

View File

@@ -22,8 +22,9 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { useParams } from "react-router-dom"; import { Redirect, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { RepositoryRoleCollection } from "@scm-manager/ui-types";
import { import {
CreateButton, CreateButton,
ErrorNotification, ErrorNotification,
@@ -32,11 +33,33 @@ import {
Notification, Notification,
Subtitle, Subtitle,
Title, Title,
urls urls,
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import PermissionRoleTable from "../components/PermissionRoleTable"; import PermissionRoleTable from "../components/PermissionRoleTable";
import { useRepositoryRoles } from "@scm-manager/ui-api"; 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 = { type Props = {
baseUrl: string; baseUrl: string;
}; };
@@ -44,29 +67,9 @@ type Props = {
const RepositoryRoles: FC<Props> = ({ baseUrl }) => { const RepositoryRoles: FC<Props> = ({ baseUrl }) => {
const params = useParams(); const params = useParams();
const page = urls.getPageFromMatch({ params }); 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 [t] = useTranslation("admin");
const roles = list?._embedded.repositoryRoles; const canAddRoles = !!data?._links.create;
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;
};
if (error) { if (error) {
return <ErrorNotification error={error} />; return <ErrorNotification error={error} />;
@@ -76,12 +79,18 @@ const RepositoryRoles: FC<Props> = ({ baseUrl }) => {
return <Loading />; return <Loading />;
} }
if (data && data.pageTotal < page && page > 1) {
return <Redirect to={`${baseUrl}/${data.pageTotal}`} />;
}
return ( return (
<> <>
<Title title={t("repositoryRole.title")} /> <Title title={t("repositoryRole.title")} />
<Subtitle subtitle={t("repositoryRole.overview.title")} /> <Subtitle subtitle={t("repositoryRole.overview.title")} />
{renderPermissionsTable()} <RepositoryRolesPage data={data} page={page} baseUrl={baseUrl} />
{renderCreateButton()} {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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import GroupRow from "./GroupRow";
import { Group } from "@scm-manager/ui-types"; import { Group } from "@scm-manager/ui-types";
import GroupRow from "./GroupRow";
type Props = WithTranslation & { type Props = {
groups: Group[]; groups: Group[];
}; };
class GroupTable extends React.Component<Props> { const GroupTable: FC<Props> = ({ groups }) => {
render() { const [t] = useTranslation("groups");
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>
);
}
}
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. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { Redirect, useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; 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 { import {
CreateButton, CreateButton,
LinkPaginator, LinkPaginator,
@@ -31,32 +33,44 @@ import {
OverviewPageActions, OverviewPageActions,
Page, Page,
PageActions, PageActions,
urls urls,
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { GroupTable } from "./../components/table"; 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 Groups: FC = () => {
const location = useLocation(); const location = useLocation();
const params = useParams(); const params = useParams();
const search = urls.getQueryStringFromLocation(location); const search = urls.getQueryStringFromLocation(location);
const page = urls.getPageFromMatch({ params }); 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 [t] = useTranslation("groups");
const groups = list?._embedded.groups; const groups = data?._embedded?.groups;
const canCreateGroups = !!list?._links.create; const canCreateGroups = !!data?._links.create;
if (data && data.pageTotal < page && page > 1) {
const renderGroupTable = () => { return <Redirect to={`/groups/${data.pageTotal}`} />;
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>;
};
return ( return (
<Page <Page
@@ -65,8 +79,8 @@ const Groups: FC = () => {
loading={isLoading || !groups} loading={isLoading || !groups}
error={error || undefined} error={error || undefined}
> >
{renderGroupTable()} <GroupPage data={data} groups={groups} page={page} search={search} />
{canCreateGroups ? <CreateButton label={t("groups.createButton")} link="/groups/create" /> : null} {canCreateGroups ? <CreateButton link="/groups/create" label={t("groups.createButton")} /> : null}
<PageActions> <PageActions>
<OverviewPageActions <OverviewPageActions
showCreateButton={canCreateGroups} showCreateButton={canCreateGroups}

View File

@@ -22,46 +22,34 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { useRouteMatch } from "react-router-dom"; import { Redirect, useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next"; 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 { import {
ChangesetList, ChangesetList,
ErrorNotification, ErrorNotification,
LinkPaginator, LinkPaginator,
Loading, Loading,
Notification, Notification,
urls urls,
} from "@scm-manager/ui-components"; } 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 = () => { export const usePage = () => {
const match = useRouteMatch(); const match = useRouteMatch();
return urls.getPageFromMatch(match); return urls.getPageFromMatch(match);
}; };
const Changesets: FC<Props> = ({ repository, branch }) => { type Props = {
const page = usePage(); repository: Repository;
branch?: Branch;
const { isLoading, error, data } = useChangesets(repository, { branch, page: page - 1 }); url: string;
return <ChangesetsPanel repository={repository} branch={branch} error={error} isLoading={isLoading} data={data} />;
}; };
export const ChangesetsPanel: FC<ChangesetProps> = ({ repository, error, isLoading, data }) => { const Changesets: FC<Props> = ({ repository, branch, url }) => {
const [t] = useTranslation("repos");
const page = usePage(); const page = usePage();
const { isLoading, error, data } = useChangesets(repository, { branch, page: page - 1 });
const [t] = useTranslation("repos");
const changesets = data?._embedded?.changesets; const changesets = data?._embedded?.changesets;
if (error) { if (error) {
@@ -72,7 +60,11 @@ export const ChangesetsPanel: FC<ChangesetProps> = ({ repository, error, isLoadi
return <Loading />; 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>; 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} /> <ChangesetList repository={repository} changesets={changesets} />
</div> </div>
<div className="panel-footer"> <div className="panel-footer">
<LinkPaginator page={page} collection={data} /> <LinkPaginator collection={data} page={page} />
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ import React, { FC } from "react";
import { QueryResult } from "@scm-manager/ui-types"; import { QueryResult } from "@scm-manager/ui-types";
import Hits from "./Hits"; import Hits from "./Hits";
import { LinkPaginator } from "@scm-manager/ui-components"; import { LinkPaginator } from "@scm-manager/ui-components";
import { Redirect, useLocation } from "react-router-dom";
type Props = { type Props = {
result: QueryResult; result: QueryResult;
@@ -35,9 +36,21 @@ type Props = {
}; };
const Results: FC<Props> = ({ result, type, page, query }) => { 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 ( return (
<div className="panel"> <div className="panel">
<Hits type={type} hits={result._embedded.hits} /> <Hits type={type} hits={hits} />
<div className="panel-footer"> <div className="panel-footer">
<LinkPaginator collection={result} page={page} filter={query} /> <LinkPaginator collection={result} page={page} filter={query} />
</div> </div>

View File

@@ -21,35 +21,34 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types"; import { User } from "@scm-manager/ui-types";
import UserRow from "./UserRow"; import UserRow from "./UserRow";
type Props = WithTranslation & { type Props = {
users: User[]; users: User[];
}; };
class UserTable extends React.Component<Props> { const UserTable: FC<Props> = ({ users }) => {
render() { const [t] = useTranslation("users");
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>
);
}
}
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. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { Redirect, useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; 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 { import {
CreateButton, CreateButton,
LinkPaginator, LinkPaginator,
@@ -31,10 +33,31 @@ import {
OverviewPageActions, OverviewPageActions,
Page, Page,
PageActions, PageActions,
urls urls,
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { UserTable } from "./../components/table"; 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 Users: FC = () => {
const location = useLocation(); const location = useLocation();
@@ -42,28 +65,13 @@ const Users: FC = () => {
const search = urls.getQueryStringFromLocation(location); const search = urls.getQueryStringFromLocation(location);
const page = urls.getPageFromMatch({ params }); const page = urls.getPageFromMatch({ params });
const { isLoading, error, data } = useUsers({ page: page - 1, search }); const { isLoading, error, data } = useUsers({ page: page - 1, search });
const users = data?._embedded.users;
const [t] = useTranslation("users"); const [t] = useTranslation("users");
const users = data?._embedded?.users;
const canAddUsers = !!data?._links.create; const canAddUsers = !!data?._links.create;
const renderUserTable = () => { if (data && data.pageTotal < page && page > 1) {
if (data && users && users.length > 0) { return <Redirect to={`/users/${data.pageTotal}`} />;
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;
};
return ( return (
<Page <Page
@@ -72,8 +80,8 @@ const Users: FC = () => {
loading={isLoading || !users} loading={isLoading || !users}
error={error || undefined} error={error || undefined}
> >
{renderUserTable()} <UserPage data={data} users={users} page={page} search={search} />
{renderCreateButton()} {canAddUsers ? <CreateButton link="/users/create" label={t("users.createButton")} /> : null}
<PageActions> <PageActions>
<OverviewPageActions <OverviewPageActions
showCreateButton={canAddUsers} showCreateButton={canAddUsers}