Connected service management front to back; migrated to formik

This commit is contained in:
2022-06-19 21:10:05 +02:00
parent e6dedf5cd8
commit fef4cac1cf
24 changed files with 454 additions and 162 deletions

View File

@ -16,11 +16,11 @@
"@headlessui/react": "^1.6.3",
"@heroicons/react": "^1.0.6",
"clsx": "^1.1.1",
"formik": "^2.2.9",
"graphql": "^16.5.0",
"graphql-tag": "^2.12.6",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-hook-form": "^7.31.3",
"react-query": "^3.39.0",
"react-router-dom": "6"
},

View File

@ -15,12 +15,12 @@ specifiers:
autoprefixer: ^10.4.7
clsx: ^1.1.1
eslint-plugin-react-hooks: ^4.3.0
formik: ^2.2.9
graphql: ^16.5.0
graphql-tag: ^2.12.6
postcss: ^8.4.14
react: ^18.0.0
react-dom: ^18.0.0
react-hook-form: ^7.31.3
react-query: ^3.39.0
react-router-dom: '6'
tailwindcss: ^3.0.24
@ -35,11 +35,11 @@ dependencies:
'@headlessui/react': 1.6.3_ef5jwxihqo6n7gxfmzogljlgcm
'@heroicons/react': 1.0.6_react@18.1.0
clsx: 1.1.1
formik: 2.2.9_react@18.1.0
graphql: 16.5.0
graphql-tag: 2.12.6_graphql@16.5.0
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
react-hook-form: 7.31.3_react@18.1.0
react-query: 3.39.0_ef5jwxihqo6n7gxfmzogljlgcm
react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm
@ -1960,6 +1960,11 @@ packages:
engines: {node: '>=4.0.0'}
dev: true
/deepmerge/2.2.1:
resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==}
engines: {node: '>=0.10.0'}
dev: false
/defaults/1.0.3:
resolution: {integrity: sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA==}
dependencies:
@ -2429,6 +2434,21 @@ packages:
web-streams-polyfill: 4.0.0-beta.1
dev: true
/formik/2.2.9_react@18.1.0:
resolution: {integrity: sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==}
peerDependencies:
react: '>=16.8.0'
dependencies:
deepmerge: 2.2.1
hoist-non-react-statics: 3.3.2
lodash: 4.17.21
lodash-es: 4.17.21
react: 18.1.0
react-fast-compare: 2.0.4
tiny-warning: 1.0.3
tslib: 1.14.1
dev: false
/fraction.js/4.2.0:
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
dev: true
@ -2650,6 +2670,12 @@ packages:
'@babel/runtime': 7.18.0
dev: false
/hoist-non-react-statics/3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
dependencies:
react-is: 16.13.1
dev: false
/http-cache-semantics/4.1.0:
resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
dev: true
@ -3042,6 +3068,10 @@ packages:
p-locate: 4.1.0
dev: false
/lodash-es/4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
dev: false
/lodash.get/4.4.2:
resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=}
dev: true
@ -3599,13 +3629,12 @@ packages:
scheduler: 0.22.0
dev: false
/react-hook-form/7.31.3_react@18.1.0:
resolution: {integrity: sha512-NVZdCWViIWXXXlQ3jxVQH0NuNfwPf8A/0KvuCxrM9qxtP1qYosfR2ZudarziFrVOC7eTUbWbm1T4OyYCwv9oSQ==}
engines: {node: '>=12.22.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18
dependencies:
react: 18.1.0
/react-fast-compare/2.0.4:
resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==}
dev: false
/react-is/16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
/react-query/3.39.0_ef5jwxihqo6n7gxfmzogljlgcm:
@ -4079,6 +4108,10 @@ packages:
resolution: {integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=}
dev: true
/tiny-warning/1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
/title-case/3.0.3:
resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==}
dependencies:
@ -4132,7 +4165,6 @@ packages:
/tslib/1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
dev: true
/tslib/2.3.1:
resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==}

View File

