mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-13 11:09:29 -05:00
feat: add first version of nocode editor without preview/publish/share
This commit is contained in:
52
components/LoadingModal.tsx
Normal file
52
components/LoadingModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { TailSpin } from "react-loader-spinner";
|
||||
|
||||
export default function LoadingModal({ isLoading }) {
|
||||
return (
|
||||
<Transition.Root show={isLoading} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
className="fixed inset-0 z-10 overflow-y-auto"
|
||||
open={isLoading}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-20" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span
|
||||
className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center px-4 py-20 pb-4 overflow-hidden text-left align-bottom transition-all transform rounded-lg sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||
<TailSpin color="#000" height={50} width={50} />
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,104 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import Loading from "../Loading";
|
||||
import Page from "./Page";
|
||||
import UsageIntro from "./UsageIntro";
|
||||
import LoadingModal from "../LoadingModal";
|
||||
|
||||
export default function Builder({ formId }) {
|
||||
const { noCodeForm, mutateNoCodeForm, isLoadingNoCodeForm } =
|
||||
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } =
|
||||
useNoCodeForm(formId);
|
||||
const [pagesDraft, setPagesDraft] = useState([]);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
setIsLoading(true);
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.pagesDraft = pagesDraft;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const addPage = useCallback(() => {
|
||||
if (noCodeForm) {
|
||||
const updatedNCF = JSON.parse(JSON.stringify(noCodeForm));
|
||||
updatedNCF.pages.push({
|
||||
id: uuidv4(),
|
||||
elements: [],
|
||||
});
|
||||
mutateNoCodeForm(updatedNCF, false);
|
||||
const newPagesDraft = JSON.parse(JSON.stringify(pagesDraft));
|
||||
newPagesDraft.push({
|
||||
id: uuidv4(),
|
||||
blocks: [],
|
||||
});
|
||||
setPagesDraft(newPagesDraft);
|
||||
}, [pagesDraft, setPagesDraft]);
|
||||
|
||||
const deletePage = (pageIdx) => {
|
||||
const newPagesDraft = JSON.parse(JSON.stringify(pagesDraft));
|
||||
newPagesDraft.splice(pageIdx, 1);
|
||||
setPagesDraft(newPagesDraft);
|
||||
};
|
||||
|
||||
const initPages = useCallback(() => {
|
||||
if (!isLoadingNoCodeForm && !isInitialized) {
|
||||
if (noCodeForm.pagesDraft.length === 0) {
|
||||
addPage();
|
||||
} else {
|
||||
setPagesDraft(noCodeForm.pagesDraft);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [mutateNoCodeForm, noCodeForm]);
|
||||
}, [isLoadingNoCodeForm, noCodeForm, addPage, isInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (noCodeForm && noCodeForm.pages.length === 0) addPage();
|
||||
}, [noCodeForm]);
|
||||
initPages();
|
||||
}, [isLoadingNoCodeForm, initPages]);
|
||||
|
||||
if (isLoadingNoCodeForm) {
|
||||
return <Loading />;
|
||||
<Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-gray-100">
|
||||
<div className="flex justify-center w-full mt-10">
|
||||
<div className="w-full max-w-5xl">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{noCodeForm.pages.map((page) => (
|
||||
<Page key={page.id} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addPage()}
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 mt-3 text-sm font-medium text-gray-700 border border-gray-300 border-dashed rounded-md bg-gray-50 hover:bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500"
|
||||
>
|
||||
+ Add Page
|
||||
</button>
|
||||
<>
|
||||
<div className="relative z-10 flex flex-shrink-0 h-16 border-b border-gray-200 shadow-inner bg-gray-50">
|
||||
<div className="flex items-center justify-center flex-1 px-4">
|
||||
<nav className="flex space-x-4" aria-label="resultModes">
|
||||
<button
|
||||
onClick={() => save()}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-600 border border-gray-800 rounded-md hover:text-gray-600"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addPage()}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-600 border border-gray-800 rounded-md hover:text-gray-600"
|
||||
>
|
||||
Add Page
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-100">
|
||||
<div className="flex justify-center w-full mt-10">
|
||||
<div className="w-full px-4 max-w-7xl">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div className="px-10">
|
||||
<UsageIntro />
|
||||
</div>
|
||||
{pagesDraft.map((page, pageIdx) => (
|
||||
<Page
|
||||
key={page.id}
|
||||
page={page}
|
||||
pageIdx={pageIdx}
|
||||
pagesDraft={pagesDraft}
|
||||
setPagesDraft={setPagesDraft}
|
||||
deletePageAction={deletePage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingModal isLoading={isLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { createReactEditorJS } from "react-editor-js";
|
||||
|
||||
const ReactEditorJS = createReactEditorJS();
|
||||
|
||||
const Editor = ({}) => {
|
||||
const editorCore = useRef(null);
|
||||
|
||||
const handleInitialize = useCallback((instance) => {
|
||||
editorCore.current = instance;
|
||||
}, []);
|
||||
|
||||
/* const handleSave = useCallback(async () => {
|
||||
const savedData = await editorCore.current.save();
|
||||
console.log(savedData);
|
||||
}, []);
|
||||
|
||||
setTimeout(() => {
|
||||
// save every ten seconds
|
||||
handleSave();
|
||||
}, 10000); */
|
||||
|
||||
const EDITOR_JS_TOOLS = {};
|
||||
|
||||
// Editor.js This will show block editor in component
|
||||
// pass EDITOR_JS_TOOLS in tools props to configure tools with editor.js
|
||||
return (
|
||||
<ReactEditorJS
|
||||
onInitialize={handleInitialize}
|
||||
tools={EDITOR_JS_TOOLS}
|
||||
minHeight={0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Return the CustomEditor to use by other components.
|
||||
|
||||
export default Editor;
|
||||
@@ -1,12 +1,56 @@
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import dynamic from "next/dynamic";
|
||||
let Editor = dynamic(() => import("./Editor"), {
|
||||
let Editor = dynamic(() => import("../editorjs/Editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Page({}) {
|
||||
export default function Page({
|
||||
page,
|
||||
pageIdx,
|
||||
pagesDraft,
|
||||
setPagesDraft,
|
||||
deletePageAction,
|
||||
}) {
|
||||
const updatePage = (blocks) => {
|
||||
const newPagesDraft = JSON.parse(JSON.stringify(pagesDraft));
|
||||
if (pageIdx < newPagesDraft.length) {
|
||||
newPagesDraft[pageIdx].blocks = blocks;
|
||||
setPagesDraft(newPagesDraft);
|
||||
} else {
|
||||
throw Error(
|
||||
`updatePage error: Page at position ${pageIdx} not found in pagesDraft`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-10 bg-white rounded-lg">
|
||||
{Editor && <Editor />}
|
||||
<div className="flex w-full">
|
||||
<div className="flex w-8">
|
||||
{pageIdx !== 0 && (
|
||||
<button
|
||||
className="flex items-center h-full text-gray-400"
|
||||
onClick={() => {
|
||||
if (confirm("Do you really want to delete this page?")) {
|
||||
deletePageAction(pageIdx);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-full p-10 bg-white rounded-lg">
|
||||
<div className="relative">
|
||||
{Editor && (
|
||||
<Editor
|
||||
id={`${page.id}-editor`}
|
||||
autofocus={pageIdx === 0}
|
||||
onChange={(blocks) => updatePage(blocks)}
|
||||
value={pagesDraft[pageIdx]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
36
components/builder/UsageIntro.tsx
Normal file
36
components/builder/UsageIntro.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function UsageIntro() {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
return (
|
||||
!dismissed && (
|
||||
<div className="p-4 bg-gray-100 border border-gray-700 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon
|
||||
className="w-5 h-5 text-blue-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 ml-3 md:flex md:justify-between">
|
||||
<p className="text-sm text-gray-700">
|
||||
Welcome to the snoopForms No-Code Editor. Use 'tab' to
|
||||
add new blocks or change their options. You can also drag 'n
|
||||
drop blocks to reorder them.
|
||||
</p>
|
||||
<p className="mt-3 text-sm md:mt-0 md:ml-6">
|
||||
<a
|
||||
onClick={() => setDismissed(true)}
|
||||
className="font-medium text-gray-700 whitespace-nowrap hover:text-gray-600"
|
||||
>
|
||||
Dismiss
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
57
components/editorjs/Editor.tsx
Normal file
57
components/editorjs/Editor.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import EditorJS from "@editorjs/editorjs";
|
||||
import DragDrop from "editorjs-drag-drop";
|
||||
import Undo from "editorjs-undo";
|
||||
import TextQuestion from "./tools/TextQuestion";
|
||||
|
||||
const Editor = ({ id, autofocus = false, onChange, value }) => {
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
const ejInstance = useRef<EditorJS | null>();
|
||||
|
||||
useEffect(() => {
|
||||
onChange(blocks);
|
||||
}, [blocks]);
|
||||
|
||||
// This will run only once
|
||||
useEffect(() => {
|
||||
if (!ejInstance.current) {
|
||||
initEditor();
|
||||
}
|
||||
return () => {
|
||||
destroyEditor();
|
||||
};
|
||||
async function destroyEditor() {
|
||||
await ejInstance.current.isReady;
|
||||
ejInstance.current.destroy();
|
||||
ejInstance.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initEditor = () => {
|
||||
const editor = new EditorJS({
|
||||
minHeight: 0,
|
||||
holder: id,
|
||||
data: value,
|
||||
onReady: () => {
|
||||
ejInstance.current = editor;
|
||||
new DragDrop(editor);
|
||||
new Undo({ editor });
|
||||
},
|
||||
onChange: async () => {
|
||||
let content = await editor.saver.save();
|
||||
setBlocks(content.blocks);
|
||||
},
|
||||
autofocus: autofocus,
|
||||
tools: { textQuestion: TextQuestion },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div id={id} />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
77
components/editorjs/tools/TextQuestion.tsx
Normal file
77
components/editorjs/tools/TextQuestion.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
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" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-justify"><line x1="21" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="3" y2="18"></line></svg>`,
|
||||
title: "Text Question",
|
||||
};
|
||||
}
|
||||
|
||||
constructor({
|
||||
data,
|
||||
}: {
|
||||
api: API;
|
||||
config?: ToolConfig;
|
||||
data?: TextQuestionData;
|
||||
}) {
|
||||
this.label = data.label;
|
||||
this.placeholder = data.placeholder;
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
// console.log(block)
|
||||
// ;(window as any).x = block
|
||||
|
||||
return {
|
||||
label: (block.firstElementChild.firstElementChild as HTMLInputElement)
|
||||
.value,
|
||||
placeholder: (
|
||||
block.firstElementChild.lastElementChild as HTMLInputElement
|
||||
).value,
|
||||
};
|
||||
}
|
||||
/* renderSettings(): HTMLElement {
|
||||
return document.createElement("div");
|
||||
} */
|
||||
|
||||
render(): HTMLElement {
|
||||
/* this.wrapperDiv.innerHTML = "";
|
||||
this.latexDiv.innerHTML = "";
|
||||
this.renderLatex();
|
||||
this.wrapperDiv.append(this.latexDiv);
|
||||
this.wrapperDiv.append(this.editTextfield);
|
||||
|
||||
return this.wrapperDiv; */
|
||||
const container = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="pb-3">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={this.label}
|
||||
className="block p-0 text-sm font-medium text-gray-700 border-0 border-transparent ring-0 focus:ring-0"
|
||||
placeholder="Your Question"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full mt-1 text-gray-400 border-gray-300 rounded-md shadow-sm sm:text-sm placeholder:text-gray-300"
|
||||
placeholder="optional placeholder"
|
||||
defaultValue={this.placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, container);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const libs = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function FormCode() {
|
||||
export default function FormCode({ formId }) {
|
||||
const [selectedLib, setSelectedLib] = useState(null);
|
||||
|
||||
return (
|
||||
@@ -54,6 +54,32 @@ export default function FormCode() {
|
||||
programming language or framework.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="my-5 text-gray-600" />
|
||||
<div>
|
||||
<label htmlFor="formId" className="block text-base text-gray-800">
|
||||
Your form ID
|
||||
</label>
|
||||
<div className="mt-5 sm:flex sm:items-center">
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<input
|
||||
id="formId"
|
||||
type="text"
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-snoopred-500 focus:border-snoopred-500 sm:text-sm disabled:bg-gray-100"
|
||||
value={formId}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(formId);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-5 text-gray-600 " />
|
||||
<div className="mt-5">
|
||||
<RadioGroup value={selectedLib} onChange={setSelectedLib}>
|
||||
<RadioGroup.Label className="text-xs font-medium tracking-wide text-gray-500 uppercase">
|
||||
|
||||
@@ -40,18 +40,6 @@ export default function LayoutFormResults({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-shrink-0 h-16 border-b border-gray-200 shadow-inner bg-gray-50">
|
||||
<div className="flex items-center justify-center flex-1 px-4">
|
||||
<nav className="flex space-x-4" aria-label="resultModes">
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-600 border border-gray-800 rounded-md hover:text-gray-600"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
Reference in New Issue
Block a user