Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[generic issuance] pipeline CRUD app with minimal UI #1451

Merged
merged 14 commits into from
Feb 1, 2024
2 changes: 2 additions & 0 deletions apps/generic-issuance-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
"clean": "rm -rf node_modules public/js tsconfig.tsbuildinfo"
},
"dependencies": {
"@pcd/passport-interface": "0.10.0",
"@stytch/react": "^15.0.0",
"@stytch/vanilla-js": "^4.4.2",
"dotenv": "^16.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.9.0",
"styled-components": "^5.3.6"
},
Expand Down
4 changes: 3 additions & 1 deletion apps/generic-issuance-client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { createHashRouter, RouterProvider } from "react-router-dom";
import { GlobalStyle } from "./components/GlobalStyle";
import Dashboard from "./pages/Dashboard";
import Home from "./pages/Home";
import Pipeline from "./pages/Pipeline";

const stytch = new StytchUIClient(process.env.STYTCH_PUBLIC_TOKEN);

const router = createHashRouter([
{ path: "/", element: <Home /> },
{ path: "/dashboard", element: <Dashboard /> }
{ path: "/dashboard", element: <Dashboard /> },
{ path: "/pipelines/:id", element: <Pipeline /> }
]);

createRoot(document.getElementById("root") as HTMLElement).render(
Expand Down
111 changes: 94 additions & 17 deletions apps/generic-issuance-client/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,68 @@
import {
PipelineDefinition,
requestGenericIssuanceGetAllUserPipelines,
requestGenericIssuanceUpsertPipeline
} from "@pcd/passport-interface";
import { useStytch, useStytchUser } from "@stytch/react";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { ZUPASS_SERVER_URL } from "../constants";

const SAMPLE_CREATE_PIPELINE_TEXT = JSON.stringify(
{
type: "Lemonade",
editorUserIds: [],
options: {
lemonadeApiKey: "your-lemonade-api-key",
events: []
}
},
null,
2
);

export default function Dashboard(): ReactNode {
const stytchClient = useStytch();
const { user } = useStytchUser();
const [isLoggingOut, setLoggingOut] = useState(false);
const [userPingMessage, setUserPingMessage] = useState("");
// TODO: After MVP, replace with RTK hooks or a more robust state management.
rrrliu marked this conversation as resolved.
Show resolved Hide resolved
const [pipelines, setPipelines] = useState<PipelineDefinition[]>([]);
const [isCreatingPipeline, setCreatingPipeline] = useState(false);
const [newPipelineRaw, setNewPipelineRaw] = useState(
SAMPLE_CREATE_PIPELINE_TEXT
);
const [error, setError] = useState("");

useEffect(() => {
setUserPingMessage("Pinging server...");
fetch(new URL("generic-issuance/api/user/ping", ZUPASS_SERVER_URL).href, {
credentials: "include"
})
.then((res) => res.json())
.then((message) => setUserPingMessage(`JWT valid, received ${message}.`))
.catch((e) => setUserPingMessage(`Error: ${e}`));
const fetchAllPipelines = useCallback(async () => {
const res =
await requestGenericIssuanceGetAllUserPipelines(ZUPASS_SERVER_URL);
if (res.success) {
setPipelines(res.value);
} else {
// TODO: Better errors
alert(`An error occurred while fetching user pipelines: ${res.error}`);
}
}, []);

useEffect(() => {
fetchAllPipelines();
}, [fetchAllPipelines]);

const createPipeline = async (): Promise<void> => {
if (!newPipelineRaw) return;
const res = await requestGenericIssuanceUpsertPipeline(
ZUPASS_SERVER_URL,
JSON.parse(newPipelineRaw)
);
await fetchAllPipelines();
if (res.success) {
setCreatingPipeline(false);
} else {
// TODO: Better errors
alert(`An error occurred while creating pipeline: ${res.error}`);
}
};

if (!user) {
window.location.href = "/";
}
Expand All @@ -36,20 +80,53 @@ export default function Dashboard(): ReactNode {
<p>
Congrats - you are now logged in as <b>{user.emails?.[0]?.email}.</b>
</p>
{userPingMessage && <p>{userPingMessage}</p>}
<button
onClick={async (): Promise<void> => {
setLoggingOut(true);
try {
await stytchClient.session.revoke();
} catch (e) {
setError(e);
setLoggingOut(false);
if (confirm("Are you sure you want to log out?")) {
setLoggingOut(true);
try {
await stytchClient.session.revoke();
} catch (e) {
setError(e);
setLoggingOut(false);
}
}
}}
>
Log out
</button>

<h2>My Pipelines</h2>
{!pipelines.length && <p>No pipelines right now - go create some!</p>}
{!!pipelines.length && (
<ol>
{pipelines.map((p) => (
<Link to={`/pipelines/${p.id}`}>
<li key={p.id}>
id: {p.id}, type: {p.type}
</li>
</Link>
))}
</ol>
)}
<p>
<button onClick={(): void => setCreatingPipeline((curr) => !curr)}>
{isCreatingPipeline ? "Minimize 🔼" : "Create new pipeline 🔽"}
</button>
{isCreatingPipeline && (
<div>
<textarea
rows={10}
cols={50}
value={newPipelineRaw}
onChange={(e): void => setNewPipelineRaw(e.target.value)}
/>
<div>
<button onClick={createPipeline}>Create new pipeline</button>
</div>
</div>
)}
</p>
</div>
);
}
126 changes: 126 additions & 0 deletions apps/generic-issuance-client/src/pages/Pipeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
PipelineDefinition,
requestGenericIssuanceDeletePipeline,
requestGenericIssuanceGetPipeline,
requestGenericIssuanceUpsertPipeline
} from "@pcd/passport-interface";
import { useStytchUser } from "@stytch/react";
import { ReactNode, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { ZUPASS_SERVER_URL } from "../constants";

function format(obj: object): string {
return JSON.stringify(obj, null, 2);
}

export default function Pipeline(): ReactNode {
const params = useParams();
const { user } = useStytchUser();
const { id } = params;
// TODO: After MVP, replace with RTK hooks or a more robust state management.
const [savedPipeline, setSavedPipeline] = useState<PipelineDefinition>();
const [textareaValue, setTextareaValue] = useState("");
const [queryLoading, setQueryLoading] = useState(true);
const [saveLoading, setSaveLoading] = useState(false);
const [error, setError] = useState("");

async function savePipeline(): Promise<void> {
setSaveLoading(true);
const res = await requestGenericIssuanceUpsertPipeline(
ZUPASS_SERVER_URL,
JSON.parse(textareaValue)
);
if (res.success) {
setSavedPipeline(res.value);
setTextareaValue(format(res.value));
setError("");
} else {
setError(`An error occured while saving: ${res.error}`);
}
setSaveLoading(false);
}

async function deletePipeline(): Promise<void> {
if (confirm("Are you sure you would like to delete this pipeline?")) {
const res = await requestGenericIssuanceDeletePipeline(
ZUPASS_SERVER_URL,
id
);
if (res.success) {
window.location.href = "/#/dashboard";
} else {
setError(`An error occured while deleting: ${res.error}`);
}
}
}

useEffect(() => {
async function fetchPipeline(): Promise<void> {
const res = await requestGenericIssuanceGetPipeline(
ZUPASS_SERVER_URL,
id
);
if (res.success) {
setSavedPipeline(res.value);
setTextareaValue(format(res.value));
setError("");
} else {
setError(
`This pipeline "${id}" is invalid or you do not have access to this pipeline.`
);
setSavedPipeline(undefined);
}
setQueryLoading(false);
}
fetchPipeline();
}, [id]);

if (!user) {
window.location.href = "/";
}

if (queryLoading) {
return <div>Loading...</div>;
}

const hasEdits = format(savedPipeline) !== textareaValue;

return (
<div>
{savedPipeline && (
<>
<p>
<textarea
cols={50}
rows={30}
value={textareaValue}
onChange={(e): void => setTextareaValue(e.target.value)}
/>
</p>
<p>
{hasEdits && (
<button disabled={saveLoading} onClick={savePipeline}>
{saveLoading ? "Saving..." : "Save changes"}
</button>
)}
{!hasEdits && <button disabled>All changes saved ✅</button>}
</p>
<p>
<button onClick={deletePipeline}>Delete pipeline</button>
</p>
</>
)}
{error && (
<p>
<strong>Error: </strong>
{error}
</p>
)}
<p>
<Link to="/dashboard">
<button>Return to all pipelines</button>
</Link>
</p>
</div>
);
}
19 changes: 16 additions & 3 deletions apps/generic-issuance-client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@
"resolveJsonModule": true,
"downlevelIteration": true,
"jsx": "react-jsx",
"lib": ["ES2015", "DOM"],
"lib": [
"ES2015",
"DOM"
],
"esModuleInterop": true,
// To allow for mocha and jest to work together:
// https://stackoverflow.com/a/65568463
"skipLibCheck": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
],
"references": [
{
"path": "../../packages/lib/passport-interface"
}
]
}
2 changes: 2 additions & 0 deletions apps/passport-server/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import process from "node:process";
import * as path from "path";
import urljoin from "url-join";
import { MockPipelineAtomDB } from "../test/generic-issuance/MockPipelineAtomDB";
import { MockPipelineUserDB } from "../test/generic-issuance/MockPipelineUserDB";
import { getDevconnectPretixAPI } from "./apis/devconnect/devconnectPretixAPI";
import { IEmailAPI, sendgridSendEmail } from "./apis/emailAPI";
import { getHoneycombAPI } from "./apis/honeycombAPI";
Expand Down Expand Up @@ -51,6 +52,7 @@ export async function startApplication(
publicResourcesDir: path.join(process.cwd(), "public"),
gitCommitHash: await getCommitHash(),
// TODO: remove these once we have settled on a db schema for these
pipelineUserDB: new MockPipelineUserDB(),
pipelineAtomDB: new MockPipelineAtomDB()
};

Expand Down
23 changes: 19 additions & 4 deletions apps/passport-server/src/database/queries/pipelineDefinitionDB.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { PipelineDefinition, PipelineType } from "@pcd/passport-interface";
import _ from "lodash";
import { Pool, PoolClient } from "postgres-pool";
import {
PipelineDefinition,
PipelineType
} from "../../services/generic-issuance/pipelines/types";
import { GenericIssuancePipelineRow } from "../models";
import { sqlQuery, sqlTransaction } from "../sqlQuery";

Expand All @@ -16,6 +13,7 @@ import { sqlQuery, sqlTransaction } from "../sqlQuery";
*/
export interface IPipelineDefinitionDB {
loadPipelineDefinitions(): Promise<PipelineDefinition[]>;
clearDefinition(definitionID: string): Promise<void>;
clearAllDefinitions(): Promise<void>;
getDefinition(definitionID: string): Promise<PipelineDefinition | undefined>;
setDefinition(definition: PipelineDefinition): Promise<void>;
Expand Down Expand Up @@ -63,6 +61,23 @@ export class PipelineDefinitionDB implements IPipelineDefinitionDB {
await sqlQuery(this.db, "DELETE FROM generic_issuance_pipelines");
}

public async clearDefinition(definitionID: string): Promise<void> {
await sqlTransaction(
this.db,
"Delete pipeline definition",
async (client) => {
await client.query(
"DELETE FROM generic_issuance_pipeline_editors WHERE pipeline_id = $1",
[definitionID]
);
await client.query(
"DELETE FROM generic_issuance_pipelines WHERE id = $1",
[definitionID]
);
}
);
}

public async getDefinition(
definitionID: string
): Promise<PipelineDefinition | undefined> {
Expand Down
Loading
Loading