@ -1,5 +1,4 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import MiddlewarePage from "./components/pages/middleware";
import RoutersPage from "./components/pages/routers";
import SecretsPage from "./components/pages/secrets";
import ServicesPage from "./components/pages/services";
@ -13,7 +12,6 @@ export default function App() {
<Route element={<RequireAuth />}>
<Route path="/services" element={<ServicesPage />} />
<Route path="/routers" element={<RoutersPage />} />
<Route path="/middleware" element={<MiddlewarePage />} />
<Route path="/secrets" element={<SecretsPage />} />
<Route path="/" element={<Navigate to="/services" />} />
</Route>

View File

@ -12,10 +12,10 @@ interface InputProps {
const getButtonSize = (size?: InputSize) => {
switch (size) {
case "md":
return "px-[1rem] py-[.5rem] text-sm";
default:
case "lg":
return "px-10 py-4 text-base";
default:
return "px-[1rem] py-[.5rem] text-sm";
}
};

View File

@ -17,10 +17,10 @@ interface InputProps {
const getInputSize = (size?: InputSize) => {
switch (size) {
case "md":
return "px-[1rem] py-[.5rem] text-sm";
default:
case "lg":
return "px-5 py-3 text-base";
default:
return "px-[1rem] py-[.5rem] text-sm";
}
};

View File

@ -1,7 +1,16 @@
import { FC, HTMLProps } from "react";
import clsx from "clsx";
const styleClasses = {
base: "w-full lg:w-1/2 mx-auto",
};
const Container: FC<HTMLProps<HTMLDivElement>> = (props) => {
return <div className="w-full lg:w-1/2 mx-auto">{props.children}</div>;
return (
<div className={clsx(styleClasses.base, props.className)}>
{props.children}
</div>
);
};
export default Container;

View File

@ -3,7 +3,7 @@ import Sidebar from "../molecules/Sidebar";
const DashboardLayout: FC<HTMLProps<HTMLDivElement>> = (props) => {
return (
<div className="flex h-screen overflow-hidden bg-white rounded-lg">
<div className="antialiased flex h-screen overflow-hidden bg-white rounded-lg">
<Sidebar />
<div className="flex flex-col flex-1 w-0 overflow-hidden">
<main className="relative flex-1 overflow-y-auto focus:outline-none">

View File

@ -7,19 +7,25 @@ import {
} from "@heroicons/react/outline";
import { FC, ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import useServices from "../../hooks/useServices";
import { useServicesQuery } from "../../generated/graphql";
import Badge from "../atoms/Badge";
import InputLabel from "../atoms/InputLabel";
import clsx from "clsx";
const styleClasses = {
base: "cursor-pointer inline-flex items-center justify-between w-full px-4 py-2 text-base text-black transition duration-500 ease-in-out transform rounded-lg focus:shadow-outline hover:bg-gray-100",
};
const SidebarTab: FC<{
onClick?: any;
children?: ReactNode;
badge?: ReactNode;
className?: string;
}> = (props) => {
return (
<button
onClick={props.onClick}
className="cursor-pointer inline-flex items-center justify-between w-full px-4 py-2 text-base text-black transition duration-500 ease-in-out transform rounded-lg focus:shadow-outline hover:bg-gray-100"
className={clsx(styleClasses.base, props.className)}
>
<div className="flex items-center">{props.children}</div>
{props.badge}
@ -28,6 +34,7 @@ const SidebarTab: FC<{
};
const Sidebar = () => {
const services = useServicesQuery();
const navigate = useNavigate();
const navFactory = (to: string) => () => {
@ -57,7 +64,9 @@ const Sidebar = () => {
<ul className="space-y-1">
<li>
<SidebarTab
badge={<Badge>12</Badge>}
badge={
<Badge>{(services.data?.services || []).length}</Badge>
}
onClick={navFactory("/services")}
>
<PuzzleIcon className="w-6 h-6 text-gray-700" />
@ -75,10 +84,10 @@ const Sidebar = () => {
</li>
<li>
<SidebarTab
badge={<Badge>0</Badge>}
onClick={navFactory("/middleware")}
badge={<Badge>Soon</Badge>}
className="text-gray-400 cursor-not-allowed"
>
<CubeIcon className="w-6 h-6 text-gray-700" />
<CubeIcon className="w-6 h-6" />
<span className="ml-3">Middleware</span>
</SidebarTab>
</li>
@ -101,7 +110,7 @@ const Sidebar = () => {
</div>
<div className="flex flex-shrink-0 py-4 px-5 justify-between items-center border-t border-gray-200">
<div className="flex flex-row justify-center items-center space-x-4">
<p>nikuu</p>
<p>admin</p>
</div>
<div className="w-6 h-6">
<LogoutIcon

View File

@ -15,6 +15,7 @@ interface TextFieldProps {
inputClassName?: string;
onChange?: React.FormEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
value?: string;
}
const TextField: FC<TextFieldProps> = (props) => {
@ -33,6 +34,7 @@ const TextField: FC<TextFieldProps> = (props) => {
size={props.size}
onChange={props.onChange}
onBlur={props.onBlur}
value={props.value}
/>
</div>
</div>

View File

@ -1,3 +1,4 @@
import { useFormik } from "formik";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
@ -7,18 +8,19 @@ import Button from "../../atoms/Button";
import TextField from "../../molecules/TextField";
const LoginPage = () => {
const {
handleSubmit,
control,
formState: { errors },
} = useForm();
const navigator = useNavigate();
const loginMutation = useLoginMutation();
const [submitError, setSubmitError] = useState("");
const onSubmit = ({ name, password }: any) => {
loginMutation.mutate({ credentials: { name, password } });
};
const loginForm = useFormik({
initialValues: {
name: "",
password: "",
},
onSubmit: ({ name, password }) => {
loginMutation.mutate({ credentials: { name, password } });
},
});
useEffect(() => {
if (!loginMutation.data) return;
@ -48,35 +50,31 @@ const LoginPage = () => {
<div className="py-10">
<form
className="space-y-6"
action="#"
method="POST"
data-bitwarden-watching="1"
onSubmit={handleSubmit(onSubmit)}
onSubmit={loginForm.handleSubmit}
>
<Controller
name="name"
control={control}
defaultValue=""
render={({ field }) => (
<TextField label="Username" placeholder="name" {...field} />
)}
<TextField
label="Username"
placeholder="name"
id="name"
size="lg"
onChange={loginForm.handleChange}
value={loginForm.values.name}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextField
type="password"
label="Password"
placeholder="*****"
{...field}
/>
)}
<TextField
type="password"
id="password"
label="Password"
size="lg"
placeholder="*****"
onChange={loginForm.handleChange}
value={loginForm.values.password}
/>
<div>
<Button type="submit">Login</Button>
<Button type="submit" size="lg">
Login
</Button>
{submitError && <span>{submitError}</span>}
</div>
</form>

View File

@ -1,11 +0,0 @@
import DashboardLayout from "../../layouts/Dashboard";
const MiddlewarePage = () => {
return (
<DashboardLayout>
<div>Middleware</div>
</DashboardLayout>
);
};
export default MiddlewarePage;

View File

@ -1,39 +1,109 @@
import { PlusIcon } from "@heroicons/react/outline";
import clsx from "clsx";
import { useState } from "react";
import useServices from "../../../hooks/useServices";
import { useEffect, useMemo, useState } from "react";
import Button from "../../atoms/Button";
import InlineLink from "../../atoms/InlineLink";
import Container from "../../layouts/Container";
import DashboardLayout from "../../layouts/Dashboard";
import TextField from "../../molecules/TextField";
import {
useServicesQuery,
useRemoveServiceMutation,
useAddServiceMutation,
Service,
} from "../../../generated/graphql";
import { queryClient } from "../../../main";
import { useFormik } from "formik";
const ServicesPage = () => {
const [toggleCreate, setToggleCreate] = useState(false);
const services = useServices();
const [searchQuery, setSearchQuery] = useState("");
const services = useServicesQuery();
const handleToggleCreate = () => {
setToggleCreate(!toggleCreate);
const serviceForm = useFormik({
initialValues: {
name: "",
destination: "",
},
onSubmit: ({ name, destination }, helpers) => {
addService.mutate({ name, loadbalancer: { servers: [destination] } });
helpers.resetForm();
},
});
// TODO: Figure out what I am doing wrong with optimistic updating (removeService and addService)
const removeService = useRemoveServiceMutation({
onMutate: async ({ id }) => {
const queryKey = ["Services"];
await queryClient.cancelQueries(queryKey);
const prevServices = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old) =>
old.services.filter((s: Service) => s.id !== id)
);
return { prevServices };
},
onError: (err, newTodo, ctx) => {
const queryKey = ["Services"];
queryClient.setQueryData(queryKey, ctx.prevServices);
},
onSettled: () => {
const queryKey = ["Services"];
queryClient.invalidateQueries(queryKey);
},
});
const addService = useAddServiceMutation({
onMutate: async (newService) => {
const queryKey = ["Services"];
await queryClient.cancelQueries(queryKey);
const prevServices = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old) => [
...old.services,
newService,
]);
return { prevServices };
},
onError: (err, newTodo, ctx) => {
const queryKey = ["Services"];
queryClient.setQueryData(queryKey, ctx.prevServices);
},
onSettled: () => {
const queryKey = ["Services"];
queryClient.invalidateQueries(queryKey);
},
});
const deleteService = (serviceId: number) => {
return (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
ev.preventDefault();
removeService.mutate({ id: serviceId });
};
};
const filteredServices = useMemo(() => {
if (!services.data?.services) return [];
return services.data.services.filter((service) => {
return service.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}, [services, searchQuery]);
return (
<DashboardLayout>
<Container>
<Container className="px-2">
<h1 className="text-3xl font-bold mt-12 mb-8">Services</h1>
<div className="mb-4 flex items-end justify-between">
<TextField
label="Search"
size="md"
placeholder="Search services"
inputClassName="bg-white border border-gray-300 w-60 shadow-none"
onChange={(ev) => setSearchQuery(ev.target.value)}
/>
<Button
size="md"
className={clsx(
"w-20 border border-sky-600",
toggleCreate && "bg-white text-sky-600 hover:bg-white"
)}
onClick={handleToggleCreate}
onClick={() => setToggleCreate(!toggleCreate)}
>
<div className="flex flex-row space-x-1">
<PlusIcon width={20} />
@ -42,59 +112,72 @@ const ServicesPage = () => {
</Button>
</div>
{toggleCreate && (
<div className="w-full bg-gray-50 border border-gray-200 rounded-lg p-5 mb-4">
<form
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-5 mb-4"
onSubmit={serviceForm.handleSubmit}
>
<h1 className="mb-4">Create a new service</h1>
<div className="space-y-3">
<TextField
label="Name"
required={true}
id="name"
placeholder="service name"
size="md"
inputClassName="shadow-none border border-gray-300 bg-white"
onChange={serviceForm.handleChange}
value={serviceForm.values.name}
/>
<TextField
label="Destination"
required={true}
placeholder="url"
size="md"
id="destination"
inputClassName="shadow-none border border-gray-300 bg-white"
onChange={serviceForm.handleChange}
value={serviceForm.values.destination}
/>
<div className="flex justify-end items-center">
<Button size="md" className="w-20 border border-sky-600">
Create
</Button>
<Button className="w-20 border border-sky-600">Create</Button>
</div>
</div>
</div>
</form>
)}
<div className="rounded-lg border">
<table className="w-full border-collapse">
<div className="rounded-lg border overflow-x-auto">
<table className="w-full max-w-6xl border-collapse table-auto">
<thead className="h-12">
<tr className="text-left font-semibold text-sm border-b bg-gray-50">
<th className="px-3 py-2 lg:w-2/12">Name</th>
<th className="px-3 py-2 lg:w-4/12">Destination</th>
<th className="px-3 py-2 lg:w-5/12">Referenced by</th>
<th className="px-3 py-2 lg:w-1/12">{/* Delete */}</th>
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Destination</th>
<th className="px-3 py-2">Referenced by</th>
<th className="px-3 py-2">{/* Options */}</th>
</tr>
</thead>
<tbody>
{services.data &&
services.data.map((service) => (
<tr key={service.name} className="h-12 border-b ">
<td className="px-3 py-2 truncate">{service.name}</td>
<td className="px-3 py-2 truncate">
<InlineLink href="https://example.com/">
{service.destination[0]}
{filteredServices.map((service) => (
<tr key={service.name} className="h-12 border-b ">
<td className="px-3 py-2 truncate">{service.name}</td>
<td className="px-3 py-2 truncate space-x-1">
{service.loadbalancer.servers.map((server) => (
<InlineLink key={server.id} href={server.address}>
{server.address}
</InlineLink>
</td>
<td className="px-3 py-2 truncate">
<InlineLink href="https://example.com/">
{service.references}
</InlineLink>
</td>
<td className="py-2 cursor-pointer text-sky-900">Delete</td>
</tr>
))}
))}
</td>
<td className="px-3 py-2 truncate">
{/* TODO: Display list of references */}
<p>None</p>
</td>
<td className="py-2">
<button
className="cursor-pointer text-sky-900"
onClick={deleteService(service.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>

View File

View File

@ -1,19 +0,0 @@
import { faker } from "@faker-js/faker";
import { useQuery } from "react-query";
import { Service } from "../types";
let services = Array(11)
.fill(0)
.map(() => ({
name: faker.name.lastName(),
references: [faker.name.firstName()],
destination: [faker.internet.ipv4()],
}));
const fetchServices = async (): Promise<Service[]> => {
return services;
};
export default function useServices() {
return useQuery(["services"], fetchServices);
}

View File

@ -0,0 +1,13 @@
mutation Login($credentials: LoginInput!) {
login(body: $credentials) {
... on AuthSuccess {
user {
name
}
token
}
... on CommonError {
message
}
}
}

View File

@ -1,13 +1,26 @@
mutation Login($credentials: LoginInput!) {
login(body: $credentials) {
... on AuthSuccess {
user {
name
}
token
}
... on CommonError {
message
}
fragment UITableFragment on Service {
id
name
loadbalancer {
servers {
id
address
}
}
}
}
query Services {
services {
...UITableFragment
}
}
mutation AddService($name: String!, $loadbalancer: AddLoadbalancerInput!) {
addService(body:{name: $name, loadbalancer: $loadbalancer}) {
...UITableFragment
}
}
mutation RemoveService($id: Int!) {
removeService(id: $id)
}