diff --git a/README.md b/README.md index b58e0af..affb38f 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,96 @@ -# Getting Started with Create React App +# Survey Viewer App -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +Survey Viewer is an online application to view/run a Survey from a json Survey Definition used by [Influenzanet Survey Engine](https://github.com/influenzanet/survey-engine.ts) -## Available Scripts +## Installing -In the project directory, you can run: +This application is built as a Single Page Application with React Framework. -### `yarn start` +To install the application, clone this repo and run yarn to install the dependencies -Runs the app in the development mode.\ -Open [http://localhost:3000](http://localhost:3000) to view it in the browser. +```bash +yarn +```` -The page will reload if you make edits.\ -You will also see any lint errors in the console. +To run in in dev environment +```bash +yarn start +``` -### `yarn test` +To build the app as a standalone website: +```bash +yarn build +``` -Launches the test runner in the interactive watch mode.\ -See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. +The deployable website will be in 'build' directory -### `yarn build` +## Survey Definition Source -Builds the app for production to the `build` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. +2 ways are proposed to view a survey : -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! +- Upload a JSON file containing the Survey Definition +- Use a Survey Provider Service : a list of available survey will be loaded from the service and downloaded directly from the service -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. +An implementation for survey provider service is available in [Grippenet repository](https://github.com/grippenet/survey-provider-service). It serves survey list from files in a directory. -### `yarn eject` +To use the service provider, the application must be compiled with some environment variables -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** +- REACT_APP_SURVEY_URL: URL of the survey service +- REACT_APP_CSP_CONNECT_URLS: the URL also must be added to this variable to enable the app to connect to the survey service -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. +Example +```bash +REACT_APP_SURVEY_URL=https://your.survey.service +# You may add +REACT_APP_CSP_CONNECT_URLS=$REACT_APP_SURVEY_URL ... +``` -Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. +# Customize -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. +Warning: These customizations are useable only after the application is built. So you need to use either dev server (`yarn start`) or to rebuild the static website (`yarn build`) -## Learn More +## Add a custom Response Component -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). +To register a customComponent, you have to modify the script 'localConfig.ts' and make the function registerCustomComponents return the list of your component -To learn React, check out the [React documentation](https://reactjs.org/). +```ts +export const registerCustomComponents = () : CustomSurveyResponseComponent[] | undefined => { + return [ + { + name: 'awesomeComponent', // Name of your component as it will be used in `role` field of the survey definition + component: MyAwesomeComponent + } + ]; +} +``` + +## Add Flags + +You can tell to the survey viewer which flags are handled by your platform (and for each what are the expected values). The Survey Context editor will propose the known list and enable more friendly editor than json editor. + +You had to customize the + +```ts +export const registerParticipantFlags = () : ParticipantFlags => { + return { + 'myflagkey': { + label: 'The label to tell the user what is the purpose of this flag' + values: [ + { + value: '1' // The value the flag can have + label: 'A friendly label to explain what is this value' + } + ] + }, + // A real example (the 'prev' flag used in Influenzanet platform) + 'prev': { + label:"Has ongoing symptoms", + values: [ + {value:"0", label:"Does not have ongoing symptoms"} + {value: "1", label:"Has ongoing symptoms"} + ], + }, + }; +} + +``` \ No newline at end of file diff --git a/package.json b/package.json index 2629514..674264c 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,15 @@ "@types/node": "^16.11.12", "@types/react": "^17.0.40", "@types/react-dom": "^17.0.13", - "bootstrap": "^5.2.2", - "case-web-ui": "^1.14.0", + "bootstrap": "^5.2.3", + "case-web-ui": "^1.14.7", "clsx": "^1.2.1", "date-fns": "^2.29.3", "react": "^17.0.2", - "react-bootstrap": "^2.5.0", + "react-bootstrap": "^2.7.3", "react-dom": "^17.0.2", "react-scripts": "4.0.3", - "survey-engine": "1.2.0", + "survey-engine": "1.2.1", "typescript": "^4.5.5", "web-vitals": "^1.0.1" }, diff --git a/src/App.tsx b/src/App.tsx index 9c80422..d8c0e7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,36 +1,46 @@ import React, { useState } from 'react'; -import { Survey, SurveyContext, SurveySingleItemResponse } from 'survey-engine/data_types'; +import { Survey } from 'survey-engine/data_types'; import Navbar from './components/NavbarComp'; -import SimulationSetup, { defaultSimulatorUIConfig, defaultSurveyContext } from './components/SimulationSetup'; import SurveyLoader from './components/SurveyLoader'; import SurveyMenu from './components/SurveyMenu'; import SurveyServiceLoader from './components/SurveyServiceLoader'; -import SurveySimulator, { SimulatorUIConfig } from './components/SurveySimulator'; +import { registerCustomComponents, registerParticipantFlags } from './localConfig'; interface AppState { - selectedLanguage?: string; - languageCodes?: string[]; + selectedAppLanguage?: string; // Language of the Application (for future translation) surveyKey?: string; survey?: Survey; - surveyContext: SurveyContext; - prefillsFile?: File; - prefillValues?: SurveySingleItemResponse[], screen: Screens; - simulatorUIConfig: SimulatorUIConfig; } -type Screens = 'loader' | 'menu' | 'simulation-setup' | 'simulator'; +// Application translations, currently only english is provided. +const availableAppLanguages : string[] = ['en']; + +const customResponseComponents = registerCustomComponents(); +const participantFlags = registerParticipantFlags(); + +type Screens = 'loader' | 'menu'; const initialState: AppState = { screen: 'loader', - simulatorUIConfig: { ...defaultSimulatorUIConfig }, - surveyContext: { ...defaultSurveyContext }, } const surveyProviderUrl = process.env.REACT_APP_SURVEY_URL ?? ""; console.log("Using provider "+ surveyProviderUrl); +// Default languages to show in the survey. If several use the first available in the survey. +// You can customize the default survey languages by definiing REACT_APP_DEFAULT_SURVEY_LANGUAGES env variable +const defaultSurveyLanguages : string[] = (()=>{ + let defaultLang: string[] = ['en']; + const envLang = process.env.REACT_APP_DEFAULT_SURVEY_LANGUAGES ?? ""; + if(envLang) { + defaultLang = envLang.split(',').map(v => v.trim()); + } + return defaultLang; +})(); + + const App: React.FC = () => { const [appState, setAppState] = useState({ ...initialState @@ -42,11 +52,6 @@ const App: React.FC = () => { return; } - const languageCodes = surveyObject.props?.name?.map(o => o.code); - if (!languageCodes || languageCodes.length < 1) { - alert('Languages cannot be extracted'); - return; - } const surveyDef = surveyObject.surveyDefinition; @@ -54,8 +59,7 @@ const App: React.FC = () => { setAppState({ ...initialState, - selectedLanguage: languageCodes[0], - languageCodes: languageCodes, + selectedAppLanguage: availableAppLanguages[0], surveyKey: surveyKey, screen: 'menu', survey: surveyObject @@ -83,104 +87,31 @@ const App: React.FC = () => { }}>
- - { + { surveyProviderUrl ? : null - } + } +
+ case 'menu': - if (!appState.selectedLanguage || !appState.survey) { + if (!appState.selectedAppLanguage || !appState.survey) { reset(); return null; } return navigateTo('simulation-setup')} + customResponseComponents={customResponseComponents} onExit={() => { reset() }} /> - case 'simulation-setup': - return navigateTo('simulator')} - onExit={() => navigateTo('menu')} - prefillsFile={appState.prefillsFile} - onPrefillChanged={(prefills?: File) => { - if (prefills) { - const reader = new FileReader() - - reader.onabort = () => console.log('file reading was aborted') - reader.onerror = () => console.log('file reading has failed') - reader.onload = () => { - // Do whatever you want with the file contents - const res = reader.result; - if (!res || typeof (res) !== 'string') { - console.error('TODO: handle file upload error') - return; - } - const content = JSON.parse(res); - setAppState(prev => { - return { - ...prev, - prefillsFile: prefills, - prefillValues: content, - - } - }) - } - reader.readAsText(prefills) - } else { - setAppState(prev => { - return { - ...prev, - prefillsFile: prefills, - prefillValues: [] - - } - }) - } - - - - }} - currentSurveyContext={appState.surveyContext} - onSurveyContextChanged={(config) => setAppState(prev => { - return { - ...prev, - surveyContext: { - ...config - } - } - })} - currentSimulatorUIConfig={appState.simulatorUIConfig} - onSimulatorUIConfigChanged={(config) => setAppState(prev => { - return { - ...prev, - simulatorUIConfig: { - showKeys: config.showKeys, - texts: { ...config.texts } - } - } - })} - /> - case 'simulator': - return navigateTo('simulation-setup')} - /> } } + return (
{ }}> { setAppState(prev => { return { ...prev, - selectedLanguage: code + selectedAppLanguage: code } }) }} diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 40462c5..3f5b157 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -1,21 +1,55 @@ -import React from 'react'; +import clsx from 'clsx'; +import React, { ReactNode } from 'react'; +import { CardBody, CardHeader, Card } from 'react-bootstrap'; + + +type VariantsType = 'primary' | 'grey' | 'info'; interface CardProps { - title: string; + title: ReactNode; className?: string; + variant?: VariantsType; + headerTag?: 'h3' | 'h4' |'h5' |'h6'; +} + +interface VariantProps { + header: string; + body: string; +} + +const variants: Record = { + 'primary': { + header:'bg-primary text-white', + body:'bg-grey-1' + }, + 'grey': { + header:'bg-grey-1', + body:'' + }, + 'info': { + header:'bg-info', + body:'' + } } -const Card: React.FC = (props) => { + +const CardTitled: React.FC = (props) => { + + const v = variants[props.variant ?? 'primary']; + + const hTag = props.headerTag ?? 'h4'; + const h = React.createElement(hTag, {className: "fw-bold m-0"}, props.title); + return ( -
-
-

{props.title}

-
-
+ + + {h} + + {props.children} -
-
+ + ); }; -export default Card; +export default CardTitled; diff --git a/src/components/HelpComponents.tsx b/src/components/HelpComponents.tsx new file mode 100644 index 0000000..32ed65c --- /dev/null +++ b/src/components/HelpComponents.tsx @@ -0,0 +1,11 @@ + +import { Badge, OverlayTrigger, Tooltip } from 'react-bootstrap'; +import React, { useState } from 'react'; + +interface HelpTooltipProps { + label: string; +} + +export const HelpTooltip: React.FC = (props) => { + return {props.label}}>? +} diff --git a/src/components/NavbarComp.tsx b/src/components/NavbarComp.tsx index baf70d7..55f932f 100644 --- a/src/components/NavbarComp.tsx +++ b/src/components/NavbarComp.tsx @@ -12,6 +12,7 @@ const NavbarComp: React.FC = (props) => { const languageSelector = (codes: string[]) => { return () } @@ -32,7 +34,7 @@ const NavbarComp: React.FC = (props) => {
- CASE Viewer + { process.env.REACT_APP_TITLE ?? "CASE Viewer" } {props.surveyName ? {props.surveyName} : null} {props.languagecodes ? languageSelector(props.languagecodes) : null} diff --git a/src/components/SimulationSetup.tsx b/src/components/SimulationSetup.tsx index ec98b6c..7dc598c 100644 --- a/src/components/SimulationSetup.tsx +++ b/src/components/SimulationSetup.tsx @@ -1,22 +1,25 @@ import Editor from '@monaco-editor/react'; -import { Checkbox, FileDropzone } from 'case-web-ui'; +import { FileDropzone } from 'case-web-ui'; import clsx from 'clsx'; import React, { useState } from 'react'; import { SurveyContext } from 'survey-engine/data_types'; -import Card from './Card'; +import CardTitled from './Card'; import { acceptJSON } from './constants'; import { SimulatorUIConfig, SurveyUILabels } from './SurveySimulator'; import UploadDialog from './UploadDialog'; +import { ParticipantFlag, ParticipantFlags } from '../types/flags'; +import { Badge, Button, ListGroup, ListGroupItem} from 'react-bootstrap'; +import { PrefillEntry, PrefillsRegistry } from '../types'; +import { HelpTooltip } from './HelpComponents'; interface SimulationSetupProps { currentSimulatorUIConfig: SimulatorUIConfig; currentSurveyContext: SurveyContext; - prefillsFile?: File; + participantFlags: ParticipantFlags + prefillRegistry: PrefillsRegistry onSimulatorUIConfigChanged: (config: SimulatorUIConfig) => void; onSurveyContextChanged: (context: SurveyContext) => void; - onPrefillChanged: (prefills?: File) => void; - onStart: () => void; - onExit: () => void; + onPrefillChanged: (registry: PrefillsRegistry) => void; } export const defaultSimulatorUIConfig: SimulatorUIConfig = { @@ -42,19 +45,6 @@ const SimulationSetup: React.FC = (props) => { const [hasUILabelEditorErrors, setHasUILabelEditorErrors] = useState(false); const [openSurveyUIConfigUploadDialog, setOpenSurveyUIConfigUploadDialog] = useState(false); - const navButtons = ( -
- - -
- ) - const surveyUIConfigUploader = { @@ -109,114 +99,152 @@ const SimulationSetup: React.FC = (props) => { onClose={() => setOpenSurveyContextUploadDialog(false)} /> + const updatePrefillRegistry = (registry: PrefillsRegistry) => { + const r = { ...registry }; + props.onPrefillChanged(r); + } + const uploadPrefill = (file?: File) => { + if (!file) { + return; + } + const reader = new FileReader() - return ( -
- {navButtons} - - -
Prefill:
- { - if (acceptedFiles.length > 0) { - props.onPrefillChanged(acceptedFiles[0]); - } - }} - /> - + reader.onabort = () => console.log('file reading was aborted') + reader.onerror = () => console.log('file reading has failed') + reader.onload = () => { + // Do whatever you want with the file contents + const res = reader.result; + if (!res || typeof (res) !== 'string') { + console.error('TODO: handle file upload error') + return; + } + const content = JSON.parse(res); + console.log("prefill values", content); -
Survey context:
- { - if (markers.length > 0) { - setHasSurveyContextEditorErrors(true) - } else { - setHasSurveyContextEditorErrors(false) - } - }} - onChange={(value) => { - if (!value) { return } - let context: SurveyContext; - try { - context = JSON.parse(value); - } catch (e: any) { - console.error(e); - return - } - if (!context) { return } - props.onSurveyContextChanged({ - ...context - }) + const registry = props.prefillRegistry; + const n = registry.prefills.push({ name: file.name, data: content }); + registry.current = n - 1; + updatePrefillRegistry(registry); + } + reader.readAsText(file) + } - }} - /> - {hasSurveyContextEditorErrors ? -

- Check the editor for errors -

- : null} -
+ const handleChangePrefill = (index?: number) => { + const registry = props.prefillRegistry; + registry.current = index; + updatePrefillRegistry(registry); + } + + const prefillEntry = (entry: PrefillEntry, index: number) => { + const active = index === props.prefillRegistry.current; + return handleChangePrefill(index)} style={{"cursor":"pointer"}}> + {entry.name} {active ? Current : ''} + + } + + return ( +
+ + Prefills headerTag='h5'> + List of the prefill datasets available. Each one prefills a full survey. Click on one element of the list to use it to prefill the survey. + + {props.prefillRegistry.prefills.map(prefillEntry)} + + Prefills can be added by downloading them from a file or by filling a survey and saving the response as prefills +
+ Add a prefill dataset by downloading a file + { + if (acceptedFiles.length > 0) { + uploadPrefill(acceptedFiles[0]); + } + }} + /> +
- -
+ onChange={(value) => { + if (!value) { return } + let context: SurveyContext; + try { + context = JSON.parse(value); + } catch (e: any) { + console.error(e); + return + } + if (!context) { return } + props.onSurveyContextChanged({ + ...context + }) + }} + /> + {hasSurveyContextEditorErrors ? +

+ Check the editor for errors +

+ : null} +
+ + +
+ - + - - { - props.onSimulatorUIConfigChanged({ - ...props.currentSimulatorUIConfig, - showKeys: value - }) - }} - label="Show keys" - /> +
Survey UI Labels:
= (props) => { >Upload config
-
- {navButtons} + {surveyContextUploader} {surveyUIConfigUploader}
); }; + +interface FlagsEditorProps { + availableFlags: ParticipantFlags + surveyContext: SurveyContext + onChange: (ctx: SurveyContext) => void; +} + +export const FlagsEditor: React.FC = (props) => { + + const [newFlag, setNewFlag] = useState(''); + + + const knownFlags = new Map(Object.entries(props.availableFlags)); + const curCtx = props.surveyContext.participantFlags ?? {}; + + const flagUpdated = (name: string, value: string) => { + if (name === '') { + return; + } + const ctx = { ...props.surveyContext }; + ctx.participantFlags[name] = value; + props.onChange(ctx); + } + + const removeFlag = (name: string) => { + if (name === '') { + return; + } + const ctx = { ...props.surveyContext }; + delete ctx.participantFlags[name]; + props.onChange(ctx); + } + + const addCustomFlag = () => { + flagUpdated(newFlag, ''); + } + + const FlagLabel = (key: string, def?: ParticipantFlag) => { + return + {key} + {def ? : ''} + + } + + const showFlag = (key: string) => { + + const def = knownFlags.get(key); + + const value = props.surveyContext.participantFlags[key] ?? ''; + return
+
{FlagLabel(key, def)}
+
+ flagUpdated(key, ev.target.value)} className='form-control' /> +
+
+ {def ? + ( + Acceptable values : + + ) + : ''} + +
+
+ + } + + const usedKeys = Array.from(Object.keys(curCtx)); + // List of missing flags in context, to be added + const missing = Array.from(knownFlags.keys()).filter(k => !usedKeys.includes(k)); + + return
+ {usedKeys.map(showFlag)} + {missing.length > 0 ? ( +
+ Add a known flag : + +
+ ) : ''} + Add custom flag + setNewFlag(ev.target.value)} /> + +
+} + export default SimulationSetup; + diff --git a/src/components/SurveyInfo.tsx b/src/components/SurveyInfo.tsx new file mode 100644 index 0000000..7ba44d6 --- /dev/null +++ b/src/components/SurveyInfo.tsx @@ -0,0 +1,48 @@ +import { SurveyCard, } from 'case-web-ui'; +import React, { useState } from 'react'; +import { Card, CardBody} from 'react-bootstrap'; +import { Survey, LocalizedString } from 'survey-engine/data_types'; + +interface SurveyInfoProps { + survey: Survey; + languageCode?: string; +} + +const SurveyInfo: React.FC = (props) => { + const [showDetails, setShowDetails] = useState(false); + const surveyDefinition = props.survey.surveyDefinition; + const surveyProps = props.survey.props; + const surveyCard = surveyProps ? : ''; + + return ( + + + setShowDetails(!showDetails)}>Toggle info +

Survey {surveyDefinition.key}

+ {showDetails ? ( +
+ {surveyCard} +
+ ) : ''} +
+
+ ); +}; + +export default SurveyInfo; \ No newline at end of file diff --git a/src/components/SurveyInspector/ExpressionEvaluator.tsx b/src/components/SurveyInspector/ExpressionEvaluator.tsx new file mode 100644 index 0000000..3473d36 --- /dev/null +++ b/src/components/SurveyInspector/ExpressionEvaluator.tsx @@ -0,0 +1,213 @@ +import React, { ReactNode, useState } from 'react'; +import { ListGroup, Tabs, Tab, } from 'react-bootstrap'; +import { Expression, ExpressionArg, isExpression, isItemGroupComponent, isSurveyGroupItem, ItemComponent, SurveyGroupItem, SurveyItem, SurveySingleItemResponse } from 'survey-engine/data_types'; +import clsx from 'clsx'; + +import { SurveyEngineCore } from 'survey-engine/engine'; + +/** + * Reference to an expression in a survey + */ +interface ExpressionRef { + exp: Expression; // The expression to inspect + field: string; // Field in the survey item + key: string; // itemKey where the expression lives + value?: any; // Resolved value with the last state + changed?: boolean; // Does this value change after the last survey state transition ? + show: boolean; +} + +/** + * Expression Registry collect all known expression in a survey + */ +class ExpressionRegistry { + + exps : Map; // All known expressions + + fields: Set; + + constructor() { + this.exps = new Map(); + this.fields = new Set(); + } + + build(survey: SurveyGroupItem) { + this.handleItem(survey); + } + + add(itemKey:string, key: string, field: string, exp?: Expression) { + if(!exp) { + return; + } + + const ref = { + key: key, + field: field, + exp: exp, + show: true + }; + + if(!this.exps.has(itemKey)) { + const rr : ExpressionRef[] = [ ref ]; + this.exps.set(itemKey, rr); + } else { + const rr = this.exps.get(itemKey); + rr?.push(ref); + } + if(field) { + this.fields.add(field); + } + } + + handleComponent(itemKey:string, comp: ItemComponent, index: number) { + const key = (comp.key ?? comp.role + '#'+ index); + if(comp.disabled && isExpression(comp.disabled)) { + this.add(itemKey, key, 'disabled', comp.disabled); + } + if(comp.displayCondition && isExpression(comp.displayCondition)) { + this.add(itemKey, key, 'displayCondition', comp.displayCondition); + } + if(comp.properties) { + const handleProp = (prop: ExpressionArg|number|string|undefined, name: string) => { + if(prop && typeof(prop) == "object" && "dtype" in prop && prop.dtype === "exp" ) { + this.add(itemKey, key, 'properties.'+name, (prop as ExpressionArg).exp); + } + } + handleProp(comp.properties.min, 'min'); + handleProp(comp.properties.max, 'max'); + handleProp(comp.properties.dateInputMode, 'dateInputMode'); + handleProp(comp.properties.stepSize, 'stepSize'); + } + if(comp) + if(isItemGroupComponent(comp)) { + comp.items.forEach( (c, i) => this.handleComponent(itemKey, c, i)); + } + } + + handleItem(item: SurveyItem) { + if(!item) { + return; + } + if(item.condition) { + this.add(item.key, '', 'condition', item.condition); + } + if(isSurveyGroupItem(item)) { + item.items.forEach(i => this.handleItem(i)); + } else { + if(item.components) { + this.handleComponent(item.key, item.components, 0); + } + if(item.validations) { + item.validations.forEach((v) => { + if(isExpression(v.rule)) { + this.add(item.key, v.key, 'validations' , v.rule ); + } + }); + } + } + } +} + +export class EngineState { + engine?: SurveyEngineCore; + surveyDefinition?: SurveyGroupItem + registry: ExpressionRegistry + responses?: SurveySingleItemResponse[] + + constructor(surveyDefinition?: SurveyGroupItem) { + this.engine = undefined; + this.registry = new ExpressionRegistry(); + this.responses = undefined; + this.surveyDefinition = surveyDefinition; + if(surveyDefinition) { + this.registry.build(surveyDefinition); + } + } + + setEngine(engine: SurveyEngineCore) { + this.engine = engine; + } + + setResponses(responses : SurveySingleItemResponse[]) { + this.responses = responses; + } + + update() { + this.registry.exps.forEach((refs)=> { + refs.forEach(ref => { + const oldValue = ref.value; + ref.value = this.engine?.resolveExpression(ref.exp); + ref.changed = oldValue !== ref.value; + }); + }); + } +} + +interface ExpressionListProps { + engineState: EngineState + update: number; + onSelect: (exp:Expression)=>void; +} + +export const ExpressionList: React.FC = (props) => { + + const list = props.engineState.registry.exps; + + const [search, setSearch] = useState(undefined); + + const [refresh, setRefresh] = useState(false); + + const toFunc = (e: Expression):string => { + var p : string[]; + + if(e.data) { + p = e.data.map(arg => { + if(arg.dtype) { + if(arg.dtype === "exp" && arg.exp) { + return toFunc(arg.exp); + } + if(arg.dtype === "num") { + return '' + arg.num; + } + } + return '"' + arg.str + '"'; + }); + } else { + p = []; + } + return e.name + '(' + p.join(',') + ')'; + } + + const ExpItem = (itemKey: string, ref: ExpressionRef, index: number) => { + return + + {itemKey}{ref.key ? ' [' + ref.key + ']' : ''} + {ref.field} + +

{ toFunc(ref.exp) }

+

{ JSON.stringify(ref.value) }

+
+ }; + + const buildList = (refresh: boolean) => { + const r : ReactNode[] = []; + list.forEach( (refs, key) => { + if(search) { + if(!key.includes(search) ){ + return; + } + } + r.push( ...refs.map( (ref, index) => ExpItem(key, ref, index)) ); + }); + return r; + } + + return +
+ { if(e.key === "Enter") setSearch(e.currentTarget.value) }}/> +
+ + { buildList(refresh) } + +
+} \ No newline at end of file diff --git a/src/components/SurveyInspector/ResponseList.tsx b/src/components/SurveyInspector/ResponseList.tsx new file mode 100644 index 0000000..d884c96 --- /dev/null +++ b/src/components/SurveyInspector/ResponseList.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { ListGroup, ListGroupItem } from 'react-bootstrap'; +import { SurveySingleItemResponse } from 'survey-engine/data_types'; + + +interface ResponsesListProps { + responses?: SurveySingleItemResponse[] + meta?: boolean; + json?: boolean; +} + +export const ResponsesList: React.FC = (props) => { + + const [search, setSearch] = useState(undefined); + const [json, setJSON] = useState(false); + + const jsonify = (data: any)=>{ + return
 { JSON.stringify(data, undefined, 2) } 
+ } + + const showResponse = (response : SurveySingleItemResponse, index:number) => { + return +
{response.key}
+ Response + { jsonify(response.response) } + { props.meta ? jsonify(response.meta) : ''} +
+ } + + const selectResponses = (responses: SurveySingleItemResponse[]) => { + if(!search) { + return responses; + } + return responses.filter(r => { + return r.key.includes(search); + }); + } + + const responses = props.responses ? selectResponses(props.responses) : []; + + return +
+ + { if(e.key === "Enter") setSearch(e.currentTarget.value) }}/> +
+ { + json ? + jsonify(responses) : + { responses.map(showResponse) } + } +
+} + + diff --git a/src/components/SurveyInspector/SurveyInspector.tsx b/src/components/SurveyInspector/SurveyInspector.tsx new file mode 100644 index 0000000..13b6b55 --- /dev/null +++ b/src/components/SurveyInspector/SurveyInspector.tsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react'; +import { Button, Tabs, Tab } from 'react-bootstrap'; +import { Expression, } from 'survey-engine/data_types'; +import Editor from '@monaco-editor/react'; +import clsx from 'clsx'; + +import { EngineState, ExpressionList } from "./ExpressionEvaluator"; +import { ResponsesList } from "./ResponseList"; + +interface SurveyInspectorProps { + engineState: EngineState + update: number; +} + + +export const SurveyInspector: React.FC = (props) => { + + const [hasEditorErrors, setHasEditorErrors] = useState(false); + const [expression, setExpression] = useState(undefined); + const [inputExp, setInputExpression] = useState({name:"getContext"}); + const [result, setResult] = useState(undefined); + + const evaluateExpression = () => { + if(props.engineState.engine) { + setResult(props.engineState.engine.resolveExpression(expression)); + } + }; + + const handleExpSelected = (exp: Expression) => { + setInputExpression(exp); + } + + return + + + + + + + + { + if (markers.length > 0) { + setHasEditorErrors(true) + } else { + setHasEditorErrors(false) + } + }} + onChange={(value) => { + if (!value) { return } + let config: Expression; + try { + config = JSON.parse(value); + } catch (e: any) { + console.error(e); + return + } + if (!config) { return } + setExpression(config); + }} + /> + +
+
Result
+ +
+
+
; +} diff --git a/src/components/SurveyInspector/index.ts b/src/components/SurveyInspector/index.ts new file mode 100644 index 0000000..fe77733 --- /dev/null +++ b/src/components/SurveyInspector/index.ts @@ -0,0 +1,2 @@ +export { EngineState } from "./ExpressionEvaluator"; +export { SurveyInspector } from "./SurveyInspector"; \ No newline at end of file diff --git a/src/components/SurveyLoader.tsx b/src/components/SurveyLoader.tsx index 0a2f73f..325d56e 100644 --- a/src/components/SurveyLoader.tsx +++ b/src/components/SurveyLoader.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { DialogBtn, FileDropzone } from 'case-web-ui'; import { Survey } from 'survey-engine/data_types'; -import Card from './Card'; +import CardTitled from './Card'; import { acceptJSON } from './constants'; interface SurveyLoaderProps { @@ -10,7 +10,7 @@ interface SurveyLoaderProps { const SurveyLoader: React.FC = (props) => { const texts = { - title: 'Load Survey', + title: 'Load Survey from a json file', btn: { open: 'Open', useUrl: 'use url' @@ -38,7 +38,7 @@ const SurveyLoader: React.FC = (props) => { } return ( - + = (props) => { >{texts.btn.useUrl}
- + ); }; diff --git a/src/components/SurveyMenu.tsx b/src/components/SurveyMenu.tsx index b421a5d..1f9c905 100644 --- a/src/components/SurveyMenu.tsx +++ b/src/components/SurveyMenu.tsx @@ -1,76 +1,104 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { SurveyCard } from 'case-web-ui'; import { LocalizedString, Survey } from 'survey-engine/data_types'; +import { Tabs, Tab, Container, Button } from 'react-bootstrap'; +import { SurveyContext, SurveySingleItemResponse } from 'survey-engine/data_types'; +import SimulationSetup, { defaultSimulatorUIConfig, defaultSurveyContext } from './SimulationSetup'; +import SurveySimulator, { SimulatorUIConfig } from './SurveySimulator'; +import { CustomSurveyResponseComponent } from 'case-web-ui'; +import { ParticipantFlags, PrefillsRegistry, SurveyAndContext } from '../types'; +import { format } from 'date-fns'; interface SurveyMenuProps { survey: Survey; - selectedLangue: string; - onOpenSimulator: () => void; + defaultSurveyLanguages: string[]; + customResponseComponents?: CustomSurveyResponseComponent[] + participantFlags: ParticipantFlags onExit: () => void; } const SurveyMenu: React.FC = (props) => { - const surveyDefinition = props.survey.surveyDefinition; + const [surveyContext, setSurveyContext] = useState(defaultSurveyContext); + const [prefillValues, setPrefillValues] = useState([]); + const [simulatorUIConfig, setSimulatorUIConfig] = useState(defaultSimulatorUIConfig); + const [surveyAndContext, setSurveyAndContext] = useState({ + survey: props.survey, + context: surveyContext + }); + + // Show survey is used in the reset process. When ShowSurvey is set to false, a timer will + const [showSurvey, setShowSurvey] = useState(true); + + const [prefillRegistry, SetPrefillRegistry] = useState({current: undefined, prefills:[]}); - return ( -
-
-
-

Actions

-
- -
-
- -
-
- -
-
-
-

Infos

-

Survey Card

- { - props.survey.props ? : null - } + useEffect(() => { + if(showSurvey) { + return; + } + const timer = setTimeout(() => setShowSurvey(true), 500); + return () => clearTimeout(timer); + }, [showSurvey]); -
-
+ const updateRegistry = (registry: PrefillsRegistry) => { + SetPrefillRegistry(registry); + if(typeof(registry.current) != "undefined") { + const e = registry.prefills[registry.current]; + setPrefillValues(e.data); + } else { + setPrefillValues([]); + } + setShowSurvey(false); + } + + const handleSaveAsPrefill = (data: SurveySingleItemResponse[]) => { + const name = 'response_'+ format(Date.now(), 'yyyy-MM-dd hh:mm:ss'); + const n = prefillRegistry.prefills.push({name: name, data: data}); + prefillRegistry.current = n - 1; + updateRegistry({'current': n-1, prefills: prefillRegistry.prefills }); + } + + const handleReset = () => { + console.log('reset'); + setSurveyAndContext({survey: props.survey, context: surveyContext}); + setShowSurvey(false); + }; + + const surveyDefinition = props.survey.surveyDefinition; -
- ); + return + + + {showSurvey ? + props.onExit()} + onReset={handleReset} + customResponseComponents={props.customResponseComponents} + onSaveAsPrefill={handleSaveAsPrefill} + />: + + } + + + { + updateRegistry(r); + }} + currentSurveyContext={surveyContext} + onSurveyContextChanged={(config) => setSurveyContext(config)} + currentSimulatorUIConfig={simulatorUIConfig} + onSimulatorUIConfigChanged={(config) => setSimulatorUIConfig(config)} + /> + + + ; }; export default SurveyMenu; diff --git a/src/components/SurveyServiceLoader.tsx b/src/components/SurveyServiceLoader.tsx index cf9b7de..1eabf16 100644 --- a/src/components/SurveyServiceLoader.tsx +++ b/src/components/SurveyServiceLoader.tsx @@ -1,9 +1,9 @@ import { Survey } from 'survey-engine/data_types'; -import { Badge, ListGroup } from 'react-bootstrap'; +import { Badge, ListGroup, Accordion } from 'react-bootstrap'; import useLoadJSON from '../hooks/useLoadJSON'; import { useEffect, useState } from 'react'; -import Card from './Card'; -import { format, parseISO } from 'date-fns'; +import CardTitled from './Card'; +import { parseISO } from 'date-fns'; interface localisedString { [key:string]: string; @@ -80,11 +80,22 @@ const SurveyList: React.FC = (props) => { const [lang, setLang] = useState(new Set(languages)); + const surveyByStudy : Map = new Map(); + props.surveys.forEach(s => { const codes = Object.keys(s.description); codes.forEach(k => languages.add(k)); + + const study = s.study; + if (!surveyByStudy.has(study)) { + surveyByStudy.set(study, []); + } + const list = surveyByStudy.get(study); + list?.push(s); }); + console.log(surveyByStudy); + const changeLanguage = (newLang: Set)=> { setLang(newLang); } @@ -113,12 +124,22 @@ const SurveyList: React.FC = (props) => { ) } + const surveyList = (study: string, surveys: SurveyDescription[])=> { + return + {study} + + + { surveys.map( (survey)=> { return surveySelector(survey)}) } + + + ; + } return (
- - { props.surveys.map( (survey)=> { return surveySelector(survey)}) } - + + {Array.from(surveyByStudy.entries()).map(entry => surveyList(entry[0], entry[1]))} +
); }; @@ -156,9 +177,9 @@ const SurveyServiceLoader: React.FC = (props) => { } return ( - + - + ); }; diff --git a/src/components/SurveySimulator.tsx b/src/components/SurveySimulator.tsx index a7d0c38..3152405 100644 --- a/src/components/SurveySimulator.tsx +++ b/src/components/SurveySimulator.tsx @@ -1,10 +1,14 @@ import { AlertBox, SurveyView, Dialog, DialogBtn } from 'case-web-ui'; -import React, { useState } from 'react'; -import { Dropdown, DropdownButton } from 'react-bootstrap'; +import React, { useState } from 'react'; +import { Button, Card, CardBody } from 'react-bootstrap'; import { Survey, SurveyContext, SurveySingleItemResponse } from 'survey-engine/data_types'; - import { nl, nlBE, fr, de, it, da, es, pt } from 'date-fns/locale'; -import { CustomSurveyResponseComponent } from 'case-web-ui/build/components/survey/SurveySingleItemView/ResponseComponent/ResponseComponent'; +import { SurveyEngineCore } from 'survey-engine/engine'; +import { EngineState, SurveyInspector } from './SurveyInspector'; +import { CustomSurveyResponseComponent } from 'case-web-ui'; +import clsx from 'clsx'; +import { SurveyAndContext } from '../types'; +import SurveyInfo from './SurveyInfo'; const dateLocales = [ { code: 'nl', locale: nl, format: 'dd-MM-yyyy' }, @@ -33,22 +37,65 @@ export interface SimulatorUIConfig { interface SurveySimulatorProps { config: SimulatorUIConfig; - surveyAndContext?: { - survey: Survey; - context: SurveyContext; - }; + surveyAndContext?: SurveyAndContext; prefills?: SurveySingleItemResponse[]; - selectedLanguage?: string; + defaultSurveyLanguages: string[]; customResponseComponents?:CustomSurveyResponseComponent[] onExit: () => void; + onSaveAsPrefill: (p: SurveySingleItemResponse[])=>void; + onReset: () => void; } + const SurveySimulator: React.FC = (props) => { + + const surveyDefinition = props.surveyAndContext ? props.surveyAndContext.survey.surveyDefinition : undefined; + + const languageCodes: string[] = []; // Available language codes in the survey + + if(props.surveyAndContext?.survey) { + props.surveyAndContext.survey.props?.name?.map(o => languageCodes.push(o.code)); + } + const [openSurveyEndDialog, setOpenSurveyEndDialog] = useState(false); const [surveyResponseData, setSurveyResponseData] = useState([]); + const [engineState, setEngineState ] = useState(new EngineState(surveyDefinition)); + const [evaluatorCounter, setEvaluatorCounter ] = useState(0); // Ok to show the evaluator (after engineReady is true) + const [showEvaluator, setShowEvaluator] = useState(false); + + const [showKeys, setShowKeys ] = useState(props.config.showKeys); + + const [languageCode, setLanguageCode] = useState(()=> { + let defaultLanguage = 'en'; // fallback + if(languageCodes) { + const def = props.defaultSurveyLanguages.find(lang => languageCodes.includes(lang)); + if(def) { + defaultLanguage = def; + } + } + return defaultLanguage; + }); + + const onResponseChanged=(responses: SurveySingleItemResponse[], version: string, engine?: SurveyEngineCore) => { + console.log(responses, engineState.engine, engine); + if(engine) { + engineState.setEngine(engine); + engineState.setResponses(responses); + engineState.update(); + setEvaluatorCounter(evaluatorCounter + 1); + } else { + console.warn("Engine instance is not passed to callback, not able to use evaluator"); + } + } + + const toggleEvaluator = () => { + setShowEvaluator(!showEvaluator) + } + const surveySubmitDialog = { setSurveyResponseData([]); setOpenSurveyEndDialog(false); @@ -69,6 +116,18 @@ const SurveySimulator: React.FC = (props) => { label="Exit without Save" outlined={true} /> + { + const response = surveyResponseData; + setSurveyResponseData([]); + props.onSaveAsPrefill(response); + props.onReset(); + setOpenSurveyEndDialog(false); + }} + label="Store as prefill for next survey" + outlined={true} + /> { @@ -83,69 +142,73 @@ const SurveySimulator: React.FC = (props) => { setOpenSurveyEndDialog(false) props.onExit() }} - label="Save and Exit" + label="Save as file and Exit" />
+ const languageSelector = (code:string, index:number) => { + const btn = code == languageCode ? 'btn-primary' : 'btn-info'; + return
  • setLanguageCode(code)}>{code}
  • ; + } + + console.log('prefills', props.prefills); + return (
    -
    -
    - { - switch (eventKey) { - case 'save': - break; - case 'exit': - if (window.confirm('Do you want to exit the simulator (will lose state)?')) { - props.onExit(); - } - break; - } - }} - > - Save Current Survey State - - Exit Simulator - +
    +
    + + + + + +
    + Languages : +
      + { languageCodes.map(languageSelector) } +
    +
    +
    +
    -
    - {props.surveyAndContext ? +
    + {props.surveyAndContext ? ( +
    + { setSurveyResponseData(responses.slice()) setOpenSurveyEndDialog(true); }} + onResponsesChanged={onResponseChanged} nextBtnText={props.config.texts.nextBtn} backBtnText={props.config.texts.backBtn} submitBtnText={props.config.texts.submitBtn} invalidResponseText={props.config.texts.invalidResponseText} dateLocales={dateLocales} customResponseComponents={props.customResponseComponents} - /> : + /> +
    ) : }
    +
    + { evaluatorCounter ? :

    Select at least a response to show the inspector

    } +
    {surveySubmitDialog} diff --git a/src/index.scss b/src/index.scss index cd1a1ce..de3e91c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1 +1,26 @@ @import "~case-web-ui/build/scss/theme-default.scss"; + +.nav-tabs { + background-color: map-get($theme-colors, "lightest"); +} + +.nav-tabs .nav-item { + background-color: map-get($theme-colors, "lightest"); + + .nav-link { + color: map-get($theme-colors ,'grey-5' ); + text-decoration: none; + } + + .nav-link:hover { + color: map-get($theme-colors ,'grey-2' ); + } +} + +.list-inline { + display: inline; +} + +.list-inline li { + display: inline-block; +} diff --git a/src/localConfig.ts b/src/localConfig.ts new file mode 100644 index 0000000..d4839e9 --- /dev/null +++ b/src/localConfig.ts @@ -0,0 +1,11 @@ +import { CustomSurveyResponseComponent } from 'case-web-ui'; +import { ParticipantFlags } from './types/flags'; + +export const registerCustomComponents = () : CustomSurveyResponseComponent[] | undefined => { + return undefined; + // here +} + +export const registerParticipantFlags = () : ParticipantFlags => { + return {}; +} \ No newline at end of file diff --git a/src/types/flags.ts b/src/types/flags.ts new file mode 100644 index 0000000..3a64c30 --- /dev/null +++ b/src/types/flags.ts @@ -0,0 +1,13 @@ + +interface FlagValue { + value: string; + label: string; +} + +// Describe an available participant flag +export interface ParticipantFlag { + label: string + values: FlagValue[] +} + +export type ParticipantFlags = Record; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..ae37547 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './flags'; +export * from './survey'; +export * from './language'; \ No newline at end of file diff --git a/src/types/language.ts b/src/types/language.ts new file mode 100644 index 0000000..922cd04 --- /dev/null +++ b/src/types/language.ts @@ -0,0 +1,4 @@ +export interface LanguageConfig { + availableLanguages : string[] + defaultLanguage: string +} \ No newline at end of file diff --git a/src/types/survey.ts b/src/types/survey.ts new file mode 100644 index 0000000..5fcfdfd --- /dev/null +++ b/src/types/survey.ts @@ -0,0 +1,17 @@ + +import { Survey, SurveyContext, SurveySingleItemResponse } from 'survey-engine/data_types'; + +export interface SurveyAndContext { + survey: Survey; + context: SurveyContext; +} + +export interface PrefillEntry { + name: string; + data: SurveySingleItemResponse[] +} + +export interface PrefillsRegistry { + current?: number; + prefills: PrefillEntry[] +} diff --git a/yarn.lock b/yarn.lock index 16dfc7a..389f804 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3364,10 +3364,10 @@ case-sensitive-paths-webpack-plugin@2.3.0: resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.3.0.tgz#23ac613cc9a856e4f88ff8bb73bbb5e989825cf7" integrity sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ== -case-web-ui@^1.14.0: - version "1.14.0" - resolved "https://registry.yarnpkg.com/case-web-ui/-/case-web-ui-1.14.0.tgz#5cf3305868f194c74bd9a815b1ec8cbe1109097b" - integrity sha512-Qbn52iGOdco4DFbcHplrqGYr7t3T1VkTkb68ykWlZ/p9LU5R82rmcYgffcbiSPwE9SriJhwv06Ynhbxli101vQ== +case-web-ui@^1.14.3: + version "1.14.3" + resolved "https://registry.yarnpkg.com/case-web-ui/-/case-web-ui-1.14.3.tgz#43b921daf19fac19fa36bf3800ae72b8e9bf4a5f" + integrity sha512-UQY5mS8jz94fuVeBiXKOc8u/uJIQ6sDAG++VvnbIxFlEty4I0UneTHEnDPSovQMTQjcVRg3vqmFQbGoau5ZMkA== dependencies: "@fortawesome/fontawesome-free" "^5.15.4"