implement react-query for edge-cases (#1711)

When initially implementing react-query, we focussed on core features. This pull request now replaces the remaining apiClient usages in ui-components and ui-webapp with react-query hooks.
This commit is contained in:
Konstantin Schaper
2021-06-28 13:19:03 +02:00
committed by GitHub
parent 2cd46ce8a0
commit e1239aff92
50 changed files with 1101 additions and 1116 deletions

View File

@@ -0,0 +1,82 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { ApiKey, ApiKeyCreation, ApiKeysCollection, ApiKeyWithToken, Me, User } from "@scm-manager/ui-types";
import { ApiResult } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
const CONTENT_TYPE_API_KEY = "application/vnd.scmm-apiKey+json;v=2";
export const useApiKeys = (user: User | Me): ApiResult<ApiKeysCollection> =>
useQuery(["user", user.name, "apiKeys"], () => apiClient.get(requiredLink(user, "apiKeys")).then((r) => r.json()));
const createApiKey =
(link: string) =>
async (key: ApiKeyCreation): Promise<ApiKeyWithToken> => {
const creationResponse = await apiClient.post(link, key, CONTENT_TYPE_API_KEY);
const location = creationResponse.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
const locationResponse = await apiClient.get(location);
const [apiKey, token] = await Promise.all<ApiKey, string>([locationResponse.json(), creationResponse.text()]);
return { ...apiKey, token } as ApiKeyWithToken;
};
export const useCreateApiKey = (user: User | Me, apiKeys: ApiKeysCollection) => {
const queryClient = useQueryClient();
const { mutate, data, isLoading, error, reset } = useMutation<ApiKeyWithToken, Error, ApiKeyCreation>(
createApiKey(requiredLink(apiKeys, "create")),
{
onSuccess: () => queryClient.invalidateQueries(["user", user.name, "apiKeys"]),
}
);
return {
create: (key: ApiKeyCreation) => mutate(key),
isLoading,
error,
apiKey: data,
reset,
};
};
export const useDeleteApiKey = (user: User | Me) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, ApiKey>(
(apiKey) => {
const deleteUrl = requiredLink(apiKey, "delete");
return apiClient.delete(deleteUrl);
},
{
onSuccess: () => queryClient.invalidateQueries(["user", user.name, "apiKeys"]),
}
);
return {
remove: (apiKey: ApiKey) => mutate(apiKey),
isLoading,
error,
isDeleted: !!data,
};
};

View File

