Skip to content

Commit 35a7a89

Browse files
committed
Add report button to package details page
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
1 parent aef4d18 commit 35a7a89

File tree

13 files changed

+366
-9
lines changed

13 files changed

+366
-9
lines changed

builder/src/api/api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ class ExperimentalApiImpl extends ThunderstoreApi {
8484
return (await response.json()) as UpdatePackageListingResponse;
8585
};
8686

87+
reportPackageListing = async (props: {
88+
packageListingId: string;
89+
data: {
90+
package_version_id: string;
91+
reason: string;
92+
description?: string;
93+
};
94+
}) => {
95+
await this.post(
96+
ApiUrls.reportPackageListing(props.packageListingId),
97+
props.data
98+
);
99+
};
100+
87101
approvePackageListing = async (props: {
88102
packageListingId: string;
89103
data: {

builder/src/api/error.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { JSONValue } from "./models";
1+
import { GenericApiError, JSONValue } from "./models";
22

33
export const stringifyError = (
44
val: JSONValue | undefined,
@@ -33,25 +33,40 @@ export class ThunderstoreApiError {
3333
message: string;
3434
response: Response | null;
3535
errorObject: JSONValue | null;
36+
extractedMessage: string | null;
3637

3738
constructor(
3839
message: string,
3940
response: Response | null,
40-
errorObject: JSONValue | null
41+
errorObject: JSONValue | null,
42+
extractedMessage: string | null = null
4143
) {
4244
this.message = message;
4345
this.response = response;
4446
this.errorObject = errorObject;
47+
this.extractedMessage = extractedMessage;
4548
}
4649

4750
static createFromResponse = async (message: string, response: Response) => {
4851
let errorObject: JSONValue | null = null;
52+
let extractedMessage: string | null = null;
4953
try {
5054
errorObject = await response.json();
55+
if (typeof errorObject === "string") {
56+
extractedMessage = errorObject;
57+
} else if (typeof errorObject === "object") {
58+
const genericError = errorObject as GenericApiError;
59+
extractedMessage = genericError.detail || null;
60+
}
5161
} catch (e) {
5262
console.error(e);
5363
}
54-
return new ThunderstoreApiError(message, response, errorObject);
64+
return new ThunderstoreApiError(
65+
message,
66+
response,
67+
errorObject,
68+
extractedMessage
69+
);
5570
};
5671

5772
public toString(): string {

builder/src/api/models.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,20 @@ export interface WikiPageUpsertRequest {
139139
markdown_content: string;
140140
}
141141

142-
export interface BaseApiError {
142+
export interface GenericApiError {
143+
detail?: string;
144+
}
145+
146+
export interface BaseValidationError {
143147
non_field_errors?: string[];
144148
__all__?: string[];
145149
}
146150

147-
export interface WikiDeleteError extends BaseApiError {
151+
export interface WikiDeleteError extends BaseValidationError {
148152
pageId?: string[];
149153
}
150154

151-
export interface WikiPageUpsertError extends BaseApiError {
155+
export interface WikiPageUpsertError extends BaseValidationError {
152156
title?: string[];
153157
markdown_content?: string[];
154158
}

builder/src/api/urls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export class ApiUrls {
2323
apiUrl("submission", "validate", "manifest-v1");
2424
static updatePackageListing = (packageListingId: string) =>
2525
apiUrl("package-listing", packageListingId, "update");
26+
static reportPackageListing = (packageListingId: string) =>
27+
apiUrl("package-listing", packageListingId, "report");
2628
static approvePackageListing = (packageListingId: string) =>
2729
apiUrl("package-listing", packageListingId, "approve");
2830
static rejectPackageListing = (packageListingId: string) =>

builder/src/components/FormSelectField.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface FormSelectFieldProps<T, F extends Record<string, any>> {
1111
getOption: (t: T) => { value: string; label: string };
1212
default?: T | T[];
1313
isMulti?: boolean;
14+
required?: boolean;
1415
}
1516
export const FormSelectField: React.FC<FormSelectFieldProps<any, any>> = (
1617
props
@@ -34,6 +35,7 @@ export const FormSelectField: React.FC<FormSelectFieldProps<any, any>> = (
3435
name={props.name}
3536
control={props.control}
3637
defaultValue={defaultValue}
38+
rules={{ required: props.required }}
3739
render={({ field }) => (
3840
<Select
3941
{...field}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React, { useState } from "react";
2+
import { ReportModal } from "./ReportModal";
3+
import {
4+
ReportModalContextProps,
5+
ReportModalContextProvider,
6+
} from "./ReportModalContext";
7+
import { CsrfTokenProvider } from "../CsrfTokenContext";
8+
9+
export const ReportButton: React.FC<ReportModalContextProps> = (props) => {
10+
const [isVisible, setIsVisible] = useState<boolean>(false);
11+
const closeModal = () => setIsVisible(false);
12+
13+
return (
14+
<CsrfTokenProvider token={props.csrfToken}>
15+
<ReportModalContextProvider initial={props} closeModal={closeModal}>
16+
{isVisible && <ReportModal />}
17+
<button
18+
type={"button"}
19+
className="btn btn-danger"
20+
aria-label="Report"
21+
onClick={() => setIsVisible(true)}
22+
>
23+
<span className="fa fa-exclamation-circle" />
24+
&nbsp;&nbsp;Report
25+
</button>
26+
</ReportModalContextProvider>
27+
</CsrfTokenProvider>
28+
);
29+
};
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { CSSProperties } from "react";
2+
import { useReportModalContext } from "./ReportModalContext";
3+
import { useOnEscape } from "../common/useOnEscape";
4+
import { ReportForm, useReportForm } from "./hooks";
5+
import { FormSelectField } from "../FormSelectField";
6+
import { FieldError } from "react-hook-form/dist/types";
7+
8+
const Header: React.FC = () => {
9+
const context = useReportModalContext();
10+
11+
return (
12+
<div className="modal-header">
13+
<div className="modal-title">Submit Report</div>
14+
<button
15+
type="button"
16+
className="close"
17+
aria-label="Close"
18+
onClick={context.closeModal}
19+
ref={(element) => element?.focus()}
20+
>
21+
<span aria-hidden="true">&times;</span>
22+
</button>
23+
</div>
24+
);
25+
};
26+
27+
function getErrorMessage(error: FieldError): string {
28+
if (error.message) return error.message;
29+
switch (error.type) {
30+
case "required":
31+
return "This field is required";
32+
case "maxLength":
33+
return "Max length exceeded";
34+
default:
35+
return "Unknown error";
36+
}
37+
}
38+
39+
function getFormErrorMessage(errorMessage: string): string {
40+
if (errorMessage === "Authentication credentials were not provided.") {
41+
return "You must be logged in to do this.";
42+
}
43+
return errorMessage;
44+
}
45+
46+
const FieldError: React.FC<{ error?: FieldError }> = ({ error }) => {
47+
if (!error) return null;
48+
return <span className={"mt-1 text-danger"}>{getErrorMessage(error)}</span>;
49+
};
50+
51+
interface BodyProps {
52+
form: ReportForm;
53+
}
54+
55+
const Body: React.FC<BodyProps> = ({ form }) => {
56+
const context = useReportModalContext().props;
57+
58+
return (
59+
<div className="modal-body gap-2 d-flex flex-column">
60+
<div className={"d-flex flex-column gap-1"}>
61+
<label className={"mb-0"}>Report reason</label>
62+
<FormSelectField
63+
className={"select-category"}
64+
control={form.control}
65+
name={"reason"}
66+
data={context.reasonChoices}
67+
getOption={(x) => x}
68+
required={true}
69+
/>
70+
<FieldError error={form.fieldErrors?.reason} />
71+
</div>
72+
<div className={"d-flex flex-column gap-1"}>
73+
<label className={"mb-0"}>Description (optional)</label>
74+
<textarea
75+
{...form.control.register("description", {
76+
maxLength: context.descriptionMaxLength,
77+
})}
78+
className={"code-input"}
79+
style={{ minHeight: "100px", fontFamily: "inherit" }}
80+
/>
81+
<FieldError error={form.fieldErrors?.description} />
82+
</div>
83+
84+
{form.error && (
85+
<div className={"alert alert-danger mt-2 mb-0"}>
86+
<p className={"mb-0"}>{getFormErrorMessage(form.error)}</p>
87+
</div>
88+
)}
89+
{form.status === "SUBMITTING" && (
90+
<div className={"alert alert-warning mt-2 mb-0"}>
91+
<p className={"mb-0"}>Submitting...</p>
92+
</div>
93+
)}
94+
{form.status === "SUCCESS" && (
95+
<div className={"alert alert-success mt-2 mb-0"}>
96+
<p className={"mb-0"}>Report submitted successfully!</p>
97+
</div>
98+
)}
99+
</div>
100+
);
101+
};
102+
103+
interface FooterProps {
104+
form: ReportForm;
105+
}
106+
107+
const Footer: React.FC<FooterProps> = ({ form }) => {
108+
return (
109+
<div className="modal-footer d-flex justify-content-end">
110+
<button
111+
type="button"
112+
className="btn btn-danger"
113+
disabled={
114+
form.status === "SUBMITTING" || form.status === "SUCCESS"
115+
}
116+
onClick={form.onSubmit}
117+
>
118+
Submit
119+
</button>
120+
</div>
121+
);
122+
};
123+
export const ReportModal: React.FC = () => {
124+
const context = useReportModalContext();
125+
useOnEscape(context.closeModal);
126+
127+
const form = useReportForm({
128+
packageListingId: context.props.packageListingId,
129+
packageVersionId: context.props.packageVersionId,
130+
});
131+
132+
const style = {
133+
backgroundColor: "rgba(0, 0, 0, 0.4)",
134+
display: "block",
135+
} as CSSProperties;
136+
return (
137+
<div
138+
className="modal"
139+
role="dialog"
140+
style={style}
141+
onClick={context.closeModal}
142+
>
143+
<div
144+
className="modal-dialog modal-dialog-centered"
145+
role="document"
146+
onClick={(e) => e.stopPropagation()}
147+
>
148+
<div className="modal-content">
149+
<Header />
150+
<Body form={form} />
151+
<Footer form={form} />
152+
</div>
153+
</div>
154+
</div>
155+
);
156+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React, { PropsWithChildren, useContext } from "react";
2+
3+
export type ReportModalContextProps = {
4+
csrfToken: string;
5+
packageListingId: string;
6+
packageVersionId: string;
7+
reasonChoices: { value: string; label: string }[];
8+
descriptionMaxLength: number;
9+
};
10+
11+
export interface IReportModalContext {
12+
props: ReportModalContextProps;
13+
closeModal: () => void;
14+
}
15+
16+
export interface ReportModalContextProviderProps {
17+
initial: ReportModalContextProps;
18+
closeModal: () => void;
19+
}
20+
21+
export const ReportModalContextProvider: React.FC<
22+
PropsWithChildren<ReportModalContextProviderProps>
23+
> = ({ children, initial, closeModal }) => {
24+
return (
25+
<ReportModalContext.Provider value={{ props: initial, closeModal }}>
26+
{children}
27+
</ReportModalContext.Provider>
28+
);
29+
};
30+
export const ReportModalContext = React.createContext<
31+
IReportModalContext | undefined
32+
>(undefined);
33+
34+
export const useReportModalContext = (): IReportModalContext => {
35+
return useContext(ReportModalContext)!;
36+
};

0 commit comments

Comments
 (0)