From 2efcbfa759c3884d56eaf4c8e9b6fc19ca3f88ae Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Tue, 22 Aug 2023 19:59:53 +0200 Subject: [PATCH] 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 --- gradle/changelog/collapsible_namespaces.yaml | 2 + gradle/changelog/sort-repos.yaml | 2 + .../scm/repository/xml/XmlRepositoryDAO.java | 4 +- scm-ui/ui-api/src/index.ts | 1 + .../src/localStorage.ts} | 2 +- scm-ui/ui-api/src/repositories.ts | 66 +++++++++---------- .../ui-components/src/layout/GroupEntries.tsx | 51 ++++++++++++-- scm-ui/ui-webapp/src/accessibilityConfig.ts | 2 +- .../components/list/RepositoryGroupEntry.tsx | 21 +----- .../components/list/groupByNamespace.test.ts | 32 ++++----- .../repos/components/list/groupByNamespace.ts | 6 +- 11 files changed, 109 insertions(+), 80 deletions(-) create mode 100644 gradle/changelog/collapsible_namespaces.yaml create mode 100644 gradle/changelog/sort-repos.yaml rename scm-ui/{ui-webapp/src/useLocalStorage.ts => ui-api/src/localStorage.ts} (97%) diff --git a/gradle/changelog/collapsible_namespaces.yaml b/gradle/changelog/collapsible_namespaces.yaml new file mode 100644 index 0000000000..28a8f545f0 --- /dev/null +++ b/gradle/changelog/collapsible_namespaces.yaml @@ -0,0 +1,2 @@ +- type: added + description: Make namespaces collapsible and save the collapsed state in local storage diff --git a/gradle/changelog/sort-repos.yaml b/gradle/changelog/sort-repos.yaml new file mode 100644 index 0000000000..7620eebaca --- /dev/null +++ b/gradle/changelog/sort-repos.yaml @@ -0,0 +1,2 @@ +- type: changed + description: Sort repositories alphanumerically per namespace diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java index d319f5c542..b9d69c7910 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java @@ -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 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 diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index f54569732c..5ae171362d 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -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"; diff --git a/scm-ui/ui-webapp/src/useLocalStorage.ts b/scm-ui/ui-api/src/localStorage.ts similarity index 97% rename from scm-ui/ui-webapp/src/useLocalStorage.ts rename to scm-ui/ui-api/src/localStorage.ts index d8107853cb..7126cacb1a 100644 --- a/scm-ui/ui-webapp/src/useLocalStorage.ts +++ b/scm-ui/ui-api/src/localStorage.ts @@ -24,7 +24,7 @@ import { useEffect, useState } from "react"; -export default function useLocalStorage( +export function useLocalStorage( key: string, initialValue: T ): [value: T, setValue: (value: T | ((previousConfig: T) => T)) => void] { diff --git a/scm-ui/ui-api/src/repositories.ts b/scm-ui/ui-api/src/repositories.ts index abe0bf673a..67a4496815 100644 --- a/scm-ui/ui-api/src/repositories.ts +++ b/scm-ui/ui-api/src/repositories.ts @@ -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( ["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 { 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( 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 => { const link = useRequiredIndexLink("repositories"); return useQuery(["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( - 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( - 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( - 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( - 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( - 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( ["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 { 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 => { const link = requiredLink(repository, "paths").replace("{revision}", revision); return useQuery(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( ({ 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, }; }; diff --git a/scm-ui/ui-components/src/layout/GroupEntries.tsx b/scm-ui/ui-components/src/layout/GroupEntries.tsx index 14523f3e87..ceeb88525d 100644 --- a/scm-ui/ui-components/src/layout/GroupEntries.tsx +++ b/scm-ui/ui-components/src/layout/GroupEntries.tsx @@ -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 = ({ namespaceHeader, elements }) => { +const GroupEntries: FC = ({ group, elements }) => { + const [t] = useTranslation("namespaces"); + const [collapsed, setCollapsed] = useLocalStorage(`repoNamespace.${group.name}.collapsed`, null); + const content = elements.map((entry, index) => (
{entry}
@@ -42,12 +50,45 @@ const GroupEntries: FC = ({ namespaceHeader, elements }) => {
)); + const settingsLink = group.namespace?._links?.permissions && ( + + + + ); + return ( <> -
- {namespaceHeader} +
setCollapsed(!collapsed)} + > + + + {group.name} + {" "} + {settingsLink} + +
-
{content}
+ {collapsed ? null :
{content}
}
); diff --git a/scm-ui/ui-webapp/src/accessibilityConfig.ts b/scm-ui/ui-webapp/src/accessibilityConfig.ts index 4045cc7b79..90bd4364bb 100644 --- a/scm-ui/ui-webapp/src/accessibilityConfig.ts +++ b/scm-ui/ui-webapp/src/accessibilityConfig.ts @@ -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"; diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx index 6464327fdf..ebe099e81c 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx @@ -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 = ({ group }) => { - const [t] = useTranslation("namespaces"); - - const settingsLink = group.namespace?._links?.permissions && ( - - - - ); - const namespaceHeader = ( - <> - - {group.name} - {" "} - {settingsLink} - - ); const entries = group.repositories.map((repository, index) => { return ; }); - return ; + return ; }; export default RepositoryGroupEntry; diff --git a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts index 4a917d15e4..7405b62228 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts +++ b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts @@ -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); diff --git a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts index f8b04a51c2..c483ef1202 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts +++ b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts @@ -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); }