diff --git a/package.json b/package.json index 89997123..446aa2de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "traitify-widgets", - "version": "3.7.1", + "version": "3.8.0", "description": "Traitiy Widgets", "repository": { "type": "git", diff --git a/public/index.js b/public/index.js index f50aadbe..4a9486c1 100644 --- a/public/index.js +++ b/public/index.js @@ -144,6 +144,7 @@ function createAssessment() { if(cache.get("surveyType") === "benchmark") { return createWidget(); } if(cache.get("surveyType") === "cognitive") { return createCognitiveAssessment(); } if(cache.get("surveyType") === "order") { return createWidget(); } + if(cache.get("surveyType") === "generic") { return createGenericAssessment(); } const params = { deck_id: cache.get("deckID"), @@ -240,6 +241,25 @@ function createElement(options = {}) { return element; } +function createGenericAssessment() { + Traitify.http.authKey = "admin-secret"; + const query = Traitify.GraphQL.generic.create; + const variables = { + surveyID: cache.get("surveyID"), + profileID: cache.get("profileID") + }; + + Traitify.http.post(Traitify.GraphQL.generic.path, {query, variables}).then((response) => { + try { + const id = response.data.getOrCreateGenericAssessment.id; + cache.set("assessmentID", id); + } catch(error) { + console.log(error); + } + setTimeout(createWidget, 500); + }); +} + function destroyWidget() { Traitify.destroy(); } function setupTargets() { @@ -369,6 +389,7 @@ function setupDom() { options: [ {text: "Benchmark", value: "benchmark"}, {text: "Cognitive", value: "cognitive"}, + {text: "Generic", value: "generic"}, {text: "Order", value: "order"}, {text: "Personality", value: "personality"} ], @@ -404,6 +425,9 @@ function setupDom() { row.appendChild(createOption({name: "orderID", text: "Order ID:"})); group.appendChild(row) + row = createElement({className: surveyType !== "generic" ? "hide" : "", id: "generic-options"}); + row.appendChild(createOption({name: "profileID", text: "Profile ID:"})); + group.appendChild(row); row = createElement({className: "row"}); row.appendChild(createElement({onClick: createAssessment, tag: "button", text: "Create / Load"})); group.appendChild(row); @@ -427,6 +451,30 @@ function setupCognitive() { }); } +function setupGeneric() { + Traitify.http.authKey = "admin-secret"; + const query = Traitify.GraphQL.generic.surveys; + const variables = {localeKey: cache.get("locale")}; + + Traitify.http.post(Traitify.GraphQL.generic.path, {query, variables}).then((response) => { + try { + const options = response.data.genericSurveys + .map(({id, name}) => ({text: name, value: id})) + .sort((a, b) => a.text.localeCompare(b.text)); + + document.querySelector("#generic-options").appendChild(createOption({ + name: "surveyID", + onChange: onInputChange, + options, + text: "Survey:" + })); + } catch(error) { + console.log(error); + } + }); + +} + function setupTraitify() { const environment = cache.get("environment"); @@ -455,7 +503,7 @@ function onSurveyTypeChange(e) { const name = e.target.name; const value = e.target.value; const assessmentID = cache.get(`${value}AssessmentID`); - const otherValues = ["benchmark", "cognitive", "order", "personality"].filter((type) => type !== value); + const otherValues = ["benchmark", "cognitive", "order", "personality", "generic"].filter((type) => type !== value); cache.set("assessmentID", assessmentID); @@ -468,4 +516,5 @@ function onSurveyTypeChange(e) { setupTraitify(); setupDom(); setupCognitive(); +setupGeneric(); createWidget(); diff --git a/src/components/common/modal/index.js b/src/components/common/modal/index.js index b976a043..c6a6c652 100644 --- a/src/components/common/modal/index.js +++ b/src/components/common/modal/index.js @@ -4,11 +4,12 @@ import Icon from "components/common/icon"; import useTranslate from "lib/hooks/use-translate"; import style from "./style.scss"; -export default function Modal({children, onClose, title}) { +export default function Modal({children, containerClass = null, onClose, title}) { const translate = useTranslate(); + const sectionClass = [style.modalContainer, containerClass].filter(Boolean).join(" "); return (
-
+
{title}
@@ -34,6 +35,7 @@ export default function Modal({children, onClose, title}) { Modal.propTypes = { children: PropTypes.node.isRequired, + containerClass: PropTypes.string, onClose: PropTypes.func.isRequired, title: PropTypes.string.isRequired }; diff --git a/src/components/index.js b/src/components/index.js index ec0d2e6b..3de77d8d 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -29,6 +29,7 @@ import RecommendationChart from "./results/recommendation/chart"; import Status from "./status"; import Survey from "./survey"; import CognitiveSurvey from "./survey/cognitive"; +import Generic from "./survey/generic"; import PersonalitySurvey from "./survey/personality"; export default { @@ -86,6 +87,7 @@ export default { Survey: { Cognitive: CognitiveSurvey, Container: Survey, + Generic, Personality: PersonalitySurvey } }; diff --git a/src/components/results/generic/index.js b/src/components/results/generic/index.js new file mode 100644 index 00000000..4bd8f120 --- /dev/null +++ b/src/components/results/generic/index.js @@ -0,0 +1,12 @@ +import useResults from "lib/hooks/use-results"; + +export default function Generic() { + const results = useResults({surveyType: "generic"}); + console.log("Generic assessment result:", results); + return ( +
+

Generic Report

+

This is a placeholder for a generic report. Please customize it as needed.

+
+ ); +} diff --git a/src/components/survey/generic/container.js b/src/components/survey/generic/container.js new file mode 100644 index 00000000..491ad244 --- /dev/null +++ b/src/components/survey/generic/container.js @@ -0,0 +1,20 @@ +import PropTypes from "prop-types"; +import style from "./style.scss"; + +export default function Container({children, progress}) { + return ( +
+ {progress < 100 && ( +
+
+
+ )} + {children} +
+ ); +} + +Container.propTypes = { + children: PropTypes.node.isRequired, + progress: PropTypes.number.isRequired +}; diff --git a/src/components/survey/generic/index.js b/src/components/survey/generic/index.js new file mode 100644 index 00000000..b537fefb --- /dev/null +++ b/src/components/survey/generic/index.js @@ -0,0 +1,132 @@ +import {faArrowLeft} from "@fortawesome/free-solid-svg-icons"; +import {useEffect, useState} from "react"; +import Icon from "components/common/icon"; +import Markdown from "components/common/markdown"; +import Modal from "components/common/modal"; +import useAssessment from "lib/hooks/use-assessment"; +import useGraphql from "lib/hooks/use-graphql"; +import useHttp from "lib/hooks/use-http"; +import useDidUpdate from "lib/hooks/use-did-update"; +import useTranslate from "lib/hooks/use-translate"; +import Container from "./container"; +import QuestionSet from "./question-set"; +import style from "./style.scss"; + +export default function Generic() { + const [questionSetIndex, setQuestionSetIndex] = useState(0); + const [answers, setAnswers] = useState([]); + const [showInstructions, setShowInstructions] = useState(false); + const [showConclusions, setShowConclusions] = useState(false); + const [submitAttempts, setSubmitAttempts] = useState(0); + + const assessment = useAssessment({surveyType: "generic"}); + const questionSets = assessment ? assessment.survey.questionSets : []; + const questionCount = questionSets.reduce((count, set) => count + set.questions.length, 0); + const currentQuestionSet = questionSets ? questionSets[questionSetIndex] : {}; + const progress = questionSetIndex >= 0 ? (questionSetIndex / questionSets.length) * 100 : 0; + const finished = questionSets.length > 0 && questionCount === answers.length; + + const graphQL = useGraphql(); + const http = useHttp(); + const translate = useTranslate(); + const props = {progress}; + + const updateAnswer = (questionId, selectedOptionId) => { + const currentAnswers = answers.filter((answer) => answer.questionId !== questionId); + setAnswers([...currentAnswers, + {questionId, selectedResponseOptionId: selectedOptionId}]); + }; + + const next = () => { setQuestionSetIndex(questionSetIndex + 1); }; + const back = () => { setQuestionSetIndex(questionSetIndex - 1); }; + + const onSubmit = () => { + if(submitAttempts > 3) { return; } + const query = graphQL.generic.update; + const variables = { + assessmentID: assessment.id, + answers + }; + + http.post(graphQL.generic.path, {query, variables}).then(({data, errors}) => { + if(!errors && data.submitGenericAssessmentAnswers) { + setShowConclusions(true); + } else { + console.warn(errors || data); // eslint-disable-line no-console + + setTimeout(() => setSubmitAttempts((x) => x + 1), 2000); + } + }); + }; + + useDidUpdate(() => { onSubmit(); }, [submitAttempts]); + + useEffect(() => { + setShowInstructions(true); + }, [assessment]); + + useEffect(() => { + if(!finished) { return; } + onSubmit(); + }, [finished]); + + if(showConclusions) { + return ( + + + {assessment.survey.conclusions} + + + + ); + } + + return ( + + {currentQuestionSet && ( + + )} + + {questionSetIndex > 0 && ( + + )} + {showInstructions + && ( + setShowInstructions(false)} + containerClass={style.modalContainer} + > + + {assessment.survey.instructions} + +
+
+ + +
+
+ )} +
+ ); +} diff --git a/src/components/survey/generic/question-set.js b/src/components/survey/generic/question-set.js new file mode 100644 index 00000000..693bb058 --- /dev/null +++ b/src/components/survey/generic/question-set.js @@ -0,0 +1,48 @@ +import PropTypes from "prop-types"; +import {useEffect, useState} from "react"; +import Responses from "./responses"; +import style from "./style.scss"; + +export default function QuestionSet({next, questionSet, updateAnswer}) { + const questionSetClass = [style.questionSet].join(" "); + const [selectedOptions, setSelectedOptions] = useState([]); + const setFinished = questionSet.questions.length === selectedOptions.length; + const selectOption = (questionId, optionId) => { + if(!selectedOptions.includes(questionId)) setSelectedOptions([...selectedOptions, questionId]); + updateAnswer(questionId, optionId); + }; + + useEffect(() => { + if(!setFinished) return; + next(); + }, [setFinished]); + + return ( +
+ {questionSet.text} +
+ {questionSet.questions.map((question) => ( +
+

{question.text}

+ selectOption(question.id, optionId)} + /> +
+ ))} +
+ ); +} + +QuestionSet.propTypes = { + next: PropTypes.func.isRequired, + questionSet: PropTypes.shape({ + text: PropTypes.string.isRequired, + questions: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + text: PropTypes.string.isRequired + })).isRequired, + setImage: PropTypes.string.isRequired + }).isRequired, + updateAnswer: PropTypes.func.isRequired +}; diff --git a/src/components/survey/generic/responses.js b/src/components/survey/generic/responses.js new file mode 100644 index 00000000..64a8381e --- /dev/null +++ b/src/components/survey/generic/responses.js @@ -0,0 +1,39 @@ +import PropTypes from "prop-types"; +import {useState} from "react"; +import style from "./style.scss"; + +export default function Responses({responseOptions = [], updateAnswer}) { + const buttonClass = ["traitify--response-button", style.response].join(" "); + const buttonWidth = (text) => (text.length > 20 ? "100%" : "auto"); + const [activeButton, setActiveButton] = useState(null); + const selectOption = (optionId) => { + setActiveButton(optionId); + updateAnswer(optionId); + }; + + return ( +
+ {responseOptions.map((option) => ( + + ))} +
+ ); +} + +Responses.propTypes = { + responseOptions: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + text: PropTypes.string.isRequired + }) + ).isRequired, + updateAnswer: PropTypes.func.isRequired +}; diff --git a/src/components/survey/generic/style.scss b/src/components/survey/generic/style.scss new file mode 100644 index 00000000..c7c6b4a3 --- /dev/null +++ b/src/components/survey/generic/style.scss @@ -0,0 +1,158 @@ +@import "style/helpers"; + +.container { + @extend %container; + display: flex; + flex-direction: column; + margin: 0 auto; + max-width: 100%; + position: relative; + z-index: 1; + padding: $buffer-lg; + text-align: center; + + @include min-width("xs") { + aspect-ratio: 2/1; + max-width: $breakpoint-md * 0.85; + } + + .modalContainer { + overflow: auto; + max-width: 600px; + } + + button { + border: 1px solid #DADCE0; + border-radius: $border-radius-sm * 0.5; + margin: $buffer $buffer * 0.5; + height: 48px; + vertical-align: middle; + + &.back { + padding: $buffer * 0.5 $buffer-lg * 0.5; + width: 150px; + + .icon { + margin-right: $buffer-sm; + font-size: $font-size-lg; + } + } + } + + .markdown { + text-align: center; + } + + .progress { + border-radius: $border-radius-sm; + height: 100%; + transition: width 0.3s; + width: 0%; + + @include theme("background-color", "progress-bar"); + } + .progressBar { + border-radius: $border-radius-sm; + height: $border-width-xl; + margin-bottom: $buffer * 2; + + @include theme("background-color", "border"); + } + &:after { + top: 0; + bottom: 0; + border: $border-width solid; + border-radius: $border-radius; + content: ""; + left: 0; + position: absolute; + right: 0; + z-index: -1; + + @include theme("border-color", "border"); + } +} + +.questionSet { + width: 100%; + align-items: center; + text-align: center; + padding: $buffer; + + img { + margin-bottom: $buffer * 2; + } + + .question { + text-align: left; + } +} + +.response { + flex: 1 1 0; + letter-spacing: 2px; + line-height: 2; + padding: $buffer * 0.25 $buffer * 0.75; + text-align: center; + + @include min-width("xs") { padding: $buffer * 0.25 $buffer * 0.75; } + @include min-width("sm") { + font-size: $font-size * 1.25; + line-height: $line-height; + padding: $buffer * 0.5 $buffer * 0.75; + } + @include theme("color", "text-light"); + &:focus { + outline-offset: -1px; + outline-style: solid; + outline-width: 1px; + } + &.btnActive { + background-color: $-blue; + color: $-white; + border-color: transparent; + } +} + +button.btnPrimary { + background-color: $-blue; + color: $-white; + display: inline-block; + padding: $buffer-lg * .5 $buffer; + text-align: center; + border: transparent; + max-width: 110px; + margin: $buffer-lg auto; +} + +.hide { display: none; } + +.grayDivider { + color: #555555; + width: 100%; + margin: $buffer * 3 auto $buffer * .5; + line-height: inherit; + clear: both; + user-select: none; + break-after: page; + border: $border-width solid #e8e8ec; + border-radius: $border-radius-sm; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: $buffer * 0.25; + + .cancelBtn { + @include buttonLight("text-dark"); + @include theme("border-color", "border-light"); + border: $border-width solid; + border-radius: $border-radius-sm * 0.5; + margin: $buffer * 0.5; + } + + .btnPrimary { + margin: $buffer * 0.5; + } +} \ No newline at end of file diff --git a/src/components/survey/index.js b/src/components/survey/index.js index e8012f49..fdc0844e 100644 --- a/src/components/survey/index.js +++ b/src/components/survey/index.js @@ -1,6 +1,7 @@ import Status from "components/status"; import useActive from "lib/hooks/use-active"; import Cognitive from "./cognitive"; +import Generic from "./generic"; import Personality from "./personality"; export default function Survey() { @@ -9,6 +10,7 @@ export default function Survey() { if(!active) { return ; } if(active.surveyType === "cognitive") { return ; } if(active.surveyType === "external") { return ; } + if(active.surveyType === "generic") { return ; } if(active.surveyType === "personality") { return ; } return null; diff --git a/src/lib/graphql/generic.js b/src/lib/graphql/generic.js new file mode 100644 index 00000000..672325ff --- /dev/null +++ b/src/lib/graphql/generic.js @@ -0,0 +1,65 @@ +export const surveys = ` + query($localeKey: String!) { + genericSurveys(localeKey: $localeKey) { + id + name + } + } +`; + +export const create = ` + mutation($profileID: ID!, $surveyID: ID!) { + getOrCreateGenericAssessment(profileId: $profileID, surveyId: $surveyID) { + id + surveyId + profileId + startedAt + completedAt + } + } +`; + +export const questions = ` + query($assessmentID: ID!) { + genericSurveyQuestions(assessmentId: $assessmentID) { + id + surveyId + profileId + startedAt + completedAt + survey { + id + name + conclusions + instructions + instructionButton + questionSets { + text + setImage + questions { + id + text + responseOptions { + id + text + } + } + } + } + } + } +`; + +export const update = ` + mutation($assessmentID: ID!, $answers: [Answers]!) { + submitGenericAssessmentAnswers(assessmentId: $assessmentID, answers: $answers) { + id + surveyId + profileId + startedAt + completedAt + } + } +`; + +export const path = "/generic-assessments/graphql"; diff --git a/src/lib/graphql/index.js b/src/lib/graphql/index.js index 57df33d2..357217c4 100644 --- a/src/lib/graphql/index.js +++ b/src/lib/graphql/index.js @@ -1,6 +1,7 @@ import * as benchmark from "./benchmark"; import * as cognitive from "./cognitive"; import * as external from "./external"; +import * as generic from "./generic"; import * as guide from "./guide"; import * as order from "./order"; import * as xavier from "./xavier"; @@ -9,6 +10,7 @@ export default { benchmark, cognitive, external, + generic, guide, order, xavier diff --git a/src/lib/recoil/assessment.js b/src/lib/recoil/assessment.js index 17dec745..3104b158 100644 --- a/src/lib/recoil/assessment.js +++ b/src/lib/recoil/assessment.js @@ -102,10 +102,43 @@ export const personalityAssessmentQuery = selectorFamily({ key: "assessment/personality" }); +export const genericAssessmentQuery = selectorFamily({ + get: (id) => async({get}) => { + if(!id) { return null; } + + const cache = get(cacheState); + const cacheKey = get(safeCacheKeyState({id, type: "assessment"})); + const cached = cache.get(cacheKey); + if(cached) { return cached; } + + const GraphQL = get(graphqlState); + const http = get(httpState); + const params = { + query: GraphQL.generic.questions, + variables: {assessmentID: id} + }; + + const response = await http.post({path: GraphQL.generic.path, params}); + if(response.errors) { + console.warn("generic-assessment", response.errors); /* eslint-disable-line no-console */ + return null; + } + + const assessment = response.data.genericSurveyQuestions; + if(!assessment?.length || !assessment?.completedAt) { return assessment; } + + cache.set(cacheKey, assessment); + + return assessment; + }, + key: "assessment/generic" +}); + export const assessmentQuery = selectorFamily({ get: ({id, surveyType}) => async({get}) => { if(surveyType === "cognitive") { return get(cognitiveAssessmentQuery(id)); } if(surveyType === "external") { return get(externalAssessmentQuery(id)); } + if(surveyType === "generic") { return get(genericAssessmentQuery(id)); } if(surveyType === "personality") { return get(personalityAssessmentQuery(id)); } return null;