From 9204c1d33288836eafbb44510e6c199e56f8bdf6 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Mon, 2 Sep 2024 20:36:06 -0600 Subject: [PATCH] [FEATURE] New Project Display --- lib/database/tables/ProjectTableService.ts | 4 ++ lib/modules/projects/project.controller.ts | 11 ++++ lib/modules/projects/project.router.ts | 2 + lib/types/contracts/project.contracts.ts | 26 +++++++-- lib/vix/ClientErrors.ts | 1 + package-lock.json | 29 +++++++++- package.json | 3 +- src/App.tsx | 19 ++++--- src/Portal.tsx | 2 +- src/components/projects/ProjectTile.tsx | 16 ++++++ src/ctx/AuthContext.tsx | 6 +-- src/ctx/ProjectContext.tsx | 29 ++++++++++ src/hooks/init-hooks.ts | 30 ++++++++++- src/util/api/GeneratedRequests.ts | 27 +++++----- src/util/api/queries.ts | 15 ++++++ src/util/api/requests.ts | 4 +- src/views/AuthenticateView.tsx | 6 +-- src/views/ProjectView.tsx | 62 +++++++++++++++++++--- 18 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 src/components/projects/ProjectTile.tsx create mode 100644 src/ctx/ProjectContext.tsx create mode 100644 src/util/api/queries.ts diff --git a/lib/database/tables/ProjectTableService.ts b/lib/database/tables/ProjectTableService.ts index 0120d11..1a60a72 100644 --- a/lib/database/tables/ProjectTableService.ts +++ b/lib/database/tables/ProjectTableService.ts @@ -31,4 +31,8 @@ export default class ProjectTableService extends TableService { throw ProjectErrors.ConflictNonUnique; }); } + + async listChildren(parentProjectId: string) { + return this.pg.project.findMany({ where: { parentProject: parentProjectId } }); + } } diff --git a/lib/modules/projects/project.controller.ts b/lib/modules/projects/project.controller.ts index 5cd57cc..c319265 100644 --- a/lib/modules/projects/project.controller.ts +++ b/lib/modules/projects/project.controller.ts @@ -18,6 +18,8 @@ type CreateCRC = ContractRouteContext<{ RequestParamsContract: typeof ProjectContract.ProjectParams; }>; +type ListCRC = ContractRouteContext<{ RequestParamsContract: typeof ProjectContract.ProjectParams }>; + export default class ProjectController extends VixpressController { declare pg: PostgresService; constructor(app: Express) { @@ -45,4 +47,13 @@ export default class ProjectController extends VixpressController { const publicKey = await decrypt(kp.encryptedPublicKey, config.SigningOptions.Keys.KeyPair); return { user: usr, project: proj, publicKey }; } + + async list(crc: any): Promise { + const { project: parentProject } = crc.req as UserRequest; + const projectPromise = this.pg.project.byId(parentProject.id); + const childProjectsPromise = this.pg.project.listChildren(parentProject.id); + const [project, childProjects] = await Promise.all([projectPromise, childProjectsPromise]); + if (!project) throw ProjectErrors.NotFoundProject; + return { project, childProjects }; + } } diff --git a/lib/modules/projects/project.router.ts b/lib/modules/projects/project.router.ts index 1b71f84..98514d4 100644 --- a/lib/modules/projects/project.router.ts +++ b/lib/modules/projects/project.router.ts @@ -14,9 +14,11 @@ export class ProjectRoute extends VixpressRoute { // Configuration const projCreate = { ...cBase, reqBody: ProjectContract.Create }; + const projList = { reqParams: ProjectContract.ProjectParams, resBody: ProjectContract.ListProjects }; // Middleware // Routes router.post("/create", RouteGuard.MangeProjectsCreate, contract(projController.create, projCreate)); + router.get("/list", RouteGuard.ManageProjectsRead, contract(projController.list, projList)); } } diff --git a/lib/types/contracts/project.contracts.ts b/lib/types/contracts/project.contracts.ts index 46c430b..189a189 100644 --- a/lib/types/contracts/project.contracts.ts +++ b/lib/types/contracts/project.contracts.ts @@ -3,14 +3,30 @@ import * as y from "yup"; import { DatabaseContract } from "./database.contracts"; // ====================================== Reused Contracts ====================================== +const Project = y.object({ + id: y.string().required(), + slug: y.string().required(), + parentProject: y.string().required(), + name: y.string().nullable(), +}); + // ====================================== Response Contracts ====================================== export const ProjectContractRes = defineContractExport("CProjectContractRes", { - CreateResponse: y.object({ - project: DatabaseContract.Project, - user: DatabaseContract.User, - publicKey: y.string().required(), - }), + CreateResponse: y + .object({ + project: DatabaseContract.Project, + user: DatabaseContract.User, + publicKey: y.string().required(), + }) + .required(), + Project: Project.required(), + ListProjects: y + .object({ + project: Project.required(), + childProjects: y.array(Project).required(), + }) + .required(), }); // ====================================== Request Contracts ====================================== diff --git a/lib/vix/ClientErrors.ts b/lib/vix/ClientErrors.ts index bedbc6b..1937677 100644 --- a/lib/vix/ClientErrors.ts +++ b/lib/vix/ClientErrors.ts @@ -18,6 +18,7 @@ export class ProjectErrors { static readonly BadRequestProjectIncomplete = new ClientError(400, "Project incomplete!"); static readonly UnexpectedRootUserError = new ClientError(500, "Error creating root user!"); static readonly ConflictNonUnique = new ClientError(409, "Slug already taken!"); + static readonly NotFoundProject = new ClientError(404, "Project not found!"); } export class KeyPairErrors { diff --git a/package-lock.json b/package-lock.json index ba1b6bc..b07aba8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cairo", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cairo", - "version": "0.0.3", + "version": "0.0.4", "license": "LGPL-2.1", "dependencies": { "@chakra-ui/react": "^2.8.2", @@ -16,6 +16,7 @@ "@mui/material": "^5.16.7", "@prisma/client": "^5.18.0", "@sendgrid/mail": "^8.1.3", + "@tanstack/react-query": "^5.53.3", "bcrypt": "^5.1.1", "cron": "^3.1.7", "dotenv": "^16.4.5", @@ -2709,6 +2710,30 @@ "node": ">=12.*" } }, + "node_modules/@tanstack/query-core": { + "version": "5.53.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.53.3.tgz", + "integrity": "sha512-ZfjAgd7NpqDx0e4aYBt7EmS2enbulPrJwowTy+mayRE93WUUH+sIYHun1TdRjpGwDPMNNZ5D6goh7n3CwoO+HA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.53.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.53.3.tgz", + "integrity": "sha512-286mN/91CeM7vC6CZFLKYDHSw+WyMX6ekIvzoTbpM4xyPb99VSyCKPLyPgaOatKqYm6ooMBquSq9NGRdKgsJfg==", + "dependencies": { + "@tanstack/query-core": "5.53.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 23ddb89..270ecc0 100644 --- a/package.json +++ b/package.json @@ -45,13 +45,14 @@ "vite-tsconfig-paths": "^5.0.1" }, "dependencies": { - "@dunemask/vix": "^0.0.1-alpha.0", "@chakra-ui/react": "^2.8.2", + "@dunemask/vix": "^0.0.1-alpha.0", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@mui/material": "^5.16.7", "@prisma/client": "^5.18.0", "@sendgrid/mail": "^8.1.3", + "@tanstack/react-query": "^5.53.3", "bcrypt": "^5.1.1", "cron": "^3.1.7", "dotenv": "^16.4.5", diff --git a/src/App.tsx b/src/App.tsx index 96331c8..942ef4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,18 +4,25 @@ import { ChakraProvider } from "@chakra-ui/react"; import useInitHooks from "@src/hooks/init-hooks"; import theme from "@src/util/theme"; import { AuthProvider } from "@src/ctx/AuthContext"; -import "react-toastify/dist/ReactToastify.css"; import Viewport from "./Viewport"; +import { ProjectProvider } from "./ctx/ProjectContext"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "react-toastify/dist/ReactToastify.css"; export default function App() { + const qc = new QueryClient(); return ( - - - - - + + + + + + + + + ); diff --git a/src/Portal.tsx b/src/Portal.tsx index 514254c..f88d732 100644 --- a/src/Portal.tsx +++ b/src/Portal.tsx @@ -19,7 +19,7 @@ declare type Portal = { path: Links; view: ReactNode }; function Auth(props: { view: (() => ReactNode) | LazyExoticComponent<() => ReactNode> }) { const { auth, loading, initialized } = useAuth(); const Component = props.view; - if (!loading && !initialized && !!auth) return ; + if (!loading && initialized && !!auth) return ; return ; } diff --git a/src/components/projects/ProjectTile.tsx b/src/components/projects/ProjectTile.tsx new file mode 100644 index 0000000..9bcb771 --- /dev/null +++ b/src/components/projects/ProjectTile.tsx @@ -0,0 +1,16 @@ +import { Box, Flex, Skeleton, Text } from "@chakra-ui/react"; +import { CProjectContract } from "@lib/contracts/project.contracts"; + +export default function ProjectTile(props: { project: CProjectContract["Project"] }) { + return ( + + {props.project.name ?? props.project.slug} + + ); +} + +export function ProjectTileSkeleton() { + return ( + + ); +} diff --git a/src/ctx/AuthContext.tsx b/src/ctx/AuthContext.tsx index cc8dc11..45b82bb 100644 --- a/src/ctx/AuthContext.tsx +++ b/src/ctx/AuthContext.tsx @@ -4,8 +4,8 @@ import { Policy, PolicyString } from "@lib/Policies"; import { Resource } from "@lib/vix/AppResources"; import { CDatabaseContract } from "@lib/contracts/database.contracts"; import { useSearchParams } from "react-router-dom"; +import { PROJECT_CONTEXT_INITIAL_STATE } from "./ProjectContext"; -const project = import.meta.env.VITE_CAIRO_PROJECT as string; const credentialApiPath = `auth/credentials`; export enum AuthStorageKeys { @@ -154,7 +154,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { async function getPolicies(token: string): Promise { const extraHeaders = { Authorization: `Bearer ${token}` }; - const projectId = !!search.get("projectId") ? search.get("projectId") : project; + const projectId = !!search.get("projectId") ? search.get("projectId") : PROJECT_CONTEXT_INITIAL_STATE.projectId; const apiPath = `/${projectId}/${credentialApiPath}`; const credentials = await apiRequest({ subpath: apiPath, jsonify: true, extraHeaders }); if (!credentials) return []; @@ -183,7 +183,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { async function fetchCredentials(token: string): Promise<[user: CDatabaseContract["User"], rp: Policy[]]> { const extraHeaders = getAuthHeader(token); - const projectId = !!search.get("projectId") ? search.get("projectId") : project; + const projectId = !!search.get("projectId") ? search.get("projectId") : PROJECT_CONTEXT_INITIAL_STATE.projectId; const apiPath = `/${projectId}/${credentialApiPath}`; const credentials = await apiRequest({ subpath: apiPath, jsonify: true, extraHeaders }); if (!credentials) throw Error("Could not authenticate!"); diff --git a/src/ctx/ProjectContext.tsx b/src/ctx/ProjectContext.tsx new file mode 100644 index 0000000..b8633b1 --- /dev/null +++ b/src/ctx/ProjectContext.tsx @@ -0,0 +1,29 @@ +import { createContext, ReactNode, useContext, useState } from "react"; + +interface ProjectState { + projectId: string; +} + +interface ProjectContextType extends ProjectState { + setProjectId: (v: string) => void; +} + +export const PROJECT_CONTEXT_INITIAL_STATE: ProjectContextType = { + projectId: import.meta.env.VITE_CAIRO_PROJECT as string, + setProjectId: () => void 0, +}; + +const ProjectContext = createContext(PROJECT_CONTEXT_INITIAL_STATE); + +export const useProjectContext = () => useContext(ProjectContext); + +export const ProjectProvider = ({ children }: { children: ReactNode }) => { + const [projectId, setProjectId] = useState(PROJECT_CONTEXT_INITIAL_STATE.projectId); + + const context: ProjectContextType = { + projectId, + setProjectId, + }; + + return {children}; +}; diff --git a/src/hooks/init-hooks.ts b/src/hooks/init-hooks.ts index de55182..8dfdbd2 100644 --- a/src/hooks/init-hooks.ts +++ b/src/hooks/init-hooks.ts @@ -1,9 +1,37 @@ import { useAuth } from "@src/ctx/AuthContext"; +import { PROJECT_CONTEXT_INITIAL_STATE, useProjectContext } from "@src/ctx/ProjectContext"; import { useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; export default function useInitHooks() { - const { authInit } = useAuth(); + const { authInit, auth } = useAuth(); + const { setProjectId } = useProjectContext(); + const [search] = useSearchParams(); + useEffect(function initHooks() { authInit(); }, []); + + useEffect( + function authInitHooks() { + if (!auth) return; + }, + [auth], + ); + + useEffect( + function paramHooks() { + projectIdParamHook(); + }, + [search], + ); + + async function nonAuthInitHooks() {} + + async function authInitHooks() {} + + function projectIdParamHook() { + const projectId = !!search.get("projectId") ? (search.get("projectId") as string) : PROJECT_CONTEXT_INITIAL_STATE.projectId; + setProjectId(projectId); + } } diff --git a/src/util/api/GeneratedRequests.ts b/src/util/api/GeneratedRequests.ts index e080a77..9f6d109 100644 --- a/src/util/api/GeneratedRequests.ts +++ b/src/util/api/GeneratedRequests.ts @@ -3,20 +3,17 @@ import { CAuthContract, CProjectContract } from "@vix/ContractTypes"; import { apiRequest } from "@dunemask/vix/bridge"; import { authenticatedApiRequest } from "./requests"; -export const getProjectAuthVerify = (project: string) => - authenticatedApiRequest({ subpath: `/${project}/auth/verify`, method: "GET", jsonify: true }); + export const getProjectAuthVerify = (project: string) => + authenticatedApiRequest({subpath: `/${project}/auth/verify`, method:"GET", jsonify: true}); -export const postProjectAuthLogin = (project: string, login: CAuthContract["Login"]) => - apiRequest({ - subpath: `/${project}/auth/login`, - method: "POST", - json: login, - jsonify: true, - }); + export const postProjectAuthLogin = (project: string,login: CAuthContract["Login"]) => + apiRequest({subpath: `/${project}/auth/login`, method:"POST", json: login, jsonify: true}); -export const getProjectAuthCredentials = (project: string) => - authenticatedApiRequest({ - subpath: `/${project}/auth/credentials`, - method: "GET", - jsonify: true, - }); + export const getProjectAuthCredentials = (project: string) => + authenticatedApiRequest({subpath: `/${project}/auth/credentials`, method:"GET", jsonify: true}); + + export const postProjectCreate = (project: string,create: CProjectContract["Create"]) => + authenticatedApiRequest({subpath: `/${project}/create`, method:"POST", json: create, jsonify: true}); + + export const getProjectList = (project: string) => + authenticatedApiRequest({subpath: `/${project}/list`, method:"GET", jsonify: true}); \ No newline at end of file diff --git a/src/util/api/queries.ts b/src/util/api/queries.ts new file mode 100644 index 0000000..7f63e59 --- /dev/null +++ b/src/util/api/queries.ts @@ -0,0 +1,15 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getProjectList } from "./GeneratedRequests"; + +export enum QueryKeys { + ProjectList = "ProjectList", +} + +export const useMutator = (apiRequest: (...args: any) => Promise, invalidate: string[]) => { + const qc = useQueryClient(); + const mutate = async (...args: any) => apiRequest(...args).then(() => qc.invalidateQueries({ queryKey: invalidate })); + return mutate; +}; + +export const useProjectList = (projectId: string) => + useQuery({ queryKey: [QueryKeys.ProjectList], queryFn: () => getProjectList(projectId) }); diff --git a/src/util/api/requests.ts b/src/util/api/requests.ts index 02ecff2..2ef6190 100644 --- a/src/util/api/requests.ts +++ b/src/util/api/requests.ts @@ -1,9 +1,9 @@ import { apiRequest, ApiRequestArgs } from "@dunemask/vix/bridge"; -import { getAuthHeader } from "@src/ctx/AuthContext"; +import { getActiveUserToken, getAuthHeader } from "@src/ctx/AuthContext"; export async function authenticatedApiRequest(apiRequestArgs: ApiRequestArgs): Promise { const extraHeaders = apiRequestArgs.extraHeaders ?? {}; - const authHeaders = getAuthHeader(); + const authHeaders = getAuthHeader(getActiveUserToken()); apiRequestArgs.extraHeaders = { ...extraHeaders, ...authHeaders }; return apiRequest(apiRequestArgs); } diff --git a/src/views/AuthenticateView.tsx b/src/views/AuthenticateView.tsx index 3cf3425..850a452 100644 --- a/src/views/AuthenticateView.tsx +++ b/src/views/AuthenticateView.tsx @@ -11,16 +11,15 @@ import { PasswordInput } from "@src/components/common/Inputs"; import { ResourcePolicyType } from "@dunemask/vix/util"; import { postProjectAuthLogin } from "@src/util/api/GeneratedRequests"; import { Navigate, useSearchParams } from "react-router-dom"; - -const project = import.meta.env.VITE_CAIRO_PROJECT as string; +import { useProjectContext } from "@src/ctx/ProjectContext"; export default function AuthenticateView() { const { auth, login } = useAuth(); + const { projectId } = useProjectContext(); const autoRedirect = useAutoRedirect(); const [search] = useSearchParams(); const [identity, setIdentity] = useState(""); const [password, setPassword] = useState(""); - const projectId = search.get("projectId") ?? project; const identityChange = (e: SyntheticEvent) => setIdentity((e.target as HTMLInputElement).value); @@ -52,6 +51,7 @@ export default function AuthenticateView() { function detectEnter(e: KeyboardEvent) { if (e.key === "Enter") submitCredentials(); } + if (auth) return ; return ( diff --git a/src/views/ProjectView.tsx b/src/views/ProjectView.tsx index 1d2a608..f1e954c 100644 --- a/src/views/ProjectView.tsx +++ b/src/views/ProjectView.tsx @@ -1,11 +1,61 @@ -import { Flex, Text, Center } from "@chakra-ui/react"; +import { Flex, Text, Center, Box, Skeleton, Stack } from "@chakra-ui/react"; +import ProjectTile, { ProjectTileSkeleton } from "@src/components/projects/ProjectTile"; +import { useProjectContext } from "@src/ctx/ProjectContext"; +import { useProjectList } from "@src/util/api/queries"; +import { GuardedContent } from "@src/util/guards"; +import { ReactNode } from "react"; + +const skeletonCount = 12; +const loadingFallbacks: ReactNode[] = new Array(skeletonCount) + .fill(0) + .map((_v: number, i: number) => ); export default function ProjectView() { + const { projectId } = useProjectContext(); + const { data, isLoading } = useProjectList(projectId); + const projects = (data?.childProjects ?? []).filter((p) => p.id !== data?.project.id); return ( - -
- Project Management -
-
+ + + + + Project + +
+ {isLoading && } + {!!data?.project && } +
+
+ + 0}> + + + Child Projects + +
+ {isLoading && loadingFallbacks} + {projects.map((p) => ( + + ))} +
+
+
+ + + +
+ + + No child projects found! + + + Create one by clicking here + + +
+
+
+
+
); } -- 2.47.2