feat: add new responses view in results; add csv download feature for responses

This commit is contained in:
Matthias Nannt
2022-06-14 21:36:25 +09:00
parent 8f0c2ce642
commit bae1aff064
16 changed files with 581 additions and 127 deletions
+79
View File
@@ -0,0 +1,79 @@
import Head from "next/head";
import MenuBreadcrumbs from "./MenuBreadcrumbs";
import MenuSteps from "./MenuSteps";
import MenuProfile from "./MenuProfile";
import { classNames } from "../../lib/utils";
import { signIn, useSession } from "next-auth/react";
import Loading from "../Loading";
export default function LayoutFormResults({
title,
formId,
resultMode,
setResultMode,
currentStep,
children,
}) {
const { data: session, status } = useSession();
if (status === "loading") {
return <Loading />;
}
if (!session) {
signIn();
return <div>You need to be authenticated to view this page.</div>;
}
const resultModes = [
{ name: "Dashboard", id: "dashboard" },
{ name: "Responses", id: "responses" },
];
return (
<>
<Head>
<title>{title}</title>
</Head>
<div className="flex min-h-screen overflow-hidden bg-gray-50">
<div className="flex flex-col flex-1 overflow-hidden">
<header className="w-full">
<div className="relative z-10 flex flex-shrink-0 h-16 bg-white border-b border-gray-200">
<div className="flex flex-1 px-4 sm:px-6">
<MenuBreadcrumbs formId={formId} />
<MenuSteps formId={formId} currentStep={currentStep} />
<div className="flex items-center justify-end flex-1 space-x-2 text-right sm:space-x-4">
{/* Profile dropdown */}
<MenuProfile />
</div>
</div>
</div>
<div className="relative z-10 flex flex-shrink-0 h-16 border-b border-gray-200 bg-gray-50">
<div className="flex items-center justify-center flex-1 px-4">
<nav className="flex space-x-4" aria-label="resultModes">
{resultModes.map((mode) => (
<button
key={mode.name}
onClick={() => setResultMode(mode.id)}
className={classNames(
mode.id === resultMode
? "bg-gray-200 text-gray-800"
: "text-gray-600 hover:text-gray-800",
"px-3 py-2 font-medium text-sm rounded-md"
)}
aria-current={mode.id === resultMode ? "page" : undefined}
>
{mode.name}
</button>
))}
</nav>
</div>
</div>
</header>
{/* Main content */}
{children}
</div>
</div>
</>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { Menu, Transition } from "@headlessui/react";
import { ChevronDownIcon } from "@heroicons/react/solid";
import { parseAsync } from "json2csv";
import { Fragment } from "react";
import { useForm } from "../../lib/forms";
import {
getSubmission,
useSubmissionSessions,
} from "../../lib/submissionSessions";
import { Submission } from "../../lib/types";
import { slugify } from "../../lib/utils";
import Loading from "../Loading";
export default function DownloadResponses({ formId }) {
const { submissionSessions, isLoadingSubmissionSessions } =
useSubmissionSessions(formId);
const { form, isLoadingForm } = useForm(formId);
const download = async (format: "csv" | "excel") => {
// build dict of answers in copy of answerSessions
const submissions: Submission[] = submissionSessions.map((s) =>
getSubmission(s, form.schema)
);
// build data fields for csv/excel file
const data = [];
for (const submission of submissions) {
const dataEntry = { createdAt: submission.createdAt };
for (const page of submission.pages) {
if (page.elements) {
for (const element of page.elements) {
if (element.type !== "submit") {
dataEntry[element.label] = element.value;
}
}
}
}
data.push(dataEntry);
}
// get fields
const fields: any = [
{
label: "Timestamp",
value: "createdAt",
},
];
for (const page of submissions[0].pages) {
for (const element of page.elements) {
if (element.type !== "submit") {
fields.push({
label: element.label,
value: element.label,
});
}
}
}
const opts: any = { fields };
if (format === "excel") {
opts.excelStrings = true;
}
const fileTypes = {
csv: { mimeType: "text/csv", fileExtension: "csv" },
excel: { mimeType: "application/vnd.ms-excel", fileExtension: "csv" },
};
try {
const csv = await parseAsync(data, opts);
// download
var blob = new Blob([csv], { type: fileTypes[format].mimeType });
const url = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement("a");
link.href = url;
link.setAttribute(
"download",
`${slugify(form.name)}.${fileTypes[format].fileExtension}`
);
document.body.appendChild(link);
link.click();
link.parentNode.removeChild(link);
} catch (err) {
console.error(err);
}
};
if (isLoadingSubmissionSessions || isLoadingForm) {
return <Loading />;
}
return (
<Menu as="div" className="relative z-10 inline-block w-full text-left">
<div>
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium text-white bg-gray-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
Download
<ChevronDownIcon
className="w-5 h-5 ml-2 -mr-1 text-white hover:text-gray-100"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white divide-y divide-gray-100 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="px-1 py-1 ">
<Menu.Item>
{({ active }) => (
<button
onClick={() => download("csv")}
className={`${
active ? "bg-red-500 text-white" : "text-gray-900"
} group flex rounded-md items-center w-full px-2 py-2 text-sm`}
>
Download as CSV
</button>
)}
</Menu.Item>
{/* <Menu.Item>
{({ active }) => (
<button
onClick={() => download("excel")}
className={`${
active ? "bg-red-500 text-white" : "text-gray-900"
} group flex rounded-md items-center w-full px-2 py-2 text-sm`}
>
Download as Excel
</button>
)}
</Menu.Item> */}
</div>
</Menu.Items>
</Transition>
</Menu>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function ResultsDashboard({}) {
return <p>Dashboard</p>;
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { RadioGroup } from "@headlessui/react";
import { CheckIcon } from "@heroicons/react/solid";
import { getEventName } from "../../lib/events";
import { useSubmissionSessions } from "../../lib/submissionSessions";
import { SubmissionSession } from "../../lib/types";
import { convertDateTimeString, convertTimeString } from "../../lib/utils";
import SubmissionDisplay from "./SubmissionDisplay";
import DownloadResponses from "./DownloadResponses";
import Loading from "../Loading";
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
type ResultsResponseProps = {
formId: string;
};
export default function ResultsResponses({ formId }: ResultsResponseProps) {
const { submissionSessions, isLoadingSubmissionSessions } =
useSubmissionSessions(formId);
const [activeSubmissionSession, setActiveSubmissionSession] =
useState<SubmissionSession | null>(null);
useEffect(() => {
if (!isLoadingSubmissionSessions && submissionSessions.length > 0) {
setActiveSubmissionSession(submissionSessions[0]);
}
}, [isLoadingSubmissionSessions]);
if (isLoadingSubmissionSessions) {
return <Loading />;
}
return (
<div className="flex flex-col flex-1 w-full mx-auto overflow-visible max-w-screen">
<div className="relative z-0 flex flex-1 overflow-visible">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none xl:order-last">
<div className="overflow-visible bg-white shadow sm:rounded-lg">
{activeSubmissionSession && (
<div className="px-4 py-5 sm:p-12">
<div className="grid grid-cols-2 gap-8 divide-x">
<div className="flow-root">
<h1 className="mb-8 text-gray-700">
{convertDateTimeString(activeSubmissionSession.createdAt)}
</h1>
<SubmissionDisplay
key={activeSubmissionSession.id}
submissionSession={activeSubmissionSession}
formId={formId}
/>
</div>
<div className="flow-root pl-10">
<h1 className="mb-8 text-gray-700">Session Activity</h1>
<ul role="list" className="-mb-8">
{activeSubmissionSession.events.map((event, eventIdx) => (
<li key={event.id}>
<div className="relative pb-8">
{eventIdx !==
activeSubmissionSession.events.length - 1 ? (
<span
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
aria-hidden="true"
/>
) : null}
<div className="relative flex space-x-3">
<div>
<span
className={classNames(
"bg-red-200",
"h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white"
)}
>
<CheckIcon
className="w-5 h-5 text-white"
aria-hidden="true"
/>
</span>
</div>
<div className="min-w-0 flex-1 pt-1.5 flex justify-between space-x-4">
<div>
<p className="text-sm text-gray-500">
{getEventName(event.type)}
{/* <span className="font-medium text-gray-900">
{event.data.pageName || ""}
</span> */}
</p>
</div>
<div className="text-sm text-right text-gray-500 whitespace-nowrap">
<time dateTime={event.createdAt}>
{convertTimeString(event.createdAt)}
</time>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
)}
</div>
</main>
<aside className="flex-shrink-0 hidden border-r border-gray-200 xl:order-first xl:flex xl:flex-col w-96">
<DownloadResponses formId={formId} />
<div className="pt-6 pb-1">
<h2 className="px-5 text-lg font-medium text-gray-900">
Responses
</h2>
</div>
<RadioGroup
value={activeSubmissionSession}
onChange={setActiveSubmissionSession}
className="flex-1 min-h-0 overflow-y-auto"
>
<div className="relative">
<ul className="relative z-0 divide-y divide-gray-200">
{submissionSessions.map((submissionSession) => (
<RadioGroup.Option
key={submissionSession.id}
value={submissionSession}
className={({ checked }) =>
classNames(
checked ? "bg-gray-100" : "",
"relative flex items-center px-6 py-5 space-x-3 "
)
}
>
<div className="flex-1 min-w-0">
<button
onClick={() =>
setActiveSubmissionSession(submissionSession)
}
className="w-full text-left focus:outline-none"
>
{/* Extend touch target to entire panel */}
<span className="absolute inset-0" aria-hidden="true" />
<p className="text-sm font-medium text-gray-900">
{convertDateTimeString(submissionSession.createdAt)}
</p>
<p className="text-sm text-gray-500 truncate">
{submissionSession.events.length} events
</p>
</button>
</div>
</RadioGroup.Option>
))}
</ul>
</div>
</RadioGroup>
</aside>
</div>
</div>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { useMemo } from "react";
import { useForm } from "../../lib/forms";
import { convertDateTimeString } from "../../lib/utils";
import Loading from "../Loading";
export default function Submission({ formId, submissionSession }) {
const { form, isLoadingForm } = useForm(formId);
// fill the schema with the values provided by the user
const getSubmission = (submissionSession, schema) => {
if (!schema) return {};
const submission = JSON.parse(JSON.stringify(schema));
submission.id = submissionSession.id;
submission.createdAt = submissionSession.createdAt;
if (submissionSession.events.length > 0) {
for (const page of submission.pages) {
if (page.type === "form") {
const pageSubmission = submissionSession.events.find(
(s) => s.type === "pageSubmission" && s.data?.pageName === page.name
);
if (typeof pageSubmission !== "undefined") {
for (const element of page.elements) {
if (element.type !== "submit") {
if (element.name in pageSubmission.data?.submission) {
element.value = pageSubmission.data.submission[element.name];
}
}
}
}
}
}
}
return submission;
};
const submission = useMemo(() => {
if (form && submissionSession) {
return getSubmission(submissionSession, form.schema);
}
}, [form, submissionSession]);
if (isLoadingForm) {
return <Loading />;
}
return (
<div className="bg-white shadow sm:rounded-lg max-w-">
<div className="px-4 py-5 sm:p-6">
<div className="text-gray-600">
<p className="text-sm">
{convertDateTimeString(submission.createdAt)}
</p>
{submission.pages.map((page) => (
<div key={page.name}>
{page.elements?.map(
(element) =>
element.type !== "submit" && (
<div key={element.name}>
<p className="font-semibold text-red-600">
{element.label}
</p>
<p className="font-normal">
{element.value || "[not provided]"}
</p>
</div>
)
)}
</div>
))}
</div>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useMemo } from "react";
import { useForm } from "../../lib/forms";
import { getSubmission } from "../../lib/submissionSessions";
import { Submission } from "../../lib/types";
import { classNames } from "../../lib/utils";
import Loading from "../Loading";
export default function SubmissionDisplay({ formId, submissionSession }) {
const { form, isLoadingForm } = useForm(formId);
const submission: Submission = useMemo(() => {
if (form && submissionSession) {
return getSubmission(submissionSession, form.schema);
}
}, [form, submissionSession]);
if (isLoadingForm) {
return <Loading />;
}
return (
<div className="flow-root mt-6">
<ul role="list" className="-my-5 divide-y divide-gray-200">
{submission.pages.map((page) =>
page.elements?.map(
(element) =>
element.type !== "submit" && (
<li key={element.name} className="py-5">
<p className="text-sm font-semibold text-gray-800">
{element.label}
</p>
<p
className={classNames(
element.value ? "text-gray-600" : "text-gray-400",
"mt-1 text-sm text-gray-600 line-clamp-2"
)}
>
{element.value || "[not provided]"}
</p>
</li>
)
)
)}
</ul>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export const getEventName = (eventType: string) => {
switch (eventType) {
case "pageSubmission":
return "Page Submission";
default:
return eventType;
}
};
+42
View File
@@ -14,3 +14,45 @@ export const useSubmissionSessions = (formId: string) => {
mutateSubmissionSessions: mutate,
};
};
// fill the schema with the values provided by the user
export const getSubmission = (submissionSession, schema) => {
if (!schema) return {};
// create new submission
const submission = {
id: submissionSession.id,
createdAt: submissionSession.createdAt,
pages: [],
};
if (submissionSession.events.length > 0) {
// iterate through schema pages to fill submission
for (const page of schema.pages) {
// new submission page
const submissionPage = {
name: page.name,
type: page.type,
elements: page.elements
? JSON.parse(JSON.stringify(page.elements))
: [],
};
// search for elements in schema pages of type "form" and fill their value into the submission
if (page.type === "form") {
const pageSubmission = submissionSession.events.find(
(s) => s.type === "pageSubmission" && s.data?.pageName === page.name
);
if (typeof pageSubmission !== "undefined") {
for (const [elementIdx, element] of page.elements.entries()) {
if (element.type !== "submit") {
if (element.name in pageSubmission.data?.submission) {
submissionPage.elements[elementIdx].value =
pageSubmission.data.submission[element.name];
}
}
}
}
}
submission.pages.push(submissionPage);
}
}
return submission;
};
+38 -3
View File
@@ -52,7 +52,10 @@ export type SchemaOption = {
value: string;
};
export type pageSubmissionData = {
export type pageSubmissionEvent = {
id: string;
createdAt: string;
updatedAt: string;
type: "pageSubmission";
data: {
submissionSessionId: string;
@@ -62,15 +65,47 @@ export type pageSubmissionData = {
};
export type submissionCompletedEvent = {
id: string;
createdAt: string;
updatedAt: string;
type: "submissionCompleted";
data: { [key: string]: string };
};
export type updateSchemaEvent = { type: "updateSchema"; data: Schema };
export type updateSchemaEvent = {
id: string;
createdAt: string;
updatedAt: string;
type: "updateSchema";
data: Schema;
};
export type ApiEvent =
| pageSubmissionData
| pageSubmissionEvent
| submissionCompletedEvent
| updateSchemaEvent;
export type WebhookEvent = Event & { formId: string; timestamp: string };
export type SubmissionSession = {
id: string;
createdAt: string;
updatedAt: string;
form?: any;
userFingerprint: string;
events: ApiEvent[];
};
export type Submission = {
id?: string;
createdAt?: string;
pages?: SubmissionPage[];
};
type SubmissionPage = {
name: string;
type: string;
elements: SubmissionPageElement[];
};
type SubmissionPageElement = SchemaElement & { value: string };
+16 -1
View File
@@ -62,7 +62,7 @@ export const convertDateString = (dateString: string) => {
);
};
export const convertDateTimeString = (dateString) => {
export const convertDateTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
@@ -79,3 +79,18 @@ export const convertDateTimeString = (dateString) => {
}
);
};
export const convertTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
{
hour: "numeric",
minute: "2-digit",
second: "2-digit",
},
{
locale: "en",
}
);
};
+1
View File
@@ -15,6 +15,7 @@
"babel-plugin-superjson-next": "^0.4.3",
"bcryptjs": "^2.4.3",
"date-fns": "^2.28.0",
"json2csv": "^5.0.7",
"next": "12.1.6",
"next-auth": "^4.3.4",
"nextjs-cors": "^2.1.1",
-1
View File
@@ -24,7 +24,6 @@ export default async function handle(
const { events } = req.body;
const error = validateEvents(events);
if (error) {
console.log(JSON.stringify(error, null, 2));
const { status, message } = error;
return res.status(status).json({ error: message });
}
@@ -21,6 +21,11 @@ export default async function handle(
where: {
form: { id: formId },
},
orderBy: [
{
createdAt: "desc",
},
],
include: {
events: true,
},
-1
View File
@@ -5,7 +5,6 @@ import { useSubmissionSessions } from "../../../lib/submissionSessions";
export default function FormIndex() {
const router = useRouter();
console.log(router.query);
const formId = router.query.id;
const { submissionSessions, isLoadingSubmissionSessions } =
useSubmissionSessions(formId?.toString());
+16 -25
View File
@@ -1,48 +1,39 @@
import { GetServerSideProps } from "next";
import { getSession } from "next-auth/react";
import { useRouter } from "next/router";
import LayoutFormBasics from "../../../components/layout/LayoutFormBasic";
import { useState } from "react";
import LayoutFormResults from "../../../components/layout/LayoutFormResults";
import Loading from "../../../components/Loading";
import Submission from "../../../components/results/Submission";
import ResultsDashboard from "../../../components/results/ResultsDashboard";
import ResultsResponses from "../../../components/results/ResultsResponses";
import { useForm } from "../../../lib/forms";
import { useSubmissionSessions } from "../../../lib/submissionSessions";
export default function Share() {
const router = useRouter();
const formId = router.query.id.toString();
const { form, isLoadingForm } = useForm(router.query.id);
const { submissionSessions, isLoadingSubmissionSessions } =
useSubmissionSessions(form?.id);
const [resultMode, setResultMode] = useState<string>("dashboard");
if (isLoadingForm || isLoadingSubmissionSessions) {
if (isLoadingForm) {
return <Loading />;
}
return (
<>
<LayoutFormBasics
<LayoutFormResults
title={form.title}
formId={formId}
currentStep="results"
resultMode={resultMode}
setResultMode={setResultMode}
>
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">
Submissions for your form
</h3>
<p>Number of submissions: {submissionSessions.length}</p>
</div>
</div>
<div className="grid gap-8 mt-8">
{submissionSessions.map((submissionSession) => (
<Submission
key={submissionSession.id}
submissionSession={submissionSession}
formId={formId}
/>
))}
</div>
</LayoutFormBasics>
{resultMode === "dashboard" && <ResultsDashboard />}
{resultMode === "responses" && (
<>
<ResultsResponses formId={formId} />
</>
)}
</LayoutFormResults>
</>
);
}
+24 -22
View File
@@ -603,6 +603,11 @@ color-name@^1.1.4, color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
commander@^6.1.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -1243,16 +1248,6 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
highlight.js@^10.5.0:
version "10.7.3"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
highlight.js@^11.5.1:
version "11.5.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.5.1.tgz#027c24e4509e2f4dcd00b4a6dda542ce0a1f7aea"
integrity sha512-LKzHqnxr4CrD2YsNoIf/o5nJ09j4yi/GcH5BnYz9UnVpZdS4ucMgvP61TDty5xJcFGRjnH4DpujkS9bHT3hq0Q==
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
@@ -1443,6 +1438,15 @@ json-stable-stringify-without-jsonify@^1.0.1:
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
json2csv@^5.0.7:
version "5.0.7"
resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-5.0.7.tgz#f3a583c25abd9804be873e495d1e65ad8d1b54ae"
integrity sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==
dependencies:
commander "^6.1.0"
jsonparse "^1.3.1"
lodash.get "^4.4.2"
json5@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
@@ -1450,6 +1454,11 @@ json5@^1.0.1:
dependencies:
minimist "^1.2.0"
jsonparse@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.0.tgz#e624f259143b9062c92b6413ff92a164c80d3ccb"
@@ -1501,6 +1510,11 @@ lodash.castarray@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==
lodash.get@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
@@ -1917,11 +1931,6 @@ prisma@^3.15.1:
dependencies:
"@prisma/engines" "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e"
prismjs@^1.28.0:
version "1.28.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.28.0.tgz#0d8f561fa0f7cf6ebca901747828b149147044b6"
integrity sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw==
prop-types@^15.5.10, prop-types@^15.7.1, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
@@ -2000,13 +2009,6 @@ react-feather@^2.0.9:
dependencies:
prop-types "^15.7.2"
react-highlight@^0.14.0:
version "0.14.0"
resolved "https://registry.yarnpkg.com/react-highlight/-/react-highlight-0.14.0.tgz#5aefa5518baa580f96b68d48129d7a5d2dc0c9ef"
integrity sha512-kWE+KXOXidS7SABhVopOgMnowbI3RAfeGZbnrduLNlWrYAED8sycL9l/Fvw3w0PFpIIawB7mRDnyhDcM/cIIGA==
dependencies:
highlight.js "^10.5.0"
react-icons@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.4.0.tgz#a13a8a20c254854e1ec9aecef28a95cdf24ef703"