[FEATURE] New Project Display (#1)
All checks were successful
S3 Repo Backup / s3-repo-backup (push) Successful in 45s
Deploy Edge / deploy-edge (push) Successful in 3m55s

Reviewed-on: https://forgejo.dunemask.dev///elysium/cairo/pulls/1
Co-authored-by: Dunemask <dunemask@gmail.com>
Co-committed-by: Dunemask <dunemask@gmail.com>
This commit is contained in:
Dunemask 2024-09-03 02:35:38 +00:00 committed by dunemask
parent c50c4ef647
commit a293eadbde
18 changed files with 247 additions and 45 deletions

View file

@ -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 } });
}
}

View file

@ -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<CProjectContract["ListProjects"]> {
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 };
}
}

View file

@ -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));
}
}

View file

@ -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 ======================================

View file

@ -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 {

29
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 (
<ChakraProvider theme={theme}>
<BrowserRouter>
<AuthProvider>
<InitProvider>
<Viewport />
</InitProvider>
</AuthProvider>
<QueryClientProvider client={qc}>
<AuthProvider>
<ProjectProvider>
<InitProvider>
<Viewport />
</InitProvider>
</ProjectProvider>
</AuthProvider>
</QueryClientProvider>
</BrowserRouter>
</ChakraProvider>
);

View file

@ -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 <Component />;
if (!loading && initialized && !!auth) return <Component />;
return <AutoRedirect />;
}

View file

@ -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 (
<Box w="210px" h="210px" bg="background.paper" _hover={{ opacity: ".9", cursor: "pointer" }} borderRadius=".5rem">
<Text>{props.project.name ?? props.project.slug}</Text>
</Box>
);
}
export function ProjectTileSkeleton() {
return (
<Skeleton w="210px" h="210px" bg="background.paper" _hover={{ opacity: ".9", cursor: "pointer" }} borderRadius=".5rem" />
);
}

View file

@ -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<Policy[]> {
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!");

View file

@ -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<ProjectContextType>(PROJECT_CONTEXT_INITIAL_STATE);
export const useProjectContext = () => useContext<ProjectContextType>(ProjectContext);
export const ProjectProvider = ({ children }: { children: ReactNode }) => {
const [projectId, setProjectId] = useState<string>(PROJECT_CONTEXT_INITIAL_STATE.projectId);
const context: ProjectContextType = {
projectId,
setProjectId,
};
return <ProjectContext.Provider value={context}>{children}</ProjectContext.Provider>;
};

View file

@ -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);
}
}

View file

@ -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<CAuthContract["LoginCredentials"]>({
subpath: `/${project}/auth/login`,
method: "POST",
json: login,
jsonify: true,
});
export const postProjectAuthLogin = (project: string,login: CAuthContract["Login"]) =>
apiRequest<CAuthContract["LoginCredentials"]>({subpath: `/${project}/auth/login`, method:"POST", json: login, jsonify: true});
export const getProjectAuthCredentials = (project: string) =>
authenticatedApiRequest<CAuthContract["Credentials"]>({
subpath: `/${project}/auth/credentials`,
method: "GET",
jsonify: true,
});
export const getProjectAuthCredentials = (project: string) =>
authenticatedApiRequest<CAuthContract["Credentials"]>({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<CProjectContract["ListProjects"]>({subpath: `/${project}/list`, method:"GET", jsonify: true});

15
src/util/api/queries.ts Normal file
View file

@ -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<any>, 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) });

View file

@ -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<K = any>(apiRequestArgs: ApiRequestArgs): Promise<K> {
const extraHeaders = apiRequestArgs.extraHeaders ?? {};
const authHeaders = getAuthHeader();
const authHeaders = getAuthHeader(getActiveUserToken());
apiRequestArgs.extraHeaders = { ...extraHeaders, ...authHeaders };
return apiRequest(apiRequestArgs);
}

View file

@ -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<string>("");
const [password, setPassword] = useState<string>("");
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 <Navigate to={rootLink(Links.AutoRedirect)} />;
return (
<Box width="100%" height="90vh" display="flex" alignItems="center" justifyContent="center">

View file

@ -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) => <ProjectTileSkeleton key={i} />);
export default function ProjectView() {
const { projectId } = useProjectContext();
const { data, isLoading } = useProjectList(projectId);
const projects = (data?.childProjects ?? []).filter((p) => p.id !== data?.project.id);
return (
<Flex h="100vh" w="100%">
<Center w="100%">
<Text>Project Management</Text>
</Center>
</Flex>
<Box h="calc(100vh - 1.5rem)" w="100%" overflowY={isLoading ? "hidden" : "unset"}>
<Flex wrap="wrap" m="1.5rem" gap="2rem">
<Flex wrap="wrap" w="100%">
<Text fontSize="18px" h="2rem" w="100%" fontWeight={800}>
Project
</Text>
<Center w="100%">
{isLoading && <ProjectTileSkeleton />}
{!!data?.project && <ProjectTile project={data?.project} />}
</Center>
</Flex>
<GuardedContent guard={projects.length > 0}>
<Flex wrap="wrap" w="100%">
<Text fontSize="18px" h="2rem" w="100%" fontWeight={800}>
Child Projects
</Text>
<Center gap="2rem" flexWrap="wrap" w="100%">
{isLoading && loadingFallbacks}
{projects.map((p) => (
<ProjectTile key={p.id} project={p} />
))}
</Center>
</Flex>
</GuardedContent>
<GuardedContent guard={projects.length === 0}>
<Flex h="40vh" w="100%">
<Center w="100%" flexWrap="wrap">
<Box bg="background.paper" p="1.75rem" borderRadius=".5rem">
<Text w="100%" textAlign="center">
No child projects found!
</Text>
<Text w="100%" textAlign="center">
Create one by clicking here
</Text>
</Box>
</Center>
</Flex>
</GuardedContent>
</Flex>
</Box>
);
}