Feature/add question types (#9)

* add new question types: email, website, phone, number & multiple choice
This commit is contained in:
Matthias Nannt
2022-07-29 00:21:32 +02:00
committed by GitHub
parent 6da264f432
commit aa648f9b2d
20 changed files with 1628 additions and 569 deletions

View File

@@ -87,7 +87,7 @@ yarn dev
```
**You can now access the app on [https://localhost:3000](https://localhost:3000)**
**You can now access the app on [https://localhost:3000](https://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of snoopForms, create a new account.
## Deployment
@@ -117,7 +117,7 @@ docker compose up -d
```
You can now access the app on [https://localhost:3000](https://localhost:3000)
You can now access the app on [https://localhost:3000](https://localhost:3000). You will be automatically redirected to the login. To use your local installation of snoopForms, create a new account.
## Contributing

View File

@@ -8,8 +8,13 @@ import { Fragment, useCallback, useEffect } from "react";
import { toast } from "react-toastify";
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
import Loading from "../Loading";
import EmailQuestion from "./tools/EmailQuestion";
import PageTransition from "./tools/PageTransition";
import MultipleChoiceQuestion from "./tools/MultipleChoiceQuestion";
import TextQuestion from "./tools/TextQuestion";
import WebsiteQuestion from "./tools/WebsiteQuestion";
import PhoneQuestion from "./tools/PhoneQuestion";
import NumberQuestion from "./tools/NumberQuestion";
interface EditorProps {
id: string;
@@ -85,6 +90,11 @@ const Editor = ({
defaultBlock: "paragraph",
tools: {
textQuestion: TextQuestion,
emailQuestion: EmailQuestion,
multipleChoiceQuestion: MultipleChoiceQuestion,
numberQuestion: NumberQuestion,
phoneQuestion: PhoneQuestion,
websiteQuestion: WebsiteQuestion,
pageTransition: PageTransition,
paragraph: {
class: Paragraph,

View File

@@ -0,0 +1,135 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import { MailIcon } from "@heroicons/react/solid";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface EmailQuestionData extends BlockToolData {
label: string;
placeholder: string;
required: boolean;
}
export default class EmailQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M14.243 5.757a6 6 0 10-.986 9.284 1 1 0 111.087 1.678A8 8 0 1118 10a3 3 0 01-4.8 2.401A4 4 0 1114 10a1 1 0 102 0c0-1.537-.586-3.07-1.757-4.243zM12 10a2 2 0 10-4 0 2 2 0 004 0z" clip-rule="evenodd" />
</svg>`,
title: "Email Question",
};
}
constructor({
data,
}: {
api: API;
config?: ToolConfig;
data?: EmailQuestionData;
}) {
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"><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 || "your email",
required: data.required !== undefined ? data.required : true,
};
}
save(block: HTMLDivElement) {
return {
...this.data,
label: (
block.firstElementChild.firstElementChild
.firstElementChild as HTMLInputElement
).value,
placeholder: (
block.firstElementChild.lastElementChild
.lastElementChild as HTMLInputElement
).value,
};
}
renderSettings(): HTMLElement {
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 {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="relative font-bold leading-7 text-gray-800 text-md sm:truncate">
<input
type="text"
id="label"
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>
<div className="relative max-w-sm mt-1 rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<MailIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="email"
className="block w-full pl-10 text-gray-300 border-gray-300 rounded-md sm:text-sm"
defaultValue={this.data.placeholder}
/>
</div>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -0,0 +1,130 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import { default as React } from "react";
import ReactDOM from "react-dom";
import MultipleChoiceQuestionComponent from "./MultipleChoiceQuestionComponent";
interface MultipleChoiceQuestionData extends BlockToolData {
label: string;
options: string[];
required: boolean;
}
export default class MultipleChoiceQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
config: ToolConfig;
data: any;
readOnly: boolean;
block: any;
wrapper: undefined | HTMLElement;
nodes: { holder: HTMLElement };
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="16" height="16" viewBox="0 0 16 16">
<path fill="#000000" d="M8 0c-4.418 0-8 3.582-8 8s3.582 8 8 8 8-3.582 8-8-3.582-8-8-8zM8 14c-3.314 0-6-2.686-6-6s2.686-6 6-6c3.314 0 6 2.686 6 6s-2.686 6-6 6zM5 8c0-1.657 1.343-3 3-3s3 1.343 3 3c0 1.657-1.343 3-3 3s-3-1.343-3-3z"/>
</svg>`,
title: "Multiple Choice Question",
};
}
static get isReadOnlySupported() {
return true;
}
constructor({
data,
config,
api,
readOnly,
}: {
api: API;
config?: ToolConfig;
data?: MultipleChoiceQuestionData;
block?: any;
readOnly: boolean;
}) {
this.api = api;
this.config = config;
this.readOnly = readOnly;
this.data = {
label: data.label || "",
options: data.options || [],
required: data.required || false,
multipleChoice: data.multipleChoice || false,
};
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.nodes = {
holder: null,
};
}
renderSettings(): HTMLElement {
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;
}
}
render() {
const rootNode = document.createElement("div");
//rootNode.setAttribute("class", this.CSS.wrapper);
this.nodes.holder = rootNode;
const onDataChange = (newData) => {
this.data = {
...newData,
};
};
ReactDOM.render(
<MultipleChoiceQuestionComponent
onDataChange={onDataChange}
readOnly={this.readOnly}
data={this.data}
/>,
rootNode
);
return this.nodes.holder;
}
save() {
return this.data;
}
}

View File

@@ -0,0 +1,168 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { Switch } from "@headlessui/react";
import { TrashIcon } from "@heroicons/react/solid";
import { default as React } from "react";
import { v4 as uuidv4 } from "uuid";
import { classNames } from "../../../lib/utils";
const DEFAULT_INITIAL_DATA = () => {
return {
label: "",
required: false,
multipleChoice: false,
options: [
{
id: uuidv4(),
label: "",
},
],
};
};
const SingleChoiceQuestion = (props) => {
const [choiceData, setChoiceData] = React.useState(
props.data.options.length > 0 ? props.data : DEFAULT_INITIAL_DATA
);
const updateData = (newData) => {
setChoiceData(newData);
if (props.onDataChange) {
// Inform editorjs about data change
props.onDataChange(newData);
}
};
const onAddOption = () => {
const newData = {
...choiceData,
};
newData.options.push({
id: uuidv4(),
label: "",
});
updateData(newData);
};
const onDeleteOption = (optionIdx) => {
const newData = {
...choiceData,
};
newData.options.splice(optionIdx, 1);
updateData(newData);
};
const onInputChange = (fieldName) => {
return (e) => {
const newData = {
...choiceData,
};
newData[fieldName] = e.currentTarget.value;
updateData(newData);
};
};
const onOptionChange = (index, fieldName) => {
return (e) => {
const newData = {
...choiceData,
};
newData.options[index][fieldName] = e.currentTarget.value;
updateData(newData);
};
};
return (
<div className="pb-5">
<div className="relative font-bold leading-7 text-gray-800 text-md sm:truncate">
<input
type="text"
id="label"
defaultValue={choiceData.label}
onBlur={onInputChange("label")}
className="w-full p-0 border-0 border-transparent ring-0 focus:ring-0 placeholder:text-gray-300"
placeholder="Your Question"
/>
{choiceData.required && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-red-500 pointer-events-none">
*
</div>
)}
</div>
<div className="max-w-sm mt-2 space-y-2">
{choiceData.options.map((option, optionIdx) => (
<div
key={option.label}
className={classNames("relative flex items-start")}
>
<span className="flex items-center text-sm">
<span
className={classNames(
choiceData.multipleChoice ? "rounded-sm" : "rounded-full",
"flex items-center justify-center w-4 h-4 bg-white border border-gray-300"
)}
aria-hidden="true"
>
<span className="rounded-full bg-white w-1.5 h-1.5" />
</span>
<input
type="text"
defaultValue={option.label}
onBlur={onOptionChange(optionIdx, "label")}
className="p-0 ml-3 font-medium text-gray-900 border-0 border-transparent outline-none focus:ring-0 focus:outline-none placeholder:text-gray-300"
placeholder={`Option ${optionIdx + 1}`}
/>
</span>
{optionIdx !== 0 && (
<button
onClick={() => onDeleteOption(optionIdx)}
className="absolute p-1 right-3"
>
<TrashIcon className="w-4 h-4 text-gray-300" />
</button>
)}
</div>
))}
</div>
<div className="relative z-0 flex mt-2 divide-x divide-gray-200">
<button
className="mr-3 justify-center mt-2 inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
onClick={onAddOption}
>
Add option
</button>
<Switch.Group as="div" className="flex items-center pl-3">
<Switch
checked={choiceData.multipleChoice}
onChange={() => {
const newData = {
...choiceData,
};
newData.multipleChoice = !newData.multipleChoice;
updateData(newData);
}}
className={classNames(
choiceData.multipleChoice ? "bg-red-600" : "bg-gray-200",
"relative inline-flex flex-shrink-0 h-4 w-7 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
)}
>
<span
aria-hidden="true"
className={classNames(
choiceData.multipleChoice ? "translate-x-3" : "translate-x-0",
"pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm font-medium text-gray-700">
Multiple Selection{" "}
</span>
{/* <span className="text-sm text-gray-500">(Save 10%)</span> */}
</Switch.Label>
</Switch.Group>
</div>
</div>
);
};
export default SingleChoiceQuestion;

View File

@@ -0,0 +1,128 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface NumberQuestionData extends BlockToolData {
label: string;
placeholder: string;
required: boolean;
}
export default class NumberQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.243 3.03a1 1 0 01.727 1.213L9.53 6h2.94l.56-2.243a1 1 0 111.94.486L14.53 6H17a1 1 0 110 2h-2.97l-1 4H15a1 1 0 110 2h-2.47l-.56 2.242a1 1 0 11-1.94-.485L10.47 14H7.53l-.56 2.242a1 1 0 11-1.94-.485L5.47 14H3a1 1 0 110-2h2.97l1-4H5a1 1 0 110-2h2.47l.56-2.243a1 1 0 011.213-.727zM9.03 8l-1 4h2.938l1-4H9.031z" clip-rule="evenodd" />
</svg>`,
title: "Number Question",
};
}
constructor({
data,
}: {
api: API;
config?: ToolConfig;
data?: NumberQuestionData;
}) {
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
).value,
placeholder: (
block.firstElementChild.lastElementChild as HTMLInputElement
).value,
};
}
renderSettings(): HTMLElement {
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 {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="relative font-bold leading-7 text-gray-800 text-md sm:truncate">
<input
type="text"
id="label"
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 max-w-sm mt-1 text-sm text-gray-400 border-gray-300 rounded-md shadow-sm placeholder:text-gray-300"
placeholder="optional placeholder"
defaultValue={this.data.placeholder}
/>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -0,0 +1,135 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import { PhoneIcon } from "@heroicons/react/solid";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface PhoneQuestionData extends BlockToolData {
label: string;
placeholder: string;
required: boolean;
}
export default class PhoneQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
</svg>`,
title: "Phone Question",
};
}
constructor({
data,
}: {
api: API;
config?: ToolConfig;
data?: PhoneQuestionData;
}) {
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"><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 || "+1 (555) 987-6543",
required: data.required !== undefined ? data.required : true,
};
}
save(block: HTMLDivElement) {
return {
...this.data,
label: (
block.firstElementChild.firstElementChild
.firstElementChild as HTMLInputElement
).value,
placeholder: (
block.firstElementChild.lastElementChild
.lastElementChild as HTMLInputElement
).value,
};
}
renderSettings(): HTMLElement {
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 {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="relative font-bold leading-7 text-gray-800 text-md sm:truncate">
<input
type="text"
id="label"
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>
<div className="relative max-w-sm mt-1 rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<PhoneIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="website"
className="block w-full pl-10 text-gray-300 border-gray-300 rounded-md sm:text-sm"
defaultValue={this.data.placeholder}
/>
</div>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -114,7 +114,7 @@ export default class TextQuestion implements BlockTool {
</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"
className="block w-full max-w-sm mt-1 text-sm text-gray-400 border-gray-300 rounded-md shadow-sm placeholder:text-gray-300"
placeholder="optional placeholder"
defaultValue={this.data.placeholder}
/>

View File

@@ -0,0 +1,138 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import { GlobeAltIcon } from "@heroicons/react/solid";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface WebsiteQuestionData extends BlockToolData {
label: string;
placeholder: string;
required: boolean;
}
export default class WebsiteQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.083 9h1.946c.089-1.546.383-2.97.837-4.118A6.004 6.004 0 004.083 9zM10 2a8 8 0 100 16 8 8 0 000-16zm0 2c-.076 0-.232.032-.465.262-.238.234-.497.623-.737 1.182-.389.907-.673 2.142-.766 3.556h3.936c-.093-1.414-.377-2.649-.766-3.556-.24-.56-.5-.948-.737-1.182C10.232 4.032 10.076 4 10 4zm3.971 5c-.089-1.546-.383-2.97-.837-4.118A6.004 6.004 0 0115.917 9h-1.946zm-2.003 2H8.032c.093 1.414.377 2.649.766 3.556.24.56.5.948.737 1.182.233.23.389.262.465.262.076 0 .232-.032.465-.262.238-.234.498-.623.737-1.182.389-.907.673-2.142.766-3.556zm1.166 4.118c.454-1.147.748-2.572.837-4.118h1.946a6.004 6.004 0 01-2.783 4.118zm-6.268 0C6.412 13.97 6.118 12.546 6.03 11H4.083a6.004 6.004 0 002.783 4.118z" clip-rule="evenodd" />
</svg>`,
title: "Website Question",
};
}
constructor({
data,
}: {
api: API;
config?: ToolConfig;
data?: WebsiteQuestionData;
}) {
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"><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 || "https://",
required: data.required !== undefined ? data.required : true,
};
}
save(block: HTMLDivElement) {
return {
...this.data,
label: (
block.firstElementChild.firstElementChild
.firstElementChild as HTMLInputElement
).value,
placeholder: (
block.firstElementChild.lastElementChild
.lastElementChild as HTMLInputElement
).value,
};
}
renderSettings(): HTMLElement {
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 {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="relative font-bold leading-7 text-gray-800 text-md sm:truncate">
<input
type="text"
id="label"
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>
<div className="relative max-w-sm mt-1 rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<GlobeAltIcon
className="w-5 h-5 text-gray-400"
aria-hidden="true"
/>
</div>
<input
type="text"
name="website"
className="block w-full pl-10 text-gray-300 border-gray-300 rounded-md sm:text-sm"
defaultValue={this.data.placeholder}
/>
</div>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -127,53 +127,53 @@ export default function FormCode({ formId }) {
className="grid grid-cols-1 gap-5 mt-3 sm:gap-6 sm:grid-cols-2"
>
{libs.map((lib) => (
<a
className="flex col-span-1 rounded-md shadow-sm"
key={lib.id}
href={lib.href}
target={lib.target || ""}
rel="noreferrer"
>
<li
className={classNames(
lib.comingSoon
? "text-ui-gray-medium"
: "shadow-sm text-ui-gray-dark hover:text-black",
"flex col-span-1 rounded-md w-full"
)}
<Link key={lib.id} href={lib.href}>
<a
className="flex col-span-1 rounded-md shadow-sm"
target={lib.target || ""}
rel="noreferrer"
>
<div
<li
className={classNames(
lib.bgColor,
"flex-shrink-0 flex items-center justify-center w-20 text-white text-sm font-medium rounded-l-md"
lib.comingSoon
? "text-ui-gray-medium"
: "shadow-sm text-ui-gray-dark hover:text-black",
"flex col-span-1 rounded-md w-full"
)}
>
<lib.icon
<div
className={classNames(
lib.comingSoon
? "text-ui-gray-medium"
: "text-white stroke-1",
"w-10 h-10"
)}
/>
</div>
<div
className={classNames(
lib.comingSoon ? "border-dashed" : "",
"flex items-center justify-between flex-1 truncate bg-white rounded-r-md"
)}
>
<div className="inline-flex px-4 py-6 text-lg truncate">
<p className="font-light">{lib.name}</p>
{lib.comingSoon && (
<div className="p-1 px-3 ml-3 bg-green-100 rounded">
<p className="text-xs text-black">coming soon</p>
</div>
lib.bgColor,
"flex-shrink-0 flex items-center justify-center w-20 text-white text-sm font-medium rounded-l-md"
)}
>
<lib.icon
className={classNames(
lib.comingSoon
? "text-ui-gray-medium"
: "text-white stroke-1",
"w-10 h-10"
)}
/>
</div>
</div>
</li>
</a>
<div
className={classNames(
lib.comingSoon ? "border-dashed" : "",
"flex items-center justify-between flex-1 truncate bg-white rounded-r-md"
)}
>
<div className="inline-flex px-4 py-6 text-lg truncate">
<p className="font-light">{lib.name}</p>
{lib.comingSoon && (
<div className="p-1 px-3 ml-3 bg-green-100 rounded">
<p className="text-xs text-black">coming soon</p>
</div>
)}
</div>
</div>
</li>
</a>
</Link>
))}
</ul>

View File

@@ -1,3 +1,4 @@
import { GlobeAltIcon, MailIcon, PhoneIcon } from "@heroicons/react/solid";
import { SnoopElement, SnoopForm, SnoopPage } from "@snoopforms/react";
import { useMemo } from "react";
import { trackPosthogEvent } from "../../lib/posthog";
@@ -43,11 +44,17 @@ export default function App({ id = "", formId, blocks, localOnly = false }) {
localOnly={localOnly}
className="w-full max-w-3xl mx-auto space-y-6"
onSubmit={() => {
trackPosthogEvent("submitForm", { formId });
if (!localOnly) {
trackPosthogEvent("submitForm", { formId });
}
}}
>
{pages.map((page) => (
<SnoopPage key={page.id} name={page.id}>
{pages.map((page, pageIdx) => (
<SnoopPage
key={page.id}
name={page.id}
thankyou={pageIdx === pages.length - 1}
>
{page.blocks.map((block) => (
<div key={block.id}>
{block.type === "paragraph" ? (
@@ -61,20 +68,81 @@ export default function App({ id = "", formId, blocks, localOnly = false }) {
<h3 className="ce-header">{block.data.text}</h3>
) : null
) : block.type === "textQuestion" ? (
<div className="pb-5">
<SnoopElement
type="text"
name={block.id}
label={block.data.label}
classNames={{
label:
"mt-4 mb-2 block text-md font-bold leading-7 text-gray-800 sm:truncate",
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={block.data.required}
/>
</div>
<SnoopElement
type="text"
name={block.id}
label={block.data.label}
placeholder={block.data.placeholder}
classNames={{
label:
"mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "emailQuestion" ? (
<SnoopElement
type="email"
name={block.id}
label={block.data.label}
placeholder={block.data.placeholder}
icon={<MailIcon className="w-5 h-5" />}
classNames={{
label:
"mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "multipleChoiceQuestion" &&
block.data.multipleChoice ? (
<SnoopElement
type="checkbox"
name={block.id}
label={block.data.label}
options={block.data.options.map((o) => o.label)}
classNames={{
label:
"mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "multipleChoiceQuestion" &&
!block.data.multipleChoice ? (
<SnoopElement
type="radio"
name={block.id}
label={block.data.label}
options={block.data.options.map((o) => o.label)}
classNames={{
label:
"mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "numberQuestion" ? (
<SnoopElement
type="number"
name={block.id}
label={block.data.label}
placeholder={block.data.placeholder}
classNames={{
label:
"mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "phoneQuestion" ? (
<SnoopElement
type="phone"
name={block.id}
label={block.data.label}
placeholder={block.data.placeholder}
icon={<PhoneIcon className="w-5 h-5" />}
classNames={{
label:
"mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "submitButton" ? (
<SnoopElement
name="submit"
@@ -85,6 +153,19 @@ export default function App({ id = "", formId, blocks, localOnly = false }) {
"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",
}}
/>
) : block.type === "websiteQuestion" ? (
<SnoopElement
type="website"
name={block.id}
label={block.data.label}
placeholder={block.data.placeholder}
icon={<GlobeAltIcon className="w-5 h-5" />}
classNames={{
label:
"mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : null}
</div>
))}

View File

@@ -10,6 +10,7 @@ import { timeSince } from "../../lib/utils";
import AnalyticsCard from "./AnalyticsCard";
import Loading from "../Loading";
import TextResults from "./summary/TextResults";
import ChoiceResults from "./summary/ChoiceResults";
export default function ResultsSummary({ formId }) {
const { submissionSessions, isLoadingSubmissionSessions } =
@@ -83,8 +84,17 @@ export default function ResultsSummary({ formId }) {
page.type === "form" && (
<div key={page.name}>
{page.elements.map((element) =>
element.type === "text" || element.type === "textarea" ? (
[
"email",
"number",
"phone",
"text",
"textarea",
"website",
].includes(element.type) ? (
<TextResults element={element} />
) : ["checkbox", "radio"].includes(element.type) ? (
<ChoiceResults element={element} />
) : null
)}
</div>

View File

@@ -1,7 +1,27 @@
import { CheckCircleIcon, MenuAlt1Icon } from "@heroicons/react/outline";
import {
AtSymbolIcon,
CheckCircleIcon,
GlobeAltIcon,
HashtagIcon,
MenuAlt1Icon,
PhoneIcon,
} from "@heroicons/react/outline";
import { IoMdRadioButtonOn } from "react-icons/io";
import { classNames } from "../../../lib/utils";
export const elementTypes = [
{
type: "email",
icon: AtSymbolIcon,
},
{
type: "number",
icon: HashtagIcon,
},
{
type: "phone",
icon: PhoneIcon,
},
{
type: "text",
icon: MenuAlt1Icon,
@@ -14,6 +34,14 @@ export const elementTypes = [
type: "checkbox",
icon: CheckCircleIcon,
},
{
type: "radio",
icon: IoMdRadioButtonOn,
},
{
type: "website",
icon: GlobeAltIcon,
},
];
export const getElementTypeIcon = (type) => {

View File

@@ -0,0 +1,54 @@
import { Chart } from "react-chartjs-2";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
} from "chart.js";
import BaseResults from "./BaseResults";
ChartJS.register(CategoryScale, LinearScale, BarElement);
export default function ChoiceResults({ element }) {
const data = {
//labels: element.data.options,
labels: element.options.map((o) => o.label),
datasets: [
{
//data: getDataset(element, elementAnswers),
data: element.options.map((o) => o.summary || 0),
backgroundColor: ["rgba(245, 59, 87, 0.7)"],
borderColor: ["rgba(245, 59, 87, 1)"],
borderWidth: 1,
},
],
};
const options: any = {
indexAxis: "y",
responsive: true,
plugins: {
legend: {
display: false,
},
},
scales: {
yAxis: [
{
ticks: {
min: 1,
precision: 0,
},
},
],
},
};
return (
<BaseResults element={element}>
<div className="flow-root px-8 my-4 mt-6 text-center">
<Chart type="bar" data={data} options={options} height={75} />
</div>
</BaseResults>
);
}

View File

@@ -3,10 +3,10 @@ import BaseResults from "./BaseResults";
export default function TextResults({ element }) {
return (
<BaseResults element={element}>
<div className="flow-root px-8 my-4 mt-6 overflow-y-scroll text-center max-h-64">
<div className="flow-root px-8 my-4 mt-6 overflow-y-scroll text-center h-44 max-h-64">
<ul className="-my-5 divide-y divide-ui-gray-light">
{element.summary.map((answer) => (
<li key={answer} className="py-8">
<li key={answer} className="py-4">
<div className="relative focus-within:ring-2 focus-within:ring-indigo-500">
<h3 className="text-sm text-gray-700">
{/* Extend touch target to entire panel */}

View File

@@ -111,17 +111,33 @@ export const getSubmissionSummary = (
);
if (typeof elementInSummary !== "undefined") {
if (
elementInSummary.type === "text" ||
elementInSummary.type === "textarea"
[
"email",
"number",
"phone",
"text",
"textarea",
"website",
].includes(elementInSummary.type)
) {
if (!("summary" in elementInSummary)) {
elementInSummary.summary = [];
}
elementInSummary.summary.push(elementValue);
} else if (
elementInSummary.type === "radio" ||
elementInSummary.type === "checkbox"
) {
} else if (elementInSummary.type === "checkbox") {
// checkbox values are a list of values
for (const value of elementValue) {
const optionInSummary = elementInSummary.options.find(
(o) => o.value === value
);
if (typeof optionInSummary !== "undefined") {
if (!("summary" in optionInSummary)) {
optionInSummary.summary = 0;
}
optionInSummary.summary += 1;
}
}
} else if (elementInSummary.type === "radio") {
const optionInSummary = elementInSummary.options.find(
(o) => o.value === elementValue
);

View File

@@ -40,9 +40,20 @@ export type SchemaPage = {
elements: SchemaElement[];
};
export type SnoopType =
| "checkbox"
| "email"
| "number"
| "phone"
| "radio"
| "submit"
| "text"
| "textarea"
| "website";
export type SchemaElement = {
name: string;
type: "checkbox" | "radio" | "text" | "textarea" | "submit";
type: SnoopType;
label?: string;
options?: SchemaOption[];
};
@@ -64,7 +75,7 @@ export type SubmissionSummaryPage = {
export type SubmissionSummaryElement = {
name: string;
type: "checkbox" | "radio" | "text" | "textarea" | "submit";
type: SnoopType;
label?: string;
summary?: string[];
options?: SubmissionSummaryOption[];

View File

@@ -1,6 +1,6 @@
{
"name": "snoopforms",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,9 +16,10 @@
"@headlessui/react": "^1.6.1",
"@heroicons/react": "^1.0.6",
"@prisma/client": "^4.1.0",
"@snoopforms/react": "^0.0.4",
"@snoopforms/react": "^0.2.0",
"babel-plugin-superjson-next": "^0.4.3",
"bcryptjs": "^2.4.3",
"chart.js": "^3.8.2",
"date-fns": "^2.28.0",
"editorjs-drag-drop": "^1.1.2",
"editorjs-undo": "^2.0.3",
@@ -30,6 +31,7 @@
"nodemailer": "^6.7.7",
"posthog-js": "^1.26.0",
"react": "17.0.2",
"react-chartjs-2": "^4.3.1",
"react-dom": "17.0.2",
"react-icons": "^4.4.0",
"react-loader-spinner": "^5.1.5",

View File

@@ -27,7 +27,6 @@ export default function ResultsSummaryPage() {
breadcrumbs={[{ name: form.name, href: "#", current: true }]}
steps={formMenuSteps}
currentStep="results"
limitHeightScreen={true}
>
<SecondNavBar
navItems={formResultsSecondNavigation}

1002
yarn.lock

File diff suppressed because it is too large Load Diff