Improve repository overview

- Sort repositories alphanumerically case insensitive per namespace
- Make the namespaces collapsible and store the collapsed state in local storage

Committed-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2023-08-22 19:59:53 +02:00
parent 9c0d064491
commit 2efcbfa759
11 changed files with 109 additions and 80 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Make namespaces collapsible and save the collapsed state in local storage

View File

@@ -0,0 +1,2 @@
- type: changed
description: Sort repositories alphanumerically per namespace

View File

@@ -41,6 +41,7 @@ import javax.inject.Inject;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
@@ -48,6 +49,7 @@ import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* @author Sebastian Sdorra
@@ -146,7 +148,7 @@ public class XmlRepositoryDAO implements RepositoryDAO {
@Override
public Collection<Repository> getAll() {
return withReadLockedMaps(() -> ImmutableList.copyOf(byNamespaceAndName.values()));
return withReadLockedMaps(() -> ImmutableList.copyOf(byNamespaceAndName.values().stream().sorted(Comparator.comparing(v -> v.getNamespaceAndName().toString().toLowerCase())).collect(Collectors.toList())));
}
@Override

View File

@@ -65,6 +65,7 @@ export * from "./usePluginCenterAuthInfo";
export * from "./compare";
export * from "./utils";
export * from "./links";
export * from "./localStorage";
export { useNamespaceOptions, useGroupOptions, useUserOptions } from "./useAutocompleteOptions";
export { default as ApiProvider } from "./ApiProvider";

View File

@@ -24,7 +24,7 @@
import { useEffect, useState } from "react";
export default function useLocalStorage<T>(
export function useLocalStorage<T>(
key: string,
initialValue: T
): [value: T, setValue: (value: T | ((previousConfig: T) => T)) => void] {

View File

@@ -30,7 +30,7 @@ import {
Repository,
RepositoryCollection,
RepositoryCreation,
RepositoryTypeCollection
RepositoryTypeCollection,
} from "@scm-manager/ui-types";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
@@ -72,7 +72,7 @@ export const useRepositories = (request?: UseRepositoriesRequest): ApiResult<Rep
}
return useQuery<RepositoryCollection, Error>(
["repositories", request?.namespace?.namespace, request?.search || "", request?.page || 0],
() => apiClient.get(`${link}?${createQueryString(queryParams)}`).then(response => response.json()),
() => apiClient.get(`${link}?${createQueryString(queryParams)}`).then((response) => response.json()),
{
enabled: !request?.disabled,
onSuccess: (repositories: RepositoryCollection) => {
@@ -80,7 +80,7 @@ export const useRepositories = (request?: UseRepositoriesRequest): ApiResult<Rep
repositories._embedded?.repositories.forEach((repository: Repository) => {
queryClient.setQueryData(["repository", repository.namespace, repository.name], repository);
});
}
},
}
);
};
@@ -98,14 +98,14 @@ const createRepository = (link: string) => {
}
return apiClient
.post(createLink, request.repository, "application/vnd.scmm-repository+json;v=2")
.then(response => {
.then((response) => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
.then((response) => response.json());
};
};
@@ -117,10 +117,10 @@ export const useCreateRepository = () => {
const { mutate, data, isLoading, error } = useMutation<Repository, Error, CreateRepositoryRequest>(
createRepository(link),
{
onSuccess: repository => {
onSuccess: (repository) => {
queryClient.setQueryData(["repository", repository.namespace, repository.name], repository);
return queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
@@ -129,7 +129,7 @@ export const useCreateRepository = () => {
},
isLoading,
error,
repository: data
repository: data,
};
};
@@ -139,7 +139,7 @@ export const useRepositoryTypes = () => useIndexJsonResource<RepositoryTypeColle
export const useRepository = (namespace: string, name: string): ApiResult<Repository> => {
const link = useRequiredIndexLink("repositories");
return useQuery<Repository, Error>(["repository", namespace, name], () =>
apiClient.get(concat(link, namespace, name)).then(response => response.json())
apiClient.get(concat(link, namespace, name)).then((response) => response.json())
);
};
@@ -150,7 +150,7 @@ export type UseDeleteRepositoryOptions = {
export const useDeleteRepository = (options?: UseDeleteRepositoryOptions) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "delete");
return apiClient.delete(link);
},
@@ -161,21 +161,21 @@ export const useDeleteRepository = (options?: UseDeleteRepositoryOptions) => {
}
queryClient.removeQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
remove: (repository: Repository) => mutate(repository),
isLoading,
error,
isDeleted: !!data
isDeleted: !!data,
};
};
export const useUpdateRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "update");
return apiClient.put(link, repository, "application/vnd.scmm-repository+json;v=2");
},
@@ -183,21 +183,21 @@ export const useUpdateRepository = () => {
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
update: (repository: Repository) => mutate(repository),
isLoading,
error,
isUpdated: !!data
isUpdated: !!data,
};
};
export const useArchiveRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "archive");
return apiClient.post(link);
},
@@ -205,21 +205,21 @@ export const useArchiveRepository = () => {
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
archive: (repository: Repository) => mutate(repository),
isLoading,
error,
isArchived: !!data
isArchived: !!data,
};
};
export const useUnarchiveRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "unarchive");
return apiClient.post(link);
},
@@ -227,35 +227,35 @@ export const useUnarchiveRepository = () => {
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
unarchive: (repository: Repository) => mutate(repository),
isLoading,
error,
isUnarchived: !!data
isUnarchived: !!data,
};
};
export const useRunHealthCheck = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "runHealthCheck");
return apiClient.post(link);
},
{
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
}
},
}
);
return {
runHealthCheck: (repository: Repository) => mutate(repository),
isLoading,
error,
isRunning: !!data
isRunning: !!data,
};
};
@@ -264,7 +264,7 @@ export const useExportInfo = (repository: Repository): ApiResultWithFetching<Exp
//TODO Refetch while exporting to update the page
const { isLoading, isFetching, error, data } = useQuery<ExportInfo, Error>(
["repository", repository.namespace, repository.name, "exportInfo"],
() => apiClient.get(link).then(response => response.json()),
() => apiClient.get(link).then((response) => response.json()),
{}
);
@@ -272,7 +272,7 @@ export const useExportInfo = (repository: Repository): ApiResultWithFetching<Exp
isLoading,
isFetching,
error: error instanceof NotFoundError ? null : error,
data
data,
};
};
@@ -315,14 +315,14 @@ export const useExportRepository = () => {
const id = setInterval(() => {
apiClient
.get(infolink)
.then(r => r.json())
.then((r) => r.json())
.then((info: ExportInfo) => {
if (info._links.download) {
clearInterval(id);
resolve(info);
}
})
.catch(e => {
.catch((e) => {
clearInterval(id);
reject(e);
});
@@ -335,21 +335,21 @@ export const useExportRepository = () => {
onSuccess: async (_, { repository }) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
exportRepository: (repository: Repository, options: ExportOptions) => mutate({ repository, options }),
isLoading,
error,
data
data,
};
};
export const usePaths = (repository: Repository, revision: string): ApiResult<Paths> => {
const link = requiredLink(repository, "paths").replace("{revision}", revision);
return useQuery<Paths, Error>(repoQueryKey(repository, "paths", revision), () =>
apiClient.get(link).then(response => response.json())
apiClient.get(link).then((response) => response.json())
);
};
@@ -370,7 +370,7 @@ export const useRenameRepository = (repository: Repository) => {
const { mutate, isLoading, error, data } = useMutation<unknown, Error, RenameRepositoryRequest>(
({ name, namespace }) => apiClient.post(url, { namespace, name }, "application/vnd.scmm-repository+json;v=2"),
{
onSuccess: () => queryClient.removeQueries(repoQueryKey(repository))
onSuccess: () => queryClient.removeQueries(repoQueryKey(repository)),
}
);
@@ -378,7 +378,7 @@ export const useRenameRepository = (repository: Repository) => {
renameRepository: (namespace: string, name: string) => mutate({ namespace, name }),
isLoading,
error,
isRenamed: !!data
isRenamed: !!data,
};
};

View File

@@ -24,17 +24,25 @@
import React, { FC, ReactNode } from "react";
import classNames from "classnames";
import styled from "styled-components";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { RepositoryGroup } from "@scm-manager/ui-types";
import { useLocalStorage } from "@scm-manager/ui-api";
import Icon from "../Icon";
const Separator = styled.div`
border-bottom: 1px solid rgb(219, 219, 219, 0.5);
`;
type Props = {
namespaceHeader: ReactNode;
group: RepositoryGroup;
elements: ReactNode[];
};
const GroupEntries: FC<Props> = ({ namespaceHeader, elements }) => {
const GroupEntries: FC<Props> = ({ group, elements }) => {
const [t] = useTranslation("namespaces");
const [collapsed, setCollapsed] = useLocalStorage<boolean | null>(`repoNamespace.${group.name}.collapsed`, null);
const content = elements.map((entry, index) => (
<React.Fragment key={index}>
<div>{entry}</div>
@@ -42,12 +50,45 @@ const GroupEntries: FC<Props> = ({ namespaceHeader, elements }) => {
</React.Fragment>
));
const settingsLink = group.namespace?._links?.permissions && (
<Link to={`/namespace/${group.name}/settings`} aria-label={t("repositoryOverview.settings.tooltip")}>
<Icon
color="inherit"
name="cog"
title={t("repositoryOverview.settings.tooltip")}
className="is-size-6 ml-2 has-text-link"
/>
</Link>
);
return (
<>
<div className={classNames("is-flex", "is-align-items-center", "is-size-6", "has-text-weight-bold", "p-3")}>
{namespaceHeader}
<div
className={classNames(
"is-flex",
"is-align-items-center",
"is-justify-content-space-between",
"is-size-6",
"has-text-weight-bold",
"p-3",
"has-cursor-pointer"
)}
onClick={() => setCollapsed(!collapsed)}
>
<span>
<Link to={`/repos/${group.name}/`} className="has-text-inherit">
{group.name}
</Link>{" "}
{settingsLink}
</span>
<Icon
color="inherit"
name={collapsed ? "caret-left" : "caret-down"}
title={t("repositoryOverview.settings.tooltip")}
className="is-size-6 ml-2"
/>
</div>
<div className={classNames("box", "p-2")}>{content}</div>
{collapsed ? null : <div className={classNames("box", "p-2")}>{content}</div>}
<div className="is-clearfix" />
</>
);

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import useLocalStorage from "./useLocalStorage";
import { useLocalStorage } from "@scm-manager/ui-api";
import { useCallback, useState } from "react";
const LOCAL_STORAGE_KEY = "scm.accessibility";

View File

@@ -22,35 +22,18 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { Link } from "react-router-dom";
import { Icon, RepositoryEntry, GroupEntries } from "@scm-manager/ui-components";
import { GroupEntries, RepositoryEntry } from "@scm-manager/ui-components";
import { RepositoryGroup } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
type Props = {
group: RepositoryGroup;
};
const RepositoryGroupEntry: FC<Props> = ({ group }) => {
const [t] = useTranslation("namespaces");
const settingsLink = group.namespace?._links?.permissions && (
<Link to={`/namespace/${group.name}/settings`} aria-label={t("repositoryOverview.settings.tooltip")}>
<Icon color="inherit" name="cog" title={t("repositoryOverview.settings.tooltip")} className="is-size-6 ml-2" />
</Link>
);
const namespaceHeader = (
<>
<Link to={`/repos/${group.name}/`} className="has-text-inherit">
{group.name}
</Link>{" "}
{settingsLink}
</>
);
const entries = group.repositories.map((repository, index) => {
return <RepositoryEntry repository={repository} key={index} />;
});
return <GroupEntries namespaceHeader={namespaceHeader} elements={entries} />;
return <GroupEntries group={group} elements={entries} />;
};
export default RepositoryGroupEntry;

View File

@@ -26,75 +26,75 @@ import groupByNamespace from "./groupByNamespace";
const base = {
type: "git",
_links: {}
_links: {},
};
const slartiBlueprintsFjords = {
...base,
namespace: "slarti",
name: "fjords-blueprints"
name: "fjords-blueprints",
};
const slartiFjords = {
...base,
namespace: "slarti",
name: "fjords"
name: "fjords",
};
const hitchhikerRestand = {
...base,
namespace: "hitchhiker",
name: "restand"
name: "restand",
};
const hitchhikerPuzzle42 = {
...base,
namespace: "hitchhiker",
name: "puzzle42"
name: "puzzle42",
};
const hitchhikerHeartOfGold = {
...base,
namespace: "hitchhiker",
name: "heartOfGold"
name: "heartOfGold",
};
const zaphodMarvinFirmware = {
...base,
namespace: "zaphod",
name: "marvin-firmware"
name: "marvin-firmware",
};
it("should group the repositories by their namespace", () => {
const repositories = [
zaphodMarvinFirmware,
slartiBlueprintsFjords,
hitchhikerRestand,
slartiFjords,
slartiBlueprintsFjords,
hitchhikerHeartOfGold,
hitchhikerPuzzle42
hitchhikerPuzzle42,
hitchhikerRestand,
];
const namespaces = {
_embedded: {
namespaces: [{ namespace: "hitchhiker" }, { namespace: "slarti" }, { namespace: "zaphod" }]
}
namespaces: [{ namespace: "hitchhiker" }, { namespace: "slarti" }, { namespace: "zaphod" }],
},
};
const expected = [
{
name: "hitchhiker",
namespace: { namespace: "hitchhiker" },
repositories: [hitchhikerHeartOfGold, hitchhikerPuzzle42, hitchhikerRestand]
repositories: [hitchhikerHeartOfGold, hitchhikerPuzzle42, hitchhikerRestand],
},
{
name: "slarti",
namespace: { namespace: "slarti" },
repositories: [slartiFjords, slartiBlueprintsFjords]
repositories: [slartiFjords, slartiBlueprintsFjords],
},
{
name: "zaphod",
namespace: { namespace: "zaphod" },
repositories: [zaphodMarvinFirmware]
}
repositories: [zaphodMarvinFirmware],
},
];
expect(groupByNamespace(repositories, namespaces)).toEqual(expected);

View File

@@ -38,7 +38,7 @@ export default function groupByNamespace(
group = {
name: groupName,
namespace: namespace,
repositories: []
repositories: [],
};
groups[groupName] = group;
}
@@ -47,8 +47,6 @@ export default function groupByNamespace(
const groupArray = [];
for (const groupName in groups) {
const group = groups[groupName];
group.repositories.sort(sortByName);
groupArray.push(groups[groupName]);
}
groupArray.sort(sortByName);
@@ -65,5 +63,5 @@ function sortByName(a, b) {
}
function findNamespace(namespaces: NamespaceCollection, namespaceToFind: string) {
return namespaces._embedded.namespaces.find(namespace => namespace.namespace === namespaceToFind);
return namespaces._embedded.namespaces.find((namespace) => namespace.namespace === namespaceToFind);
}