mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 19:39:01 -05:00
Updated Editor-Approach for easier multi-page management (#4)
* use new editor approach where we use a single editorJS instance and a pageTransition block to indicate the start of a new page * improve toast messages in editor * add keyboard listener and toast to showcase autosaving functionality * bugfix first and secondary menu positioning * add required functionality to textQuestion
This commit is contained in:
+64
-111
@@ -1,129 +1,77 @@
|
||||
import EditorJS from "@editorjs/editorjs";
|
||||
import {
|
||||
DocumentAddIcon,
|
||||
EyeIcon,
|
||||
PaperAirplaneIcon,
|
||||
ShareIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { NoCodeForm } from "@prisma/client";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useForm } from "../../lib/forms";
|
||||
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import LimitedWidth from "../layout/LimitedWidth";
|
||||
import SecondNavBar from "../layout/SecondNavBar";
|
||||
import Loading from "../Loading";
|
||||
import Page from "./Page";
|
||||
import LoadingModal from "../LoadingModal";
|
||||
import ShareModal from "./ShareModal";
|
||||
let Editor = dynamic(() => import("../editorjs/Editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Builder({ formId }) {
|
||||
const router = useRouter();
|
||||
const { form, isLoadingForm } = useForm(formId);
|
||||
const editorRef = useRef<EditorJS | null>();
|
||||
const { isLoadingForm } = useForm(formId);
|
||||
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } =
|
||||
useNoCodeForm(formId);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [openShareModal, setOpenShareModal] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const addPage = useCallback(
|
||||
async (page = undefined) => {
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.pagesDraft.push(
|
||||
page || { id: uuidv4(), type: "form", blocks: [] }
|
||||
);
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
},
|
||||
[noCodeForm, mutateNoCodeForm]
|
||||
);
|
||||
|
||||
const deletePage = async (pageIdx) => {
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.pagesDraft.splice(pageIdx, 1);
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
const addPage = () => {
|
||||
editorRef.current.blocks.insert("pageTransition", {
|
||||
submitLabel: "Submit",
|
||||
});
|
||||
const block = editorRef.current.blocks.insert("paragraph");
|
||||
editorRef.current.caret.setToBlock(
|
||||
editorRef.current.blocks.getBlockIndex(block.id)
|
||||
);
|
||||
};
|
||||
|
||||
const initPages = useCallback(async () => {
|
||||
if (!isLoadingNoCodeForm && !isLoadingForm && !isInitialized) {
|
||||
if (noCodeForm.pagesDraft.length === 0) {
|
||||
const newNoCodeForm: NoCodeForm = JSON.parse(
|
||||
JSON.stringify(noCodeForm)
|
||||
);
|
||||
newNoCodeForm.pagesDraft = [
|
||||
{
|
||||
id: uuidv4(),
|
||||
type: "form",
|
||||
blocks: [
|
||||
{
|
||||
id: "FrEb9paDoV",
|
||||
data: {
|
||||
text: form.name,
|
||||
level: 1,
|
||||
},
|
||||
type: "header",
|
||||
},
|
||||
{
|
||||
id: "qtvg94SRMB",
|
||||
data: {
|
||||
placeholder: "",
|
||||
},
|
||||
type: "textQuestion",
|
||||
},
|
||||
{
|
||||
id: "e_N-JpRIfL",
|
||||
data: {
|
||||
label: "Submit",
|
||||
},
|
||||
type: "submitButton",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
type: "thankyou",
|
||||
blocks: [
|
||||
{
|
||||
id: "pIcLJUy0SY",
|
||||
data: {
|
||||
text: "Thank you for taking the time to fill out this form 🙏",
|
||||
},
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [
|
||||
isLoadingNoCodeForm,
|
||||
noCodeForm,
|
||||
isInitialized,
|
||||
isLoadingForm,
|
||||
form,
|
||||
mutateNoCodeForm,
|
||||
]);
|
||||
const initAction = async (editor: EditorJS) => {
|
||||
editor.blocks.insert("header", {
|
||||
text: noCodeForm.form.name,
|
||||
});
|
||||
const focusBlock = editor.blocks.insert("textQuestion");
|
||||
editor.blocks.insert("pageTransition", {
|
||||
submitLabel: "Submit",
|
||||
});
|
||||
editor.blocks.insert("header", {
|
||||
text: "Thank you",
|
||||
});
|
||||
editor.blocks.insert("paragraph", {
|
||||
text: "Thank you for taking the time to fill out this form 🙏",
|
||||
});
|
||||
editor.blocks.delete(0); // remove defaultBlock
|
||||
editor.caret.setToBlock(
|
||||
editorRef.current.blocks.getBlockIndex(focusBlock.id)
|
||||
);
|
||||
};
|
||||
|
||||
const publishChanges = async () => {
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.pages = newNoCodeForm.pagesDraft;
|
||||
newNoCodeForm.published = true;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
setOpenShareModal(true);
|
||||
toast("Your changes are now live 🎉");
|
||||
setLoading(true);
|
||||
setTimeout(async () => {
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.blocks = newNoCodeForm.blocksDraft;
|
||||
newNoCodeForm.published = true;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
setLoading(false);
|
||||
toast("Your changes are now public 🎉");
|
||||
}, 500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initPages();
|
||||
}, [isLoadingNoCodeForm, initPages]);
|
||||
|
||||
if (isLoadingNoCodeForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const noCodeSecondNavigation = [
|
||||
{
|
||||
id: "addPage",
|
||||
@@ -154,22 +102,26 @@ export default function Builder({ formId }) {
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoadingNoCodeForm || isLoadingForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecondNavBar navItems={noCodeSecondNavigation} />
|
||||
<div className="w-full bg-ui-gray-lighter">
|
||||
<div className="flex justify-center w-full">
|
||||
<div className="grid w-full grid-cols-1">
|
||||
{noCodeForm.pagesDraft.map((page, pageIdx) => (
|
||||
<Page
|
||||
key={page.id}
|
||||
<div className="w-full h-full mb-20 overflow-auto bg-white">
|
||||
<div className="flex justify-center w-full pt-10 pb-56">
|
||||
<LimitedWidth>
|
||||
{Editor && (
|
||||
<Editor
|
||||
id="editor"
|
||||
formId={formId}
|
||||
page={page}
|
||||
pageIdx={pageIdx}
|
||||
deletePageAction={deletePage}
|
||||
editorRef={editorRef}
|
||||
autofocus={true}
|
||||
initAction={initAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</LimitedWidth>
|
||||
</div>
|
||||
</div>
|
||||
<ShareModal
|
||||
@@ -177,6 +129,7 @@ export default function Builder({ formId }) {
|
||||
setOpen={setOpenShareModal}
|
||||
formId={formId}
|
||||
/>
|
||||
<LoadingModal isLoading={loading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import Loading from "../Loading";
|
||||
import PageToolbar from "./PageToolbar";
|
||||
let Editor = dynamic(() => import("../editorjs/Editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Page({ formId, page, pageIdx, deletePageAction }) {
|
||||
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } =
|
||||
useNoCodeForm(formId);
|
||||
|
||||
const updatePage = async (blocks) => {
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
if (pageIdx < newNoCodeForm.pagesDraft.length) {
|
||||
newNoCodeForm.pagesDraft[pageIdx].blocks = blocks;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
} else {
|
||||
throw Error(
|
||||
`updatePage error: Page at position ${pageIdx} not found in pagesDraft`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const setPageType = async (newType) => {
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.pagesDraft[pageIdx].type = newType;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
};
|
||||
|
||||
if (isLoadingNoCodeForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full bg-white">
|
||||
{pageIdx !== 0 && (
|
||||
<div className="z-10">
|
||||
<PageToolbar
|
||||
page={page}
|
||||
pageIdx={pageIdx}
|
||||
deletePageAction={deletePageAction}
|
||||
setPageType={setPageType}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full p-10 ">
|
||||
<div className="relative max-w-5xl mx-auto">
|
||||
{Editor && (
|
||||
<Editor
|
||||
id={`${page.id}-editor`}
|
||||
autofocus={pageIdx === 0}
|
||||
onChange={(blocks) => updatePage(blocks)}
|
||||
value={noCodeForm.pagesDraft[pageIdx]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { InformationCircleIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { toast } from "react-toastify";
|
||||
import { Fragment } from "react";
|
||||
import { useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import Loading from "../Loading";
|
||||
@@ -101,6 +102,7 @@ export default function ShareModal({ open, setOpen, formId }) {
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getPublicFormUrl());
|
||||
toast("Link copied to clipboard 🙌");
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 mt-3 font-medium text-white bg-gray-800 border border-transparent rounded-md shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
|
||||
@@ -1,54 +1,91 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import EditorJS from "@editorjs/editorjs";
|
||||
import Header from "@editorjs/header";
|
||||
import Paragraph from "@editorjs/paragraph";
|
||||
import DragDrop from "editorjs-drag-drop";
|
||||
import Undo from "editorjs-undo";
|
||||
import { Fragment, useCallback, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import Loading from "../Loading";
|
||||
import PageTransition from "./tools/PageTransition";
|
||||
import TextQuestion from "./tools/TextQuestion";
|
||||
import SubmitButton from "./tools/SubmitButton";
|
||||
import Paragraph from "@editorjs/paragraph";
|
||||
import Header from "@editorjs/header";
|
||||
|
||||
const Editor = ({ id, autofocus = false, onChange, value }) => {
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
const ejInstance = useRef<EditorJS | null>();
|
||||
interface EditorProps {
|
||||
id: string;
|
||||
autofocus: boolean;
|
||||
editorRef: { current: EditorJS | null };
|
||||
formId: string;
|
||||
initAction: (editor: EditorJS) => void;
|
||||
}
|
||||
|
||||
const Editor = ({
|
||||
id,
|
||||
autofocus = false,
|
||||
editorRef,
|
||||
formId,
|
||||
initAction,
|
||||
}: EditorProps) => {
|
||||
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } =
|
||||
useNoCodeForm(formId);
|
||||
|
||||
const keyPressListener = useCallback((e) => {
|
||||
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
toast("snoopForms autosaves your work ✌️");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ejInstance.current) {
|
||||
onChange(blocks);
|
||||
}
|
||||
}, [blocks]);
|
||||
window.addEventListener("keydown", keyPressListener);
|
||||
// Remove event listeners on cleanup
|
||||
return () => {
|
||||
window.removeEventListener("keydown", keyPressListener);
|
||||
};
|
||||
}, [keyPressListener]);
|
||||
|
||||
// This will run only once
|
||||
useEffect(() => {
|
||||
if (!ejInstance.current) {
|
||||
initEditor();
|
||||
if (!isLoadingNoCodeForm) {
|
||||
if (!editorRef.current) {
|
||||
initEditor();
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
destroyEditor();
|
||||
};
|
||||
async function destroyEditor() {
|
||||
await ejInstance.current.isReady;
|
||||
ejInstance.current.destroy();
|
||||
ejInstance.current = null;
|
||||
await editorRef.current.isReady;
|
||||
editorRef.current.destroy();
|
||||
editorRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [isLoadingNoCodeForm]);
|
||||
|
||||
const initEditor = () => {
|
||||
const editor = new EditorJS({
|
||||
minHeight: 0,
|
||||
holder: id,
|
||||
data: value,
|
||||
data: { blocks: noCodeForm.blocksDraft },
|
||||
onReady: () => {
|
||||
ejInstance.current = editor;
|
||||
editorRef.current = editor;
|
||||
new DragDrop(editor);
|
||||
new Undo({ editor });
|
||||
if (editor.blocks.getBlocksCount() === 1) {
|
||||
initAction(editor);
|
||||
}
|
||||
},
|
||||
onChange: async () => {
|
||||
let content = await editor.saver.save();
|
||||
setBlocks(content.blocks);
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.blocksDraft = content.blocks;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
},
|
||||
autofocus: autofocus,
|
||||
defaultBlock: "paragraph",
|
||||
tools: {
|
||||
textQuestion: TextQuestion,
|
||||
pageTransition: PageTransition,
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
inlineToolbar: true,
|
||||
@@ -65,12 +102,14 @@ const Editor = ({ id, autofocus = false, onChange, value }) => {
|
||||
defaultLevel: 1,
|
||||
},
|
||||
},
|
||||
textQuestion: TextQuestion,
|
||||
submitButton: SubmitButton,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingNoCodeForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div id={id} />
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface PageTransitionData extends BlockToolData {
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
export default class PageTransition implements BlockTool {
|
||||
submitLabel: string;
|
||||
placeholder: string;
|
||||
api: API;
|
||||
|
||||
/* static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /> </svg>`,
|
||||
title: "New Page",
|
||||
};
|
||||
} */
|
||||
|
||||
constructor({
|
||||
data,
|
||||
}: {
|
||||
api: API;
|
||||
config?: ToolConfig;
|
||||
data?: PageTransitionData;
|
||||
}) {
|
||||
this.submitLabel = data.label || "Submit";
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
submitLabel: (
|
||||
block.firstElementChild.firstElementChild
|
||||
.firstElementChild as HTMLInputElement
|
||||
).innerHTML,
|
||||
};
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="my-8">
|
||||
<div className="inline-flex items-center px-4 py-3 text-sm font-medium text-white bg-gray-700 border border-transparent rounded-md shadow-sm hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
<div
|
||||
contentEditable
|
||||
id="label"
|
||||
defaultValue={this.submitLabel}
|
||||
className="p-0 bg-transparent border-transparent ring-0 active:ring-0 focus:border-transparent focus:ring-0 focus:outline-none placeholder:text-opacity-5"
|
||||
>
|
||||
{this.submitLabel}
|
||||
</div>
|
||||
{/* <ArrowRightIcon className="w-5 h-5 ml-2 -mr-1" aria-hidden="true" /> */}
|
||||
</div>
|
||||
<div className="relative my-4">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-2 text-sm text-gray-500 bg-white">
|
||||
Next Page
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, container);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface TextQuestionData extends BlockToolData {
|
||||
latexString: string;
|
||||
}
|
||||
|
||||
export default class TextQuestion implements BlockTool {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
api: API;
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M18.1 15.3C18 15.4 17.8 15.5 17.7 15.6L15.3 16L17 19.6C17.2 20 17 20.4 16.6 20.6L13.8 21.9C13.7 22 13.6 22 13.5 22C13.2 22 12.9 21.8 12.8 21.6L11.2 18L9.3 19.5C9.2 19.6 9 19.7 8.8 19.7C8.4 19.7 8 19.4 8 18.9V7.5C8 7 8.3 6.7 8.8 6.7C9 6.7 9.2 6.8 9.3 6.9L18 14.3C18.3 14.5 18.4 15 18.1 15.3M6 12H4V4H20V12H18.4L20.6 13.9C21.4 13.6 21.9 12.9 21.9 12V4C21.9 2.9 21 2 19.9 2H4C2.9 2 2 2.9 2 4V12C2 13.1 2.9 14 4 14H6V12Z" />`,
|
||||
title: "Submit Button",
|
||||
};
|
||||
}
|
||||
|
||||
constructor({
|
||||
data,
|
||||
}: {
|
||||
api: API;
|
||||
config?: ToolConfig;
|
||||
data?: TextQuestionData;
|
||||
}) {
|
||||
this.label = data.label || "Submit";
|
||||
this.placeholder = data.placeholder;
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
label: (block.firstElementChild.firstElementChild as HTMLInputElement)
|
||||
.innerHTML,
|
||||
};
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="inline-flex items-center px-4 py-3 text-sm font-medium text-white bg-gray-700 border border-transparent rounded-md shadow-sm hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
<div
|
||||
contentEditable
|
||||
id="label"
|
||||
defaultValue={this.label}
|
||||
className="p-0 bg-transparent border-transparent ring-0 active:ring-0 focus:border-transparent focus:ring-0 focus:outline-none"
|
||||
>
|
||||
{this.label}
|
||||
</div>
|
||||
{/* <ArrowRightIcon className="w-5 h-5 ml-2 -mr-1" aria-hidden="true" /> */}
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, container);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,16 @@ import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface TextQuestionData extends BlockToolData {
|
||||
latexString: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export default class TextQuestion implements BlockTool {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
settings: { name: string; icon: string }[];
|
||||
api: API;
|
||||
data: any;
|
||||
wrapper: undefined | HTMLElement;
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
@@ -25,12 +28,24 @@ export default class TextQuestion implements BlockTool {
|
||||
config?: ToolConfig;
|
||||
data?: TextQuestionData;
|
||||
}) {
|
||||
this.label = data.label;
|
||||
this.placeholder = data.placeholder;
|
||||
this.wrapper = undefined;
|
||||
this.settings = [
|
||||
{
|
||||
name: "required",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
|
||||
},
|
||||
];
|
||||
this.data = data;
|
||||
this.data = {
|
||||
label: data.label || "",
|
||||
placeholder: data.placeholder || "",
|
||||
required: data.required !== undefined ? data.required : true,
|
||||
};
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
...this.data,
|
||||
label: (
|
||||
block.firstElementChild.firstElementChild
|
||||
.firstElementChild as HTMLInputElement
|
||||
@@ -42,31 +57,70 @@ export default class TextQuestion implements BlockTool {
|
||||
}
|
||||
|
||||
renderSettings(): HTMLElement {
|
||||
return document.createElement("div");
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
let button = document.createElement("div");
|
||||
|
||||
button.classList.add("cdx-settings-button");
|
||||
button.classList.toggle(
|
||||
"cdx-settings-button--active",
|
||||
this.data[tune.name]
|
||||
);
|
||||
button.innerHTML = tune.icon;
|
||||
wrapper.appendChild(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("cdx-settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Click on the Settings Button
|
||||
* @param {string} tune — tune name from this.settings
|
||||
*/
|
||||
_toggleTune(tune) {
|
||||
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
|
||||
|
||||
if (tune === "required") {
|
||||
this.data.required = !this.data.required;
|
||||
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this
|
||||
.data.required
|
||||
? "*"
|
||||
: "";
|
||||
}
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
this.wrapper = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="pb-5">
|
||||
<div className="font-bold leading-7 text-gray-800 text-md sm:truncate">
|
||||
<div className="relative font-bold leading-7 text-gray-800 text-md sm:truncate">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={this.label}
|
||||
className="block w-full p-0 border-0 border-transparent ring-0 focus:ring-0"
|
||||
defaultValue={this.data.label}
|
||||
className="w-full p-0 border-0 border-transparent ring-0 focus:ring-0 placeholder:text-gray-300"
|
||||
placeholder="Your Question"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-red-500 pointer-events-none">
|
||||
*
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full mt-1 text-sm text-gray-400 border-gray-300 rounded-md shadow-sm placeholder:text-gray-300"
|
||||
placeholder="optional placeholder"
|
||||
defaultValue={this.placeholder}
|
||||
defaultValue={this.data.placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, container);
|
||||
return container;
|
||||
ReactDOM.render(toolView, this.wrapper);
|
||||
return this.wrapper;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { SnoopElement, SnoopForm, SnoopPage } from "@snoopforms/react";
|
||||
import { useMemo } from "react";
|
||||
import Loading from "../Loading";
|
||||
|
||||
export default function App({ id = "", formId, blocks, localOnly = false }) {
|
||||
const pages = useMemo(() => {
|
||||
const pages = [];
|
||||
let currentPage = {
|
||||
id: formId, // give the first page the formId as id by default
|
||||
blocks: [],
|
||||
};
|
||||
for (const block of blocks) {
|
||||
if (block.type !== "pageTransition") {
|
||||
currentPage.blocks.push(block);
|
||||
} else {
|
||||
currentPage.blocks.push({
|
||||
data: {
|
||||
label: block.data.submitLabel,
|
||||
},
|
||||
type: "submitButton",
|
||||
});
|
||||
pages.push(currentPage);
|
||||
currentPage = {
|
||||
id: block.id,
|
||||
blocks: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
pages.push(currentPage);
|
||||
return pages;
|
||||
}, [blocks, formId]);
|
||||
|
||||
if (!pages) return <Loading />;
|
||||
|
||||
export default function App({ id = "", formId, pages, localOnly = false }) {
|
||||
return (
|
||||
<div className="w-full px-5 py-5">
|
||||
<SnoopForm
|
||||
@@ -37,7 +68,7 @@ export default function App({ id = "", formId, pages, localOnly = false }) {
|
||||
element:
|
||||
"flex-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full min-w-0 rounded-md sm:text-base border-gray-300",
|
||||
}}
|
||||
required
|
||||
required={block.data.required}
|
||||
/>
|
||||
</div>
|
||||
) : block.type === "submitButton" ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ interface BaseLayoutAuthorizedProps {
|
||||
steps?: any;
|
||||
currentStep?: string;
|
||||
children: React.ReactNode;
|
||||
bgClass?: string;
|
||||
limitHeightScreen?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export default function BaseLayoutAuthorized({
|
||||
steps,
|
||||
currentStep,
|
||||
children,
|
||||
bgClass = "bg-ui-gray-lighter",
|
||||
limitHeightScreen = false,
|
||||
}: BaseLayoutAuthorizedProps) {
|
||||
const { data: session, status } = useSession();
|
||||
@@ -42,15 +44,18 @@ export default function BaseLayoutAuthorized({
|
||||
</Head>
|
||||
<div
|
||||
className={classNames(
|
||||
limitHeightScreen ? "h-screen" : "min-h-screen",
|
||||
"flex bg-ui-gray-lighter h-full"
|
||||
bgClass,
|
||||
limitHeightScreen
|
||||
? "h-screen max-h-screen overflow-hidden"
|
||||
: "min-h-screen",
|
||||
"flex h-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col flex-1 h-full">
|
||||
<header className="w-full">
|
||||
<div className="relative z-10 flex flex-shrink-0 h-16 bg-white border-b shadow-sm border-ui-gray-light">
|
||||
<div className="flex justify-between flex-1">
|
||||
<div className="inline-flex flex-1 gap-8">
|
||||
<div className="flex flex-1 space-x-8">
|
||||
<NewFormNavButton />
|
||||
<MenuBreadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
@@ -59,8 +64,10 @@ export default function BaseLayoutAuthorized({
|
||||
<MenuSteps steps={steps} currentStep={currentStep} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end flex-1 mr-4 space-x-2 text-right sm:ml-6 sm:space-x-4">
|
||||
<MenuProfile />
|
||||
<div className="flex items-center justify-end flex-1 space-x-2 text-right sm:space-x-4">
|
||||
<div className="mr-6">
|
||||
<MenuProfile />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function NewFormNavButton({}) {
|
||||
<li>
|
||||
<div className="inline-flex items-center px-6 py-2 text-sm font-medium leading-4 bg-transparent border border-transparent hover:text-white focus:outline-none">
|
||||
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Start New Form
|
||||
create form
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -17,33 +17,31 @@ interface Props {
|
||||
// button component, consuming props
|
||||
const SecondNavBar: React.FC<Props> = ({ navItems, currentItemId }) => {
|
||||
return (
|
||||
<div className="relative flex flex-shrink-0 h-16 border-b border-ui-gray-light bg-ui-gray-lighter">
|
||||
<div className="flex items-center justify-center flex-1 px-4 py-2">
|
||||
<nav className="flex space-x-10" aria-label="resultModes">
|
||||
{navItems.map((navItem) => (
|
||||
<button
|
||||
key={navItem.id}
|
||||
className={classNames(
|
||||
`h-16 text-xs`,
|
||||
!navItem.disabled &&
|
||||
(navItem.id === currentItemId
|
||||
? "text-red border-b-2 border-red"
|
||||
: "hover:border-b-2 hover:border-gray-300 text-ui-gray-dark hover:text-red bg-transparent"),
|
||||
navItem.disabled
|
||||
? "text-ui-gray-medium"
|
||||
: "hover:border-b-2 hover:border-red text-ui-gray-dark hover:text-red"
|
||||
)}
|
||||
onClick={navItem.onClick}
|
||||
disabled={navItem.disabled}
|
||||
>
|
||||
{navItem.Icon && (
|
||||
<navItem.Icon className="w-6 h-6 mx-auto mb-1 stroke-1" />
|
||||
)}
|
||||
{navItem.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center justify-center flex-shrink-0 border-b border-ui-gray-light bg-ui-gray-lighter">
|
||||
<nav className="flex space-x-10" aria-label="resultModes">
|
||||
{navItems.map((navItem) => (
|
||||
<button
|
||||
key={navItem.id}
|
||||
className={classNames(
|
||||
`h-16 text-xs border-b-2 border-transparent`,
|
||||
!navItem.disabled &&
|
||||
(navItem.id === currentItemId
|
||||
? "text-red border-b-2 border-red"
|
||||
: "hover:border-gray-300 text-ui-gray-dark hover:text-red bg-transparent"),
|
||||
navItem.disabled
|
||||
? "text-ui-gray-medium"
|
||||
: "hover:border-b-2 hover:border-red text-ui-gray-dark hover:text-red"
|
||||
)}
|
||||
onClick={navItem.onClick}
|
||||
disabled={navItem.disabled}
|
||||
>
|
||||
{navItem.Icon && (
|
||||
<navItem.Icon className="w-6 h-6 mx-auto mb-1 stroke-1" />
|
||||
)}
|
||||
{navItem.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -116,3 +116,14 @@ export const timeSince = (dateString: string) => {
|
||||
addSuffix: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const generateId = (length) => {
|
||||
let result = "";
|
||||
const characters =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -29,6 +29,11 @@ export default async function handle(
|
||||
where: {
|
||||
formId: formId,
|
||||
},
|
||||
include: {
|
||||
form: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return res.json(data);
|
||||
}
|
||||
@@ -37,7 +42,16 @@ export default async function handle(
|
||||
// Required fields in body: -
|
||||
// Optional fields in body: title, published, finishedOnboarding, elements, elementsDraft
|
||||
else if (req.method === "POST") {
|
||||
const data = { ...req.body, updatedAt: new Date() };
|
||||
const { id, createdAt, blocks, blocksDraft, published } = req.body;
|
||||
const data = {
|
||||
id,
|
||||
createdAt,
|
||||
blocks,
|
||||
blocksDraft,
|
||||
formId,
|
||||
published,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
// create or update record
|
||||
const prismaRes = await prisma.noCodeForm.upsert({
|
||||
where: { formId },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { getSession } from "next-auth/react";
|
||||
import { generateId } from "../../../lib/utils";
|
||||
|
||||
export default async function handle(
|
||||
req: NextApiRequest,
|
||||
@@ -66,17 +67,6 @@ export default async function handle(
|
||||
}
|
||||
}
|
||||
|
||||
const generateId = (length) => {
|
||||
let result = "";
|
||||
const characters =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const checkIdAvailability = async (id) => {
|
||||
const form = await prisma.form.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function handle(
|
||||
select: {
|
||||
id: true,
|
||||
published: true,
|
||||
pages: true,
|
||||
blocks: true,
|
||||
},
|
||||
});
|
||||
if (data === null) return res.status(404).json({ error: "not found" });
|
||||
|
||||
+2
-9
@@ -2,7 +2,6 @@ import { GetServerSideProps } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import App from "../../components/frontend/App";
|
||||
import Loading from "../../components/Loading";
|
||||
import { useNoCodeFormPublic } from "../../lib/noCodeForm";
|
||||
@@ -13,17 +12,11 @@ export default function Share({}) {
|
||||
const { noCodeForm, isLoadingNoCodeForm, isErrorNoCodeForm } =
|
||||
useNoCodeFormPublic(formId);
|
||||
|
||||
const pages = useMemo(() => {
|
||||
if (!isLoadingNoCodeForm && !isErrorNoCodeForm) {
|
||||
return noCodeForm["pages"];
|
||||
}
|
||||
}, [isLoadingNoCodeForm, noCodeForm, isErrorNoCodeForm]);
|
||||
|
||||
if (isErrorNoCodeForm) {
|
||||
return <p>Not found</p>;
|
||||
}
|
||||
|
||||
if (isLoadingNoCodeForm || !pages) {
|
||||
if (isLoadingNoCodeForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -32,7 +25,7 @@ export default function Share({}) {
|
||||
<Head>
|
||||
<title>SnoopForms</title>
|
||||
</Head>
|
||||
<App formId={formId} pages={pages} />
|
||||
<App formId={formId} blocks={noCodeForm.blocks} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function FormPage() {
|
||||
breadcrumbs={breadcrumbs}
|
||||
steps={formMenuSteps}
|
||||
currentStep="form"
|
||||
bgClass="bg-white"
|
||||
limitHeightScreen={true}
|
||||
>
|
||||
<FullWidth>
|
||||
<Builder formId={formId} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GetServerSideProps } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import App from "../../../components/frontend/App";
|
||||
@@ -17,22 +17,13 @@ export default function Share({}) {
|
||||
const [appId, setAppId] = useState(uuidv4());
|
||||
|
||||
const { noCodeForm, isLoadingNoCodeForm } = useNoCodeForm(formId);
|
||||
const pages = useMemo(() => {
|
||||
if (!isLoadingNoCodeForm) {
|
||||
return noCodeForm["pagesDraft"];
|
||||
}
|
||||
}, [isLoadingNoCodeForm, noCodeForm]);
|
||||
|
||||
if (!pages) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const resetApp = () => {
|
||||
setAppId(uuidv4());
|
||||
toast("Form reset successful");
|
||||
toast("Form resetted 👌");
|
||||
};
|
||||
|
||||
if (isLoadingForm) {
|
||||
if (isLoadingForm || isLoadingNoCodeForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -44,7 +35,12 @@ export default function Share({}) {
|
||||
|
||||
return (
|
||||
<LayoutPreview formId={formId} resetApp={resetApp}>
|
||||
<App id={appId} pages={pages} localOnly={true} formId={formId} />
|
||||
<App
|
||||
id={appId}
|
||||
blocks={noCodeForm.blocksDraft}
|
||||
localOnly={true}
|
||||
formId={formId}
|
||||
/>
|
||||
</LayoutPreview>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `pages` on the `NoCodeForm` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `pagesDraft` on the `NoCodeForm` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "NoCodeForm" DROP COLUMN "pages",
|
||||
DROP COLUMN "pagesDraft",
|
||||
ADD COLUMN "blocks" JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN "blocksDraft" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -36,8 +36,8 @@ model NoCodeForm {
|
||||
updatedAt DateTime @updatedAt
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
formId String @unique
|
||||
pages Json @default("[]")
|
||||
pagesDraft Json @default("[]")
|
||||
blocks Json @default("[]")
|
||||
blocksDraft Json @default("[]")
|
||||
published Boolean @default(false)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user