mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-04 18:14:53 +02:00
Edit client page done
This commit is contained in:
parent
581fdd67b1
commit
dc49027b30
20 changed files with 491 additions and 26 deletions
|
@ -66,6 +66,7 @@ export enum ActionsEnum {
|
|||
deleteClient = "deleteClient",
|
||||
updateClient = "updateClient",
|
||||
listClients = "listClients",
|
||||
getClient = "getClient",
|
||||
listOrgDomains = "listOrgDomains",
|
||||
}
|
||||
|
||||
|
|
74
server/routers/client/getClient.ts
Normal file
74
server/routers/client/getClient.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { clients } from "@server/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const getClientSchema = z
|
||||
.object({
|
||||
clientId: z
|
||||
.string()
|
||||
.transform(stoi)
|
||||
.pipe(z.number().int().positive()),
|
||||
orgId: z.string().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function query(clientId: number) {
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.clientId, clientId))
|
||||
.limit(1);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type GetClientResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||
|
||||
export async function getClient(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getClientSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
logger.error(
|
||||
`Error parsing params: ${fromError(parsedParams.error).toString()}`
|
||||
);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { clientId } = parsedParams.data;
|
||||
|
||||
const client = await query(clientId);
|
||||
|
||||
if (!client) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Client not found"));
|
||||
}
|
||||
|
||||
return response<GetClientResponse>(res, {
|
||||
data: client,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Client retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,4 +2,5 @@ export * from "./pickClientDefaults";
|
|||
export * from "./createClient";
|
||||
export * from "./deleteClient";
|
||||
export * from "./listClients";
|
||||
export * from "./updateClient";
|
||||
export * from "./updateClient";
|
||||
export * from "./getClient";
|
|
@ -120,6 +120,13 @@ authenticated.get(
|
|||
client.listClients
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/client/:clientId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getClient),
|
||||
client.getClient
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/client",
|
||||
verifyOrgAccess,
|
||||
|
|
|
@ -42,7 +42,8 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
|||
megabytesOut: sites.megabytesOut,
|
||||
orgName: orgs.name,
|
||||
type: sites.type,
|
||||
online: sites.online
|
||||
online: sites.online,
|
||||
address: sites.address,
|
||||
})
|
||||
.from(sites)
|
||||
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
|
|
|
@ -30,6 +30,8 @@ import CreateClientFormModal from "./CreateClientsModal";
|
|||
export type ClientRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
subnet: string;
|
||||
// siteIds: string;
|
||||
mbIn: string;
|
||||
mbOut: string;
|
||||
orgId: string;
|
||||
|
@ -53,7 +55,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
|||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const deleteSite = (clientId: number) => {
|
||||
const deleteClient = (clientId: number) => {
|
||||
api.delete(`/client/${clientId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting client", e);
|
||||
|
@ -218,25 +220,41 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
|||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "subnet",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
// {
|
||||
// id: "actions",
|
||||
// cell: ({ row }) => {
|
||||
// const siteRow = row.original;
|
||||
// return (
|
||||
// <div className="flex items-center justify-end">
|
||||
// <Link
|
||||
// href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
// >
|
||||
// <Button variant={"outline"} className="ml-2">
|
||||
// Edit
|
||||
// <ArrowRight className="ml-2 w-4 h-4" />
|
||||
// </Button>
|
||||
// </Link>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
];
|
||||
|
||||
return (
|
||||
|
@ -281,7 +299,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
|||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Client"
|
||||
onConfirm={async () => deleteSite(selectedClient!.id)}
|
||||
onConfirm={async () => deleteClient(selectedClient!.id)}
|
||||
string={selectedClient.name}
|
||||
title="Delete Client"
|
||||
/>
|
||||
|
|
|
@ -222,6 +222,7 @@ export default function CreateClientForm({
|
|||
onCreate?.({
|
||||
name: data.name,
|
||||
id: data.clientId,
|
||||
subnet: data.subnet,
|
||||
mbIn: "0 MB",
|
||||
mbOut: "0 MB",
|
||||
orgId: orgId as string,
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useClientContext } from "@app/hooks/useClientContext";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
|
||||
type ClientInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
const { client, updateClient } = useClientContext();
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">Client Information</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Online</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Offline</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Address</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.subnet.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
143
src/app/[orgId]/settings/clients/[clientId]/general/page.tsx
Normal file
143
src/app/[orgId]/settings/clients/[clientId]/general/page.tsx
Normal file
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useClientContext } from "@app/hooks/useClientContext";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useState } from "react";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required")
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { client, updateClient } = useClientContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: client?.name
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
async function onSubmit(data: GeneralFormValues) {
|
||||
setLoading(true);
|
||||
|
||||
await api
|
||||
.post(`/client/${client?.clientId}`, {
|
||||
name: data.name
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update client",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the client."
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
updateClient({ name: data.name });
|
||||
|
||||
toast({
|
||||
title: "Client updated",
|
||||
description: "The client has been updated."
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this client
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
client.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="general-settings-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
77
src/app/[orgId]/settings/clients/[clientId]/layout.tsx
Normal file
77
src/app/[orgId]/settings/clients/[clientId]/layout.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||
import Link from "next/link";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
import { GetClientResponse } from "@server/routers/client";
|
||||
import ClientInfoCard from "./ClientInfoCard";
|
||||
import ClientProvider from "@app/providers/ClientProvider";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ clientId: number; orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
let client = null;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetClientResponse>>(
|
||||
`/org/${params.orgId}/client/${params.clientId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
client = res.data.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching client data:", error);
|
||||
redirect(`/${params.orgId}/settings/clients`);
|
||||
}
|
||||
|
||||
const sidebarNavItems = [
|
||||
{
|
||||
title: "General",
|
||||
href: "/{orgId}/settings/clients/{clientId}/general"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex-row">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<Link href="../">Clients</Link>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{client.name}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<SettingsSectionTitle
|
||||
title={`${client?.name} Settings`}
|
||||
description="Configure the settings on your site"
|
||||
/>
|
||||
|
||||
<ClientProvider client={client}>
|
||||
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
||||
<ClientInfoCard />
|
||||
{children}
|
||||
</SidebarSettings>
|
||||
</ClientProvider>
|
||||
</>
|
||||
);
|
||||
}
|
8
src/app/[orgId]/settings/clients/[clientId]/page.tsx
Normal file
8
src/app/[orgId]/settings/clients/[clientId]/page.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ClientPage(props: {
|
||||
params: Promise<{ orgId: string; clientId: number }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`);
|
||||
}
|
|
@ -37,6 +37,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||
return {
|
||||
name: client.name,
|
||||
id: client.clientId,
|
||||
subnet: client.subnet.split("/")[0],
|
||||
mbIn: formatSize(client.megabytesIn || 0),
|
||||
mbOut: formatSize(client.megabytesOut || 0),
|
||||
orgId: params.orgId,
|
||||
|
|
|
@ -206,7 +206,6 @@ export default function GeneralPage() {
|
|||
)}
|
||||
/>
|
||||
|
||||
{/* New FormField for subnet input */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subnet"
|
||||
|
@ -215,8 +214,8 @@ export default function GeneralPage() {
|
|||
<FormLabel>Subnet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g., 192.168.1.0/24"
|
||||
{...field}
|
||||
disabled={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
|
|
@ -224,6 +224,7 @@ export default function CreateSiteForm({
|
|||
name: data.name,
|
||||
id: data.siteId,
|
||||
nice: data.niceId.toString(),
|
||||
address: data.address?.split("/")[0],
|
||||
mbIn:
|
||||
data.type == "wireguard" || data.type == "newt"
|
||||
? "0 MB"
|
||||
|
|
|
@ -37,6 +37,7 @@ export type SiteRow = {
|
|||
orgId: string;
|
||||
type: "newt" | "wireguard";
|
||||
online: boolean;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
type SitesTableProps = {
|
||||
|
@ -259,6 +260,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "address",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
|
|
|
@ -41,6 +41,7 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||
name: site.name,
|
||||
id: site.siteId,
|
||||
nice: site.niceId.toString(),
|
||||
address: site.address?.split("/")[0],
|
||||
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||
mbOut: formatSize(site.megabytesOut || 0, site.type),
|
||||
orgId: params.orgId,
|
||||
|
|
|
@ -34,6 +34,7 @@ export function SidebarNav({
|
|||
const niceId = params.niceId as string;
|
||||
const resourceId = params.resourceId as string;
|
||||
const userId = params.userId as string;
|
||||
const clientId = params.clientId as string;
|
||||
|
||||
const [selectedValue, setSelectedValue] = React.useState<string>(getSelectedValue());
|
||||
|
||||
|
@ -59,7 +60,8 @@ export function SidebarNav({
|
|||
.replace("{orgId}", orgId)
|
||||
.replace("{niceId}", niceId)
|
||||
.replace("{resourceId}", resourceId)
|
||||
.replace("{userId}", userId);
|
||||
.replace("{userId}", userId)
|
||||
.replace("{clientId}", clientId);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
11
src/contexts/clientContext.ts
Normal file
11
src/contexts/clientContext.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { GetClientResponse } from "@server/routers/client/getClient";
|
||||
import { createContext } from "react";
|
||||
|
||||
interface ClientContextType {
|
||||
client: GetClientResponse;
|
||||
updateClient: (updatedClient: Partial<GetClientResponse>) => void;
|
||||
}
|
||||
|
||||
const ClientContext = createContext<ClientContextType | undefined>(undefined);
|
||||
|
||||
export default ClientContext;
|
10
src/hooks/useClientContext.ts
Normal file
10
src/hooks/useClientContext.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import ClientContext from "@app/contexts/clientContext";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function useClientContext() {
|
||||
const context = useContext(ClientContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSiteContext must be used within a SiteProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
40
src/providers/ClientProvider.tsx
Normal file
40
src/providers/ClientProvider.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import ClientContext from "@app/contexts/clientContext";
|
||||
import { GetClientResponse } from "@server/routers/client/getClient";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ClientProviderProps {
|
||||
children: React.ReactNode;
|
||||
client: GetClientResponse;
|
||||
}
|
||||
|
||||
export function ClientProvider({
|
||||
children,
|
||||
client: serverClient
|
||||
}: ClientProviderProps) {
|
||||
const [client, setClient] = useState<GetClientResponse>(serverClient);
|
||||
|
||||
const updateClient = (updatedClient: Partial<GetClientResponse>) => {
|
||||
if (!client) {
|
||||
throw new Error("No client to update");
|
||||
}
|
||||
setClient((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
...updatedClient
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={{ client, updateClient }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientProvider;
|
Loading…
Add table
Add a link
Reference in a new issue