Skip to content

Commit

Permalink
Add report button to package details page
Browse files Browse the repository at this point in the history
Adds a report button to the package details page
Adds the react / tsx necessary to make it work
Modifies APIError to take an extractedMessage
Renames BaseApiError to BaseValidationError
Adds the "required" optional property to FormSelectFieldProps
  • Loading branch information
x753 committed Sep 16, 2024
1 parent c09a722 commit a7e2abf
Show file tree
Hide file tree
Showing 13 changed files with 366 additions and 9 deletions.
14 changes: 14 additions & 0 deletions builder/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ class ExperimentalApiImpl extends ThunderstoreApi {
return (await response.json()) as UpdatePackageListingResponse;
};

reportPackageListing = async (props: {
packageListingId: string;
data: {
package_version_id: string;
reason: string;
description?: string;
};
}) => {
await this.post(
ApiUrls.reportPackageListing(props.packageListingId),
props.data
);
};

approvePackageListing = async (props: {
packageListingId: string;
data: {
Expand Down
21 changes: 18 additions & 3 deletions builder/src/api/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JSONValue } from "./models";
import { GenericApiError, JSONValue } from "./models";

export const stringifyError = (
val: JSONValue | undefined,
Expand Down Expand Up @@ -33,25 +33,40 @@ export class ThunderstoreApiError {
message: string;
response: Response | null;
errorObject: JSONValue | null;
extractedMessage: string | null;

constructor(
message: string,
response: Response | null,
errorObject: JSONValue | null
errorObject: JSONValue | null,
extractedMessage: string | null = null
) {
this.message = message;
this.response = response;
this.errorObject = errorObject;
this.extractedMessage = extractedMessage;
}

static createFromResponse = async (message: string, response: Response) => {
let errorObject: JSONValue | null = null;
let extractedMessage: string | null = null;
try {
errorObject = await response.json();
if (typeof errorObject === "string") {
extractedMessage = errorObject;
} else if (typeof errorObject === "object") {
const genericError = errorObject as GenericApiError;
extractedMessage = genericError.detail || null;
}
} catch (e) {
console.error(e);
}
return new ThunderstoreApiError(message, response, errorObject);
return new ThunderstoreApiError(
message,
response,
errorObject,
extractedMessage
);
};

public toString(): string {
Expand Down
10 changes: 7 additions & 3 deletions builder/src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,20 @@ export interface WikiPageUpsertRequest {
markdown_content: string;
}

export interface BaseApiError {
export interface GenericApiError {
detail?: string;
}

export interface BaseValidationError {
non_field_errors?: string[];
__all__?: string[];
}

export interface WikiDeleteError extends BaseApiError {
export interface WikiDeleteError extends BaseValidationError {
pageId?: string[];
}

export interface WikiPageUpsertError extends BaseApiError {
export interface WikiPageUpsertError extends BaseValidationError {
title?: string[];
markdown_content?: string[];
}
Expand Down
2 changes: 2 additions & 0 deletions builder/src/api/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export class ApiUrls {
apiUrl("submission", "validate", "manifest-v1");
static updatePackageListing = (packageListingId: string) =>
apiUrl("package-listing", packageListingId, "update");
static reportPackageListing = (packageListingId: string) =>
apiUrl("package-listing", packageListingId, "report");
static approvePackageListing = (packageListingId: string) =>
apiUrl("package-listing", packageListingId, "approve");
static rejectPackageListing = (packageListingId: string) =>
Expand Down
2 changes: 2 additions & 0 deletions builder/src/components/FormSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface FormSelectFieldProps<T, F extends Record<string, any>> {
getOption: (t: T) => { value: string; label: string };
default?: T | T[];
isMulti?: boolean;
required?: boolean;
}
export const FormSelectField: React.FC<FormSelectFieldProps<any, any>> = (
props
Expand All @@ -34,6 +35,7 @@ export const FormSelectField: React.FC<FormSelectFieldProps<any, any>> = (
name={props.name}
control={props.control}
defaultValue={defaultValue}
rules={{ required: props.required }}
render={({ field }) => (
<Select
{...field}
Expand Down
29 changes: 29 additions & 0 deletions builder/src/components/ReportModal/ReportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React, { useState } from "react";
import { ReportModal } from "./ReportModal";
import {
ReportModalContextProps,
ReportModalContextProvider,
} from "./ReportModalContext";
import { CsrfTokenProvider } from "../CsrfTokenContext";

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

return (
<CsrfTokenProvider token={props.csrfToken}>
<ReportModalContextProvider initial={props} closeModal={closeModal}>
{isVisible && <ReportModal />}
<button
type={"button"}
className="btn btn-danger"
aria-label="Report"
onClick={() => setIsVisible(true)}
>
<span className="fa fa-exclamation-circle" />
&nbsp;&nbsp;Report
</button>
</ReportModalContextProvider>
</CsrfTokenProvider>
);
};
156 changes: 156 additions & 0 deletions builder/src/components/ReportModal/ReportModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import React, { CSSProperties } from "react";
import { useReportModalContext } from "./ReportModalContext";
import { useOnEscape } from "../common/useOnEscape";
import { ReportForm, useReportForm } from "./hooks";
import { FormSelectField } from "../FormSelectField";
import { FieldError } from "react-hook-form/dist/types";

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

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

function getErrorMessage(error: FieldError): string {
if (error.message) return error.message;
switch (error.type) {
case "required":
return "This field is required";
case "maxLength":
return "Max length exceeded";
default:
return "Unknown error";
}
}

function getFormErrorMessage(errorMessage: string): string {
if (errorMessage === "Authentication credentials were not provided.") {
return "You must be logged in to do this.";
}
return errorMessage;
}

const FieldError: React.FC<{ error?: FieldError }> = ({ error }) => {
if (!error) return null;
return <span className={"mt-1 text-danger"}>{getErrorMessage(error)}</span>;
};

interface BodyProps {
form: ReportForm;
}

const Body: React.FC<BodyProps> = ({ form }) => {
const context = useReportModalContext().props;

return (
<div className="modal-body gap-2 d-flex flex-column">
<div className={"d-flex flex-column gap-1"}>
<label className={"mb-0"}>Report reason</label>
<FormSelectField
className={"select-category"}
control={form.control}
name={"reason"}
data={context.reasonChoices}
getOption={(x) => x}
required={true}
/>
<FieldError error={form.fieldErrors?.reason} />
</div>
<div className={"d-flex flex-column gap-1"}>
<label className={"mb-0"}>Description (optional)</label>
<textarea
{...form.control.register("description", {
maxLength: context.descriptionMaxLength,
})}
className={"code-input"}
style={{ minHeight: "100px", fontFamily: "inherit" }}
/>
<FieldError error={form.fieldErrors?.description} />
</div>

{form.error && (
<div className={"alert alert-danger mt-2 mb-0"}>
<p className={"mb-0"}>{getFormErrorMessage(form.error)}</p>
</div>
)}
{form.status === "SUBMITTING" && (
<div className={"alert alert-warning mt-2 mb-0"}>
<p className={"mb-0"}>Submitting...</p>
</div>
)}
{form.status === "SUCCESS" && (
<div className={"alert alert-success mt-2 mb-0"}>
<p className={"mb-0"}>Report submitted successfully!</p>
</div>
)}
</div>
);
};

interface FooterProps {
form: ReportForm;
}

const Footer: React.FC<FooterProps> = ({ form }) => {
return (
<div className="modal-footer d-flex justify-content-end">
<button
type="button"
className="btn btn-danger"
disabled={
form.status === "SUBMITTING" || form.status === "SUCCESS"
}
onClick={form.onSubmit}
>
Submit
</button>
</div>
);
};
export const ReportModal: React.FC = () => {
const context = useReportModalContext();
useOnEscape(context.closeModal);

const form = useReportForm({
packageListingId: context.props.packageListingId,
packageVersionId: context.props.packageVersionId,
});

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>
);
};
36 changes: 36 additions & 0 deletions builder/src/components/ReportModal/ReportModalContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React, { PropsWithChildren, useContext } from "react";

export type ReportModalContextProps = {
csrfToken: string;
packageListingId: string;
packageVersionId: string;
reasonChoices: { value: string; label: string }[];
descriptionMaxLength: number;
};

export interface IReportModalContext {
props: ReportModalContextProps;
closeModal: () => void;
}

export interface ReportModalContextProviderProps {
initial: ReportModalContextProps;
closeModal: () => void;
}

export const ReportModalContextProvider: React.FC<
PropsWithChildren<ReportModalContextProviderProps>
> = ({ children, initial, closeModal }) => {
return (
<ReportModalContext.Provider value={{ props: initial, closeModal }}>
{children}
</ReportModalContext.Provider>
);
};
export const ReportModalContext = React.createContext<
IReportModalContext | undefined
>(undefined);

export const useReportModalContext = (): IReportModalContext => {
return useContext(ReportModalContext)!;
};
Loading

0 comments on commit a7e2abf

Please sign in to comment.