mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-10-26 08:06:09 +01:00
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:
2
gradle/changelog/collapsible_namespaces.yaml
Normal file
2
gradle/changelog/collapsible_namespaces.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Make namespaces collapsible and save the collapsed state in local storage
|
||||
2
gradle/changelog/sort-repos.yaml
Normal file
2
gradle/changelog/sort-repos.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: changed
|
||||
description: Sort repositories alphanumerically per namespace
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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] {
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user