@@ -22,22 +22,24 @@
* SOFTWARE.
*/
import { IndexResources, Link } from "@scm-manager/ui-types";
import { HalRepresentation, IndexResources, Link } from "@scm-manager/ui-types";
import { useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { useLegacyContext } from "./LegacyContext";
import { MissingLinkError, UnauthorizedError } from "./errors";
import { requiredLink } from "./links";
export type ApiResult<T> = {
isLoading: boolean;
error: Error | null;
data?: T;
};
export type DeleteFunction<T> = (entity: T) => void;
export const useIndex = (): ApiResult<IndexResources> => {
const legacy = useLegacyContext();
return useQuery<IndexResources, Error>("index", () => apiClient.get("/").then(response => response.json()), {
onSuccess: index => {
return useQuery<IndexResources, Error>("index", () => apiClient.get("/").then((response) => response.json()), {
onSuccess: (index) => {
// ensure legacy code is notified
if (legacy.onIndexFetched) {
legacy.onIndexFetched(index);
@@ -49,7 +51,7 @@ export const useIndex = (): ApiResult<IndexResources> => {
// This only happens once because the error response automatically invalidates the cookie.
// In this event, we have to try the request once again.
return error instanceof UnauthorizedError && failureCount === 0;
}
},
});
};
@@ -94,7 +96,20 @@ export const useVersion = (): string => {
export const useIndexJsonResource = <T>(name: string): ApiResult<T> => {
const link = useIndexLink(name);
return useQuery<T, Error>(name, () => apiClient.get(link!).then(response => response.json()), {
enabled: !!link
return useQuery<T, Error>(name, () => apiClient.get(link!).then((response) => response.json()), {
enabled: !!link,
});
};
export const useJsonResource = <T>(entity: HalRepresentation, name: string, key: string[]): ApiResult<T> =>
useQuery<T, Error>(key, () => apiClient.get(requiredLink(entity, name)).then((response) => response.json()));
export function fetchResourceFromLocationHeader(response: Response) {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
}
export const getResponseJson = (response: Response) => response.json();

View File

@@ -36,9 +36,9 @@ describe("Test branches hooks", () => {
type: "hg",
_links: {
branches: {
href: "/hog/branches"
}
}
href: "/hog/branches",
},
},
};
const develop: Branch = {
@@ -46,16 +46,16 @@ describe("Test branches hooks", () => {
revision: "42",
_links: {
delete: {
href: "/hog/branches/develop"
}
}
href: "/hog/branches/develop",
},
},
};
const branches: BranchCollection = {
_embedded: {
branches: [develop]
branches: [develop],
},
_links: {}
_links: {},
};
const queryClient = createInfiniteCachingClient();
@@ -73,7 +73,7 @@ describe("Test branches hooks", () => {
fetchMock.getOnce("/api/v2/hog/branches", branches);
const { result, waitFor } = renderHook(() => useBranches(repository), {
wrapper: createWrapper(undefined, queryClient)
wrapper: createWrapper(undefined, queryClient),
});
await waitFor(() => {
return !!result.current.data;
@@ -94,7 +94,7 @@ describe("Test branches hooks", () => {
"repository",
"hitchhiker",
"heart-of-gold",
"branches"
"branches",
]);
expect(data).toEqual(branches);
});
@@ -105,7 +105,7 @@ describe("Test branches hooks", () => {
fetchMock.getOnce("/api/v2/hog/branches/develop", develop);
const { result, waitFor } = renderHook(() => useBranch(repository, "develop"), {
wrapper: createWrapper(undefined, queryClient)
wrapper: createWrapper(undefined, queryClient),
});
expect(result.error).toBeUndefined();
@@ -128,14 +128,14 @@ describe("Test branches hooks", () => {
fetchMock.postOnce("/api/v2/hog/branches", {
status: 201,
headers: {
Location: "/hog/branches/develop"
}
Location: "/hog/branches/develop",
},
});
fetchMock.getOnce("/api/v2/hog/branches/develop", develop);
const { result, waitForNextUpdate } = renderHook(() => useCreateBranch(repository), {
wrapper: createWrapper(undefined, queryClient)
wrapper: createWrapper(undefined, queryClient),
});
await act(() => {
@@ -160,7 +160,7 @@ describe("Test branches hooks", () => {
"hitchhiker",
"heart-of-gold",
"branch",
"develop"
"develop",
]);
expect(branch).toEqual(develop);
});
@@ -177,11 +177,11 @@ describe("Test branches hooks", () => {
describe("useDeleteBranch tests", () => {
const deleteBranch = async () => {
fetchMock.deleteOnce("/api/v2/hog/branches/develop", {
status: 204
status: 204,
});
const { result, waitForNextUpdate } = renderHook(() => useDeleteBranch(repository), {
wrapper: createWrapper(undefined, queryClient)
wrapper: createWrapper(undefined, queryClient),
});
await act(() => {
@@ -198,12 +198,12 @@ describe("Test branches hooks", () => {
expect(isDeleted).toBe(true);
});
it("should invalidate branch", async () => {
it("should delete branch cache", async () => {
queryClient.setQueryData(["repository", "hitchhiker", "heart-of-gold", "branch", "develop"], develop);
await deleteBranch();
const queryState = queryClient.getQueryState(["repository", "hitchhiker", "heart-of-gold", "branch", "develop"]);
expect(queryState!.isInvalidated).toBe(true);
expect(queryState).toBeUndefined();
});
it("should invalidate cached branches list", async () => {

View File

@@ -88,7 +88,7 @@ export const useDeleteBranch = (repository: Repository) => {
},
{
onSuccess: async (_, branch) => {
await queryClient.invalidateQueries(branchQueryKey(repository, branch));
queryClient.removeQueries(branchQueryKey(repository, branch));
await queryClient.invalidateQueries(repoQueryKey(repository, "branches"));
}
}

View File

@@ -0,0 +1,33 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { File } from "@scm-manager/ui-types";
import { useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
import { ApiResult } from "./base";
export const useFileContent = (file: File): ApiResult<string> => {
const selfLink = requiredLink(file, "self");
return useQuery(["fileContent", selfLink], () => apiClient.get(selfLink).then((response) => response.text()));
};

View File

@@ -22,11 +22,93 @@
* SOFTWARE.
*/
import { ApiResult, useRequiredIndexLink } from "./base";
import { useQuery } from "react-query";
import { ApiResult, fetchResourceFromLocationHeader, getResponseJson, useRequiredIndexLink } from "./base";
import { useMutation, useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { Repository, RepositoryCreation, RepositoryType, RepositoryUrlImport } from "@scm-manager/ui-types";
import { requiredLink } from "./links";
export const useImportLog = (logId: string) : ApiResult<string> => {
export const useImportLog = (logId: string): ApiResult<string> => {
const link = useRequiredIndexLink("importLog").replace("{logId}", logId);
return useQuery<string, Error>(["importLog", logId], () => apiClient.get(link).then(response => response.text()));
}
return useQuery<string, Error>(["importLog", logId], () => apiClient.get(link).then((response) => response.text()));
};
export const useImportRepositoryFromUrl = (repositoryType: RepositoryType) => {
const url = requiredLink(repositoryType, "import", "url");
const { isLoading, error, data, mutate } = useMutation<Repository, Error, RepositoryUrlImport>((repo) =>
apiClient
.post(url, repo, "application/vnd.scmm-repository+json;v=2")
.then(fetchResourceFromLocationHeader)
.then(getResponseJson)
);
return {
isLoading,
error,
importRepositoryFromUrl: (repository: RepositoryUrlImport) => mutate(repository),
importedRepository: data,
};
};
const importRepository = (url: string, repository: RepositoryCreation, file: File, password?: string) => {
return apiClient
.postBinary(url, (formData) => {
formData.append("bundle", file, file?.name);
formData.append("repository", JSON.stringify({ ...repository, password }));
})
.then(fetchResourceFromLocationHeader)
.then(getResponseJson);
};
type ImportRepositoryFromBundleRequest = {
repository: RepositoryCreation;
file: File;
compressed?: boolean;
password?: string;
};
export const useImportRepositoryFromBundle = (repositoryType: RepositoryType) => {
const url = requiredLink(repositoryType, "import", "bundle");
const { isLoading, error, data, mutate } = useMutation<Repository, Error, ImportRepositoryFromBundleRequest>(
({ repository, file, compressed, password }) =>
importRepository(compressed ? url + "?compressed=true" : url, repository, file, password)
);
return {
isLoading,
error,
importRepositoryFromBundle: (repository: RepositoryCreation, file: File, compressed?: boolean, password?: string) =>
mutate({
repository,
file,
compressed,
password,
}),
importedRepository: data,
};
};
type ImportFullRepositoryRequest = {
repository: RepositoryCreation;
file: File;
password?: string;
};
export const useImportFullRepository = (repositoryType: RepositoryType) => {
const { isLoading, error, data, mutate } = useMutation<Repository, Error, ImportFullRepositoryRequest>(
({ repository, file, password }) =>
importRepository(requiredLink(repositoryType, "import", "fullImport"), repository, file, password)
);
return {
isLoading,
error,
importFullRepository: (repository: RepositoryCreation, file: File, password?: string) =>
mutate({
repository,
file,
password,
}),
importedRepository: data,
};
};

View File

@@ -50,6 +50,9 @@ export * from "./import";
export * from "./diff";
export * from "./notifications";
export * from "./configLink";
export * from "./apiKeys";
export * from "./publicKeys";
export * from "./fileContent";
export * from "./history";
export * from "./contentType";
export * from "./annotations";

View File

@@ -60,4 +60,40 @@ describe("requireLink tests", () => {
};
expect(() => requiredLink(object, "spaceship")).toThrowError();
});
it("should return sub-link if it exists", () => {
const object = {
_links: {
spaceship: [
{
name: "one",
href: "/v2/one"
},
{
name: "two",
href: "/v2/two"
}
]
}
};
expect(requiredLink(object, "spaceship", "one")).toBe("/v2/one");
});
it("should throw error, if sub-link does not exist in link array", () => {
const object = {
_links: {
spaceship: [
{
name: "one",
href: "/v2/one"
},
{
name: "two",
href: "/v2/two"
}
]
}
};
expect(() => requiredLink(object, "spaceship", "three")).toThrowError();
});
});

View File

@@ -21,14 +21,32 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { HalRepresentation } from "@scm-manager/ui-types";
import { HalRepresentation, Link } from "@scm-manager/ui-types";
import { MissingLinkError } from "./errors";
export const requiredLink = (object: HalRepresentation, name: string) => {
export const requiredLink = (object: HalRepresentation, name: string, subName?: string): string => {
const link = object._links[name];
if (!link) {
throw new MissingLinkError(`could not find link with name ${name}`);
}
if (Array.isArray(link)) {
if (subName) {
const subLink = link.find((l: Link) => l.name === subName);
if (subLink) {
return subLink.href;
}
throw new Error(`could not return href, sub-link ${subName} in ${name} does not exist`);
}
throw new Error(`could not return href, link ${name} is a multi link`);
}
return link.href;
};
export const objectLink = (object: HalRepresentation, name: string) => {
const link = object._links[name];
if (!link) {
return null;
}
if (Array.isArray(link)) {
throw new Error(`could not return href, link ${name} is a multi link`);
}

View File

@@ -21,18 +21,21 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { ApiResult, useIndexJsonResource } from "./base";
import { ApiResult, useIndexJsonResource, useJsonResource } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
GlobalPermissionsCollection,
Group,
Namespace,
Permission,
PermissionCollection,
PermissionCreateEntry,
Repository,
RepositoryVerbs
RepositoryVerbs,
User,
} from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
import { objectLink, requiredLink } from "./links";
import { repoQueryKey } from "./keys";
import { useRepositoryRoles } from "./repository-roles";
@@ -40,6 +43,9 @@ export const useRepositoryVerbs = (): ApiResult<RepositoryVerbs> => {
return useIndexJsonResource<RepositoryVerbs>("repositoryVerbs");
};
/**
* *IMPORTANT NOTE:* These are actually *REPOSITORY* permissions.
*/
export const useAvailablePermissions = () => {
const roles = useRepositoryRoles();
const verbs = useRepositoryVerbs();
@@ -47,14 +53,14 @@ export const useAvailablePermissions = () => {
if (roles.data && verbs.data) {
data = {
repositoryVerbs: verbs.data.verbs,
repositoryRoles: roles.data._embedded.repositoryRoles
repositoryRoles: roles.data._embedded.repositoryRoles,
};
}
return {
isLoading: roles.isLoading || verbs.isLoading,
error: roles.error || verbs.error,
data
data,
};
};
@@ -73,21 +79,21 @@ const createQueryKey = (namespaceOrRepository: Namespace | Repository) => {
export const usePermissions = (namespaceOrRepository: Namespace | Repository): ApiResult<PermissionCollection> => {
const link = requiredLink(namespaceOrRepository, "permissions");
const queryKey = createQueryKey(namespaceOrRepository);
return useQuery<PermissionCollection, Error>(queryKey, () => apiClient.get(link).then(response => response.json()));
return useQuery<PermissionCollection, Error>(queryKey, () => apiClient.get(link).then((response) => response.json()));
};
const createPermission = (link: string) => {
return (permission: PermissionCreateEntry) => {
return apiClient
.post(link, permission, "application/vnd.scmm-repositoryPermission+json")
.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());
};
};
@@ -100,21 +106,21 @@ export const useCreatePermission = (namespaceOrRepository: Namespace | Repositor
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
},
}
);
return {
isLoading,
error,
create: (permission: PermissionCreateEntry) => mutate(permission),
permission: data
permission: data,
};
};
export const useUpdatePermission = (namespaceOrRepository: Namespace | Repository) => {
const queryClient = useQueryClient();
const { isLoading, error, mutate, data } = useMutation<unknown, Error, Permission>(
permission => {
(permission) => {
const link = requiredLink(permission, "update");
return apiClient.put(link, permission, "application/vnd.scmm-repositoryPermission+json");
},
@@ -122,21 +128,21 @@ export const useUpdatePermission = (namespaceOrRepository: Namespace | Repositor
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
},
}
);
return {
isLoading,
error,
update: (permission: Permission) => mutate(permission),
isUpdated: !!data
isUpdated: !!data,
};
};
export const useDeletePermission = (namespaceOrRepository: Namespace | Repository) => {
const queryClient = useQueryClient();
const { isLoading, error, mutate, data } = useMutation<unknown, Error, Permission>(
permission => {
(permission) => {
const link = requiredLink(permission, "delete");
return apiClient.delete(link);
},
@@ -144,13 +150,53 @@ export const useDeletePermission = (namespaceOrRepository: Namespace | Repositor
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
},
}
);
return {
isLoading,
error,
remove: (permission: Permission) => mutate(permission),
isDeleted: !!data
isDeleted: !!data,
};
};
const userPermissionsKey = (user: User) => ["user", user.name, "permissions"];
const groupPermissionsKey = (group: Group) => ["group", group.name, "permissions"];
export const useGroupPermissions = (group: Group) =>
useJsonResource<GlobalPermissionsCollection>(group, "permissions", groupPermissionsKey(group));
export const useUserPermissions = (user: User) =>
useJsonResource<GlobalPermissionsCollection>(user, "permissions", userPermissionsKey(user));
export const useAvailableGlobalPermissions = () =>
useIndexJsonResource<Omit<GlobalPermissionsCollection, "_links">>("permissions");
const useSetEntityPermissions = (permissionCollection?: GlobalPermissionsCollection, key?: string[]) => {
const queryClient = useQueryClient();
const url = permissionCollection ? objectLink(permissionCollection, "overwrite") : null;
const { isLoading, error, mutate, data } = useMutation<unknown, Error, string[]>(
(permissions) =>
apiClient.put(
url!,
{
permissions,
},
"application/vnd.scmm-permissionCollection+json;v=2"
),
{
onSuccess: () => queryClient.invalidateQueries(key),
}
);
const setPermissions = (permissions: string[]) => mutate(permissions);
return {
isLoading,
error,
setPermissions: url ? setPermissions : undefined,
isUpdated: !!data,
};
};
export const useSetUserPermissions = (user: User, permissions?: GlobalPermissionsCollection) =>
useSetEntityPermissions(permissions, userPermissionsKey(user));
export const useSetGroupPermissions = (group: Group, permissions?: GlobalPermissionsCollection) =>
useSetEntityPermissions(permissions, groupPermissionsKey(group));

View File

@@ -0,0 +1,83 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Me, PublicKey, PublicKeyCreation, PublicKeysCollection, User } from "@scm-manager/ui-types";
import { ApiResult } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
export const CONTENT_TYPE_PUBLIC_KEY = "application/vnd.scmm-publicKey+json;v=2";
export const usePublicKeys = (user: User | Me): ApiResult<PublicKeysCollection> =>
useQuery(["user", user.name, "publicKeys"], () =>
apiClient.get(requiredLink(user, "publicKeys")).then((r) => r.json())
);
const createPublicKey =
(link: string) =>
async (key: PublicKeyCreation): Promise<PublicKey> => {
const creationResponse = await apiClient.post(link, key, CONTENT_TYPE_PUBLIC_KEY);
const location = creationResponse.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
const apiKeyResponse = await apiClient.get(location);
return apiKeyResponse.json();
};
export const useCreatePublicKey = (user: User | Me, publicKeys: PublicKeysCollection) => {
const queryClient = useQueryClient();
const { mutate, data, isLoading, error, reset } = useMutation<PublicKey, Error, PublicKeyCreation>(
createPublicKey(requiredLink(publicKeys, "create")),
{
onSuccess: () => queryClient.invalidateQueries(["user", user.name, "publicKeys"]),
}
);
return {
create: (key: PublicKeyCreation) => mutate(key),
isLoading,
error,
apiKey: data,
reset,
};
};
export const useDeletePublicKey = (user: User | Me) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, PublicKey>(
(publicKey) => {
const deleteUrl = requiredLink(publicKey, "delete");
return apiClient.delete(deleteUrl);
},
{
onSuccess: () => queryClient.invalidateQueries(["user", user.name, "publicKeys"]),
}
);
return {
remove: (publicKey: PublicKey) => mutate(publicKey),
isLoading,
error,
isDeleted: !!data,
};
};

View File

@@ -30,17 +30,17 @@ import {
Repository,
RepositoryCollection,
RepositoryCreation,
RepositoryTypeCollection
RepositoryTypeCollection,
} from "@scm-manager/ui-types";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { ApiResult, useIndexJsonResource, useRequiredIndexLink } from "./base";
import { createQueryString } from "./utils";
import { requiredLink } from "./links";
import { objectLink, requiredLink } from "./links";
import { repoQueryKey } from "./keys";
import { concat } from "./urls";
import { useEffect, useState } from "react";
import { NotFoundError } from "./errors";
import { MissingLinkError, NotFoundError } from "./errors";
export type UseRepositoriesRequest = {
namespace?: Namespace;
@@ -56,7 +56,7 @@ export const useRepositories = (request?: UseRepositoriesRequest): ApiResult<Rep
const link = namespaceLink || indexLink;
const queryParams: Record<string, string> = {
sortBy: "namespaceAndName"
sortBy: "namespaceAndName",
};
if (request?.search) {
queryParams.q = request.search;
@@ -66,7 +66,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) => {
@@ -74,7 +74,7 @@ export const useRepositories = (request?: UseRepositoriesRequest): ApiResult<Rep
repositories._embedded.repositories.forEach((repository: Repository) => {
queryClient.setQueryData(["repository", repository.namespace, repository.name], repository);
});
}
},
}
);
};
@@ -92,14 +92,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());
};
};
@@ -111,10 +111,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 {
@@ -123,7 +123,7 @@ export const useCreateRepository = () => {
},
isLoading,
error,
repository: data
repository: data,
};
};
@@ -133,7 +133,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())
);
};
@@ -144,7 +144,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);
},
@@ -155,21 +155,21 @@ export const useDeleteRepository = (options?: UseDeleteRepositoryOptions) => {
}
await queryClient.invalidateQueries(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");
},
@@ -177,21 +177,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);
},
@@ -199,21 +199,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);
},
@@ -221,35 +221,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,
};
};
@@ -258,14 +258,14 @@ export const useExportInfo = (repository: Repository): ApiResult<ExportInfo> =>
//TODO Refetch while exporting to update the page
const { isLoading, 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()),
{}
);
return {
isLoading,
error: error instanceof NotFoundError ? null : error,
data
data,
};
};
@@ -308,14 +308,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);
});
@@ -328,20 +328,49 @@ 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())
);
};
type RenameRepositoryRequest = {
name: string;
namespace: string;
};
export const useRenameRepository = (repository: Repository) => {
const queryClient = useQueryClient();
const url = objectLink(repository, "renameWithNamespace") || objectLink(repository, "rename");
if (!url) {
throw new MissingLinkError(`could not find rename link on repository ${repository.namespace}/${repository.name}`);
}
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))
}
);
return {
renameRepository: (namespace: string, name: string) => mutate({ namespace, name }),
isLoading,
error,
isRenamed: !!data,
};
};

View File

@@ -24,10 +24,11 @@
import { ApiResult, useRequiredIndexLink } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link, User, UserCollection, UserCreation } from "@scm-manager/ui-types";
import {Link, Me, User, UserCollection, UserCreation} from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { createQueryString } from "./utils";
import { concat } from "./urls";
import { requiredLink } from "./links";
export type UseUsersRequest = {
page?: number | string;
@@ -48,11 +49,11 @@ export const useUsers = (request?: UseUsersRequest): ApiResult<UserCollection> =
return useQuery<UserCollection, Error>(
["users", request?.search || "", request?.page || 0],
() => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then(response => response.json()),
() => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then((response) => response.json()),
{
onSuccess: (users: UserCollection) => {
users._embedded.users.forEach((user: User) => queryClient.setQueryData(["user", user.name], user));
}
},
}
);
};
@@ -60,7 +61,7 @@ export const useUsers = (request?: UseUsersRequest): ApiResult<UserCollection> =
export const useUser = (name: string): ApiResult<User> => {
const indexLink = useRequiredIndexLink("users");
return useQuery<User, Error>(["user", name], () =>
apiClient.get(concat(indexLink, name)).then(response => response.json())
apiClient.get(concat(indexLink, name)).then((response) => response.json())
);
};
@@ -68,14 +69,14 @@ const createUser = (link: string) => {
return (user: UserCreation) => {
return apiClient
.post(link, user, "application/vnd.scmm-user+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());
};
};
@@ -83,23 +84,23 @@ export const useCreateUser = () => {
const queryClient = useQueryClient();
const link = useRequiredIndexLink("users");
const { mutate, data, isLoading, error } = useMutation<User, Error, UserCreation>(createUser(link), {
onSuccess: user => {
onSuccess: (user) => {
queryClient.setQueryData(["user", user.name], user);
return queryClient.invalidateQueries(["users"]);
}
},
});
return {
create: (user: UserCreation) => mutate(user),
isLoading,
error,
user: data
user: data,
};
};
export const useUpdateUser = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => {
(user) => {
const updateUrl = (user._links.update as Link).href;
return apiClient.put(updateUrl, user, "application/vnd.scmm-user+json;v=2");
},
@@ -107,21 +108,21 @@ export const useUpdateUser = () => {
onSuccess: async (_, user) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
},
}
);
return {
update: (user: User) => mutate(user),
isLoading,
error,
isUpdated: !!data
isUpdated: !!data,
};
};
export const useDeleteUser = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => {
(user) => {
const deleteUrl = (user._links.delete as Link).href;
return apiClient.delete(deleteUrl);
},
@@ -129,14 +130,14 @@ export const useDeleteUser = () => {
onSuccess: async (_, name) => {
await queryClient.invalidateQueries(["user", name]);
await queryClient.invalidateQueries(["users"]);
}
},
}
);
return {
remove: (user: User) => mutate(user),
isLoading,
error,
isDeleted: !!data
isDeleted: !!data,
};
};
@@ -144,7 +145,7 @@ const convertToInternal = (url: string, newPassword: string) => {
return apiClient.put(
url,
{
newPassword
newPassword,
},
"application/vnd.scmm-user+json;v=2"
);
@@ -167,32 +168,73 @@ export const useConvertToInternal = () => {
onSuccess: async (_, { user }) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
},
}
);
return {
convertToInternal: (user: User, password: string) => mutate({ user, password }),
isLoading,
error,
isConverted: !!data
isConverted: !!data,
};
};
export const useConvertToExternal = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => convertToExternal((user._links.convertToExternal as Link).href),
(user) => convertToExternal((user._links.convertToExternal as Link).href),
{
onSuccess: async (_, user) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
},
}
);
return {
convertToExternal: (user: User) => mutate(user),
isLoading,
error,
isConverted: !!data
isConverted: !!data,
};
};
const CONTENT_TYPE_PASSWORD_OVERWRITE = "application/vnd.scmm-passwordOverwrite+json;v=2";
export const useSetUserPassword = (user: User) => {
const { data, isLoading, error, mutate, reset } = useMutation<unknown, Error, string>((password) =>
apiClient.put(
requiredLink(user, "password"),
{
newPassword: password,
},
CONTENT_TYPE_PASSWORD_OVERWRITE
)
);
return {
setPassword: (newPassword: string) => mutate(newPassword),
passwordOverwritten: !!data,
isLoading,
error,
reset
};
};
const CONTENT_TYPE_PASSWORD_CHANGE = "application/vnd.scmm-passwordChange+json;v=2";
type ChangeUserPasswordRequest = {
oldPassword: string;
newPassword: string;
};
export const useChangeUserPassword = (user: User | Me) => {
const { data, isLoading, error, mutate, reset } = useMutation<unknown, Error, ChangeUserPasswordRequest>((request) =>
apiClient.put(requiredLink(user, "password"), request, CONTENT_TYPE_PASSWORD_CHANGE)
);
return {
changePassword: (oldPassword: string, newPassword: string) => mutate({ oldPassword, newPassword }),
passwordChanged: !!data,
isLoading,
error,
reset
};
};