Skip to content

Commit

Permalink
Merge pull request #978 from thunderstore-io/approval-panel
Browse files Browse the repository at this point in the history
Implement package review panel UI for community moderators
  • Loading branch information
MythicManiac authored Dec 22, 2023
2 parents 12ff36c + f46cfa8 commit f9759c9
Show file tree
Hide file tree
Showing 27 changed files with 727 additions and 159 deletions.
16 changes: 16 additions & 0 deletions builder/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ class ExperimentalApiImpl extends ThunderstoreApi {
return (await response.json()) as UpdatePackageListingResponse;
};

approvePackageListing = async (props: { packageListingId: string }) => {
await this.post(ApiUrls.approvePackageListing(props.packageListingId));
};

rejectPackageListing = async (props: {
packageListingId: string;
data: {
rejection_reason: string;
};
}) => {
await this.post(
ApiUrls.rejectPackageListing(props.packageListingId),
props.data
);
};

listCommunities = async () => {
const response = await this.get(ApiUrls.listCommunities());
return (await response.json()) as PaginatedResult<Community>;
Expand Down
2 changes: 2 additions & 0 deletions builder/src/api/models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type ReviewStatus = "unreviewed" | "approved" | "rejected";

export type JSONValue =
| string
| number
Expand Down
4 changes: 4 additions & 0 deletions builder/src/api/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export class ApiUrls {
apiUrl("submission", "validate", "manifest-v1");
static updatePackageListing = (packageListingId: string) =>
apiUrl("package-listing", packageListingId, "update");
static approvePackageListing = (packageListingId: string) =>
apiUrl("package-listing", packageListingId, "approve");
static rejectPackageListing = (packageListingId: string) =>
apiUrl("package-listing", packageListingId, "reject");
static packageWiki = (namespace: string, name: string) =>
apiUrl("package", namespace, name, "wiki");
}
7 changes: 2 additions & 5 deletions builder/src/components/PackageManagement/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import React, { CSSProperties } from "react";
import { useManagementContext } from "./Context";
import {
PackageListingUpdateForm,
useOnEscape,
usePackageListingUpdateForm,
} from "./hooks";
import { PackageListingUpdateForm, usePackageListingUpdateForm } from "./hooks";
import { PackageStatus } from "./PackageStatus";
import { CategoriesSelect } from "./CategoriesSelect";
import { DeprecationForm } from "./Deprecation";
import { useOnEscape } from "../common/useOnEscape";

const Header: React.FC = () => {
const context = useManagementContext();
Expand Down
14 changes: 1 addition & 13 deletions builder/src/components/PackageManagement/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useForm } from "react-hook-form";
import { ExperimentalApi, UpdatePackageListingResponse } from "../../api";
import * as Sentry from "@sentry/react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Control } from "react-hook-form/dist/types";

type Status = undefined | "SUBMITTING" | "SUCCESS" | "ERROR";
Expand Down Expand Up @@ -49,15 +49,3 @@ export const usePackageListingUpdateForm = (
status,
};
};

export const useOnEscape = (onEscape: () => void) => {
const handleEvent = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscape();
}
};
useEffect(() => {
document.addEventListener("keydown", handleEvent);
return () => document.removeEventListener("keydown", handleEvent);
}, [handleEvent]);
};
40 changes: 40 additions & 0 deletions builder/src/components/PackageReview/Context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ReviewStatus } from "../../api";
import React, { PropsWithChildren, useContext } from "react";

export type ContextProps = {
reviewStatus: ReviewStatus;
rejectionReason: string;
packageListingId: string;
};

export interface IReviewContext {
props: ContextProps;
closeModal: () => void;
}

export interface ManagementContextProviderProps {
initial: ContextProps;
closeModal: () => void;
}

export const ReviewContextProvider: React.FC<
PropsWithChildren<ManagementContextProviderProps>
> = ({ children, initial, closeModal }) => {
return (
<ReviewContext.Provider
value={{
props: initial,
closeModal,
}}
>
{children}
</ReviewContext.Provider>
);
};
export const ReviewContext = React.createContext<IReviewContext | undefined>(
undefined
);

export const useReviewContext = (): IReviewContext => {
return useContext(ReviewContext)!;
};
135 changes: 135 additions & 0 deletions builder/src/components/PackageReview/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React, { CSSProperties } from "react";
import { useReviewContext } from "./Context";
import { PackageListingReviewForm, usePackageReviewForm } from "./useForm";
import { ReviewStatusDisplay } from "./ReviewStatus";
import { useOnEscape } from "../common/useOnEscape";

const Header: React.FC = () => {
const context = useReviewContext();

return (
<div className="modal-header">
<div className="modal-title">Review Package</div>
<button
type="button"
className="close"
aria-label="Close"
onClick={context.closeModal}
ref={(element) => element?.focus()}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
);
};

interface BodyProps {
form: PackageListingReviewForm;
}

const Body: React.FC<BodyProps> = (props) => {
const context = useReviewContext();
const state = context.props;

return (
<div className="modal-body">
<div className="alert alert-primary">
Changes might take several minutes to show publicly! Info shown
below is always up to date.
</div>
<form
onSubmit={(e) => {
e.preventDefault();
}}
>
<div className="mt-3">
<h6>Review Status</h6>
<ReviewStatusDisplay reviewStatus={state.reviewStatus} />
</div>
<div className="mt-3">
<h6>Rejection reason (saved on reject)</h6>
<textarea
{...props.form.control.register("rejectionReason")}
className={"code-input"}
style={{ minHeight: "100px" }}
/>
</div>
</form>
{props.form.error && (
<div className={"alert alert-danger mt-2 mb-0"}>
<p className={"mb-0"}>{props.form.error}</p>
</div>
)}
{props.form.status === "SUBMITTING" && (
<div className={"alert alert-warning mt-2 mb-0"}>
<p className={"mb-0"}>Saving...</p>
</div>
)}
{props.form.status === "SUCCESS" && (
<div className={"alert alert-success mt-2 mb-0"}>
<p className={"mb-0"}>Changes saved successfully!</p>
</div>
)}
</div>
);
};

interface FooterProps {
form: PackageListingReviewForm;
}

const Footer: React.FC<FooterProps> = (props) => {
return (
<div className="modal-footer d-flex justify-content-between">
<button
type="button"
className="btn btn-danger"
disabled={props.form.status === "SUBMITTING"}
onClick={props.form.reject}
>
Reject
</button>
<button
type="button"
className="btn btn-success"
disabled={props.form.status === "SUBMITTING"}
onClick={props.form.approve}
>
Approve
</button>
</div>
);
};
export const PackageReviewModal: React.FC = () => {
const context = useReviewContext();
const form = usePackageReviewForm(
context.props.packageListingId,
context.props.rejectionReason
);
useOnEscape(context.closeModal);

const style = {
backgroundColor: "rgba(0, 0, 0, 0.4)",
display: "block",
} as CSSProperties;
return (
<div
className="modal"
role="dialog"
style={style}
onClick={context.closeModal}
>
<div
className="modal-dialog modal-dialog-centered"
role="document"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-content">
<Header />
<Body form={form} />
<Footer form={form} />
</div>
</div>
</div>
);
};
23 changes: 23 additions & 0 deletions builder/src/components/PackageReview/Panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { useState } from "react";
import { ContextProps, ReviewContextProvider } from "./Context";
import { PackageReviewModal } from "./Modal";

export const PackageReviewPanel: React.FC<ContextProps> = (props) => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const closeModal = () => setIsVisible(false);

return (
<ReviewContextProvider initial={props} closeModal={closeModal}>
{isVisible && <PackageReviewModal />}
<button
type="button"
className="btn btn-warning"
aria-label="Review Package"
onClick={() => setIsVisible(true)}
>
<span className="fa fa-cog" />
&nbsp;&nbsp;Review Package
</button>
</ReviewContextProvider>
);
};
27 changes: 27 additions & 0 deletions builder/src/components/PackageReview/ReviewStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from "react";
import { ReviewStatus } from "../../api";

interface PackageStatusProps {
reviewStatus: ReviewStatus;
}

function getStatusClassName(status: ReviewStatus) {
switch (status) {
case "approved":
return "text-success";
case "unreviewed":
return "text-warning";
case "rejected":
return "text-danger";
}
}

export const ReviewStatusDisplay: React.FC<PackageStatusProps> = ({
reviewStatus,
}) => {
return (
<div className={`text-capitalize ${getStatusClassName(reviewStatus)}`}>
{reviewStatus}
</div>
);
};
74 changes: 74 additions & 0 deletions builder/src/components/PackageReview/useForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useForm } from "react-hook-form";
import { ExperimentalApi } from "../../api";
import * as Sentry from "@sentry/react";
import { useCallback, useState } from "react";
import { Control } from "react-hook-form/dist/types";

type Status = undefined | "SUBMITTING" | "SUCCESS" | "ERROR";
export type PackageListingReviewFormValues = {
rejectionReason: string;
};

export type PackageListingReviewForm = {
approve: () => Promise<void>;
reject: () => Promise<void>;
control: Control<PackageListingReviewFormValues>;
error?: string;
status: Status;
};

export const usePackageReviewForm = (
packageListingId: string,
rejectionReason?: string,
onSuccess?: () => void
): PackageListingReviewForm => {
const { handleSubmit, control } = useForm<PackageListingReviewFormValues>({
defaultValues: { rejectionReason },
});
const [status, setStatus] = useState<Status>(undefined);
const [error, setError] = useState<string | undefined>(undefined);

const handleState = useCallback(
async (handler: () => Promise<any>) => {
if (status === "SUBMITTING") return;
setError(undefined);
setStatus("SUBMITTING");
try {
await handler();
setStatus("SUCCESS");
} catch (e) {
Sentry.captureException(e);
setError(`${e}`);
setStatus("ERROR");
}
},
[setError, setStatus]
);

const approve = handleSubmit(async () => {
await handleState(async () => {
await ExperimentalApi.approvePackageListing({
packageListingId: packageListingId,
});
if (onSuccess) onSuccess();
});
});

const reject = handleSubmit(async (data) => {
await handleState(async () => {
await ExperimentalApi.rejectPackageListing({
packageListingId: packageListingId,
data: { rejection_reason: data.rejectionReason },
});
if (onSuccess) onSuccess();
});
});

return {
approve,
reject,
control,
error,
status,
};
};
13 changes: 13 additions & 0 deletions builder/src/components/common/useOnEscape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useEffect } from "react";

export const useOnEscape = (onEscape: () => void) => {
const handleEvent = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscape();
}
};
useEffect(() => {
document.addEventListener("keydown", handleEvent);
return () => document.removeEventListener("keydown", handleEvent);
}, [handleEvent]);
};
Loading

0 comments on commit f9759c9

Please sign in to comment.