uask-dom v1.2.0
U-ASK Domain Model
U-ASK domain model classes and builders. This library implement an internal domain specific language as described in Martin Fowler's Domain Specific Languages.
This library is intended to be used with U-ASK Management System and U-ASK Web Application.
Note: npm scripts needs bash ; please configure git bash as script shell for npm on Windows.
Install
npm Install uask-dom
Usage
Survey
construction
A Survey
is composed of Page
objects. Page
objects are hierarchical, they include other Page
objects or PageItems
leaf objects.
Page
objects are grouped in PageSet
objects that represent questionnaires. A Survey
may have multiple questionnaires.
PageItem
represent a question, they have a wording
and a type
. They may be involved in Rule
objects that control the value of question answers.
Survey
objects are complex graphes, construction is easier with fluent builders :
import { builder } from "uask-dom"
const b = builder();
b.survey("First-Survey")
.pageSet("Questionnaire").pages("Questions");
b.page("Questions")
.question("Ok?", "OK", b.types.yesno)
.question("When:", "WHEN", b.types.date());
const survey = b.build();
The result is a Survey
object with PageSet
, Page
and PageItem
objects that reflects the stucture of the fluent program above.
The complete refercence for survey buiders is below
Rules
A PageItem
may be the target or a Rule
. For example a page item can enforce that a value must be provided.
b.page("Questions")
.question("Ok?", "OK", b.types.yesno)
.required()
// ...
A PageItem
value can have a default value :
b.page("Questions")
// ...
.question("When:", "WHEN", b.types.date())
.defaultValue("@TODAY");
Rules are executed in order of appearance of their target PageItem
in the DSL ; for a given target they are executed in order of deacreasing precedence.
rule name | description | parameters | precedence |
---|---|---|---|
copy | copy another item value, unit, etc. | variable name | 110 |
computed | cacluate the value from other items | formula | 100 |
defaultValue | the value when the item is activated | value* | 100 |
critical | fires an event when item is valued | event, message, values | 70 |
required | requires a value when enforced | formula? | 70 |
activateWhen | activate the item on condition | variableName, values OR formula | 50 |
decimalPrecision | the number of decimal digits | precision | 10 |
inRange | must be between given min and max | min, max, limits | 10 |
letterCase | enforce lower or upper case | "upper" OR "lower" | 10 |
fixedLength | the exact number of characters | length | 10 |
maxLength | the max number of characters | length | 10 |
note: parameters followed by a * may be replaced by a formula, a formula is constructed wih the computed
keyword:
b.page("Questions")
// ...
.question("When:", "WHEN", b.types.date())
.inRange("2022-01-01", b.computed("@TODAY"));
A critical rule fires an event that will be notified to users which workflow subscribed to that event (see Derived workflows).
b.page("Questions")
// ...
.question("When:", "WHEN", b.types.date())
.critical("before_2022", "before 2022", b.computed("WHEN < #2022-01-01#"));
Workflows
A Survey
has one or more Workflow
objects. A workflow represents the page sets a user has access to and when he can create an interview related to a given page set.
Main workflow
In the main workflow a page set may :
- appear once or multiple times
- belong to the sequence or be auxiliary
terminate the workflow (or not)
The workflow sequence follows the following stucture:
The fisrt interview of a participant always refercences the starting page set. Subsequent interviews will reference pages sets in order defined in the sequence. Initial page sets appears in only one interview and follow up page sets can be repeated in multiple interviews, always following the sequence.
Terminal page sets terminate the workflow: no more interview could be created for the participant.
Auxiliary page sets can appear any time after the sequence started.
Workflow
creation is part of Survey
construction:
import { builder } from "uask-dom"
const b = builder();
b.survey("First-Survey")
.pageSet("Initial 1").pages("I1")
.pageSet("Initial 2").pages("I2")
.pageSet("Follow up 1").pages("F1")
.pageSet("Follow up 2").pages("F2")
.pageSet("Terminal 1").pages("T1")
.pageSet("Terminal 2").pages("T1")
.pageSet("Auxiliary 1").pages("A1")
.pageSet("Auxiliary 2").pages("A2")
//...
b.workflow()
.initial("Initial 1", "Initial 2")
.followUp("Follow up 1", "Follow up 2")
.terminal("Terminal 1", "Terminal 2")
.auxiliary("Auxiliary 1", "Auxiliary 2")
const survey = b.build();
Derived workflows
Derived workflows are built from the main workflow, giving access to a restricted subset of the page sets. They have the same sequence, terminal and auxiliary page sets.
Derived workflows can optionally have associated notifications. Notifications refers to critical events declared with a question. Users with that workflow will receive notifications when the event fires.
A derived workflow is identified by its name (which can not be "main"
), and optionally a specifier. Names are not restricted in this library but in U-ASK Management System the allowed values are :
writer
reader
administrator
superadministrator
developer
The specifier if any, is positionned after the name, separated with:
(e.g."writer:investigator"
,"reader:coordinator"
).
// ...
b.workflow("writer:investigator")
.withPageSets("Follow up 1", "Follow up 2")
.notify("special")
Participant
construction
A Participant
participes to a Survey
; it belongs to a Sample
.
Participant
contains Interview
objects that reference a PageSet
and contains InterviewItem
objects.
InterviewItem
holds a value and represent an answer to a PageItem
(a question).
Participant
may also be build using fluent buiders.
The following code builds a Participant
with code "11A"
in the given sample of the given survey. The participant will have one interview, corresponding to page set "Questionnaire"
with two answers, for items corresponding to variables "OK"
and "WHEN"
.
import { ParticipantBuilder } from "uask-dom";
const builder = new ParticipantBuilder(survey, "11A", sample);
builder.interview("Questionnaire")
.item("OK").value(false)
.item("WHEN").value(new Date());
const participant = builder.build();
the same can be done using domain model objects instead of string identifiers:
import { ParticipantBuilder } from "uask-dom";
const questionnaire: PageSet = //...
const Ok: PageItem = //...
const When: PageItem = //...
const builder = new ParticipantBuilder(survey, "11A", sample);
builder.interview(questionnaire)
.item(Ok).value(false)
.item(When).value(new Date())
const participant = builder.build();
See ./src/example/index.ts for an example of fluent builders usage.
Domain model objects mutations
Domain model objects are mostly immutable. When a domain model object needs an update in some business logic, a new version of the object is produced :
const pageItem = new PageItem("Are you OK ?", "OK", QuestionTypes.yesno);
const interviewItem = new InterviewItem(pageItem, true);
//...
const updatedInterviewItem = interviewItem.update({ value: false });
Domain object collections are also immutable, if a new version of an object has been produced, a new collection including the new object must be created :
const interviewItems = DomainCollection(interviewItem, ...);
//...
const updatedInterviewItems = interviewItems.update(
a => a == interviewItem ? updatedInterviewItem : a
);
Mutations on Patient
, Interview
and InterviewItem
can be achieved using the corresponding builders. The following snippet will created and updated version the participant
, with items corresponding to variables "OK"
and "WHEN"
updated in interview corresponding do page set Questionnaire
and identified by the nonce 11145786
.
import { ParticipantBuilder } from "uask-dom";
const participant: Patient = //...
const builder = new ParticipantBuilder(survey, participant)
builder.interview("Questionnaire", 11145786)
.item("OK").value(false)
.item("WHEN").value(new Date())
const updatedPatient = builder.build();
The same can be done with domain models instead of string identifiers :
import { ParticipantBuilder } from "uask-dom";
const participant: Patient = //...
const questionnaire11145786: Interview = //...
const Ok: PageItem = //...
const When: PageItem = //...
const builder = new ParticipantBuilder(survey, participant)
builder.interview(questionnaire11145786)
.item(Ok).value(false)
.item(When).value(new Date())
const updatedPatient = builder.build();
DSL reference
Survey and visit construction
instruction | |
---|---|
.survey(name) | creates a survey with given name |
.options(opt) | declares options for the survey |
.visit(name) | creates a page set (or visit) with given name |
.translate(lang, name) | add a translation for visit name |
.dateVariable(name) | declares the variable which holds the visit date |
.pages(names...) | adds pages with given names to the visit |
.mandatory(name) | declares that a page is mandatory in a visit, result is passed to .pages |
Survey options
option | default value | |
---|---|---|
languages | ['en', 'fr'] | languages supported by the survey |
defaultLang | 'en' | fallback language if browser language is not supported |
showFillRate | true | show fill rate for visits |
visitDateVar | 'VDATE' | default variable that holds the visit date |
phoneVar | '__PHONE' | participant phone number if any |
emailVar | '__EMAIL' | participant email if any |
inclusionVar | '__INCLUDED' | variable that holds whether the participant is included or not |
unitSuffix | '_UNIT' | suffix used for exporting data units if applicable |
Example
b.survey('Demo-eCRF')
.defaultLang('fr')
.visit('INCL')
.translate('fr', 'Inclusion')
.translate('en', 'Inclusion')
.dateVariable('DATE_VIS')
.pages(b.mandatory('PATIENT_INFO'), 'ANTECED')
Page and question construction
instruction | |
---|---|
.page(name) | creates a page with given name |
.translate(lang, name) | adds a translation for page name |
.startSection(name) | declares a section with given name |
.translate(lang, name) | adds a translation for section name |
.question(wording, variable, types...) | adds a question with given wording, variable and types (type can change with context, see below) |
.translate(lang, wording) | adds a translation for question wording |
.comment(info) | adds information about the variable |
.translate(lang, info) | adds a translation for question comment |
.unit(units...) | declares that the variable value may be expressed in one of the given units |
.extendable() | declares that custom units may be used for the variable |
.defaultValue(value) | declares that a new variable instance is filled with given value |
.required() | declares that the variable cannot be filled by a special value |
.decimalPrecision(precision) | decrares that the variable is a number with at most given precision decimal digits |
.inRange(min, max, limits) | decrares that the variable value must be between given values, limits may or may not be included (see below) |
.maxLength(length) | decrares that the variable value is a text which lenght is at most given length |
.fixedLength(length) | decrares that the variable value is a text which lenght is exactly given length |
.letterCase(case) | decrares that the variable value is a text which case is 'upper' or 'lower' |
.computed(formula) | apply the given formula to the variable |
.critical(event, message, values...) | fires the event when the item receives the given values |
.critical(event, message, formula) | fires the event when the formula is true |
.visibleWhen(formula, results...) | shows this question only when the given formula is true or if any equal to one of the given results |
.activatedWhen(formula, results...) | activates this question only when the given formula is true or if any equal to one of the given results |
.modifiableWhen(formula, results...) | allows modifications on this question only when the given formula is true or if any equal to one of the given results |
.question(variable, types...) | adds a question with given variable and types (type can change with context, see below), allow multiple wordings |
.wordings(wordings...) | adds multiple, contextual wordings (see below) |
.translate(lang, wordings...) | adds a translation for multiple wordings |
.include(name) | includes all the question of the given page |
.context(variable, num) | switch included variable to context num (i.e. with corresponding wording and type if multiple are declared) |
.context(num) | switch all included variables to context num (i.e. with corresponding wording and type if multiple are declared) |
.visibleWhen(formula, results...) | shows the included questions only when the given formula is true or if any equal to one of the given results |
.activatedWhen(formula, results...) | activates the included questions only when the given formula is true or if any equal to one of the given results |
.modifiableWhen(formula, results...) | allows modifications on the included questions only when the given formula is true or if any equal to one of the given results |
.computed(formula) | declares a dynamic constraint, result is passed inRange , activatedWhen , etc. |
.copy(variable) | declares a copy an existing variable values, result is passed defaultValue |
.includeLimits | declares that an in range constraint must include limits, result is passed to inRange |
.includeUpper | declares that an in range constraint must include upper limit, result is passed to inRange |
.includeLower | declares that an in range constraint must include lower limit, result is passed to inRange |
Variable types
The third argument of .question(wording, variable, type)
is the result of one of
instruction | Variable type |
---|---|
.types.acknowledge | true or undefined |
.types.yesno | Yes or No (translated to user language) |
.types.integer | a natural number |
.types.real | a real number |
.types.text | a text |
.types.info | no value |
.types.date(incomplete?, month?) | a date optionally incomplete and truncted to month |
.types.scale(min, max) | an integer value between min and max |
.types.score(scores...) | an integer that is used to compute scores |
.wording(lang, wordings...) | declares wording for scores |
.translate(lang, wordings...) | adds a translation for score wordings |
.types.choice('one' | 'many', choices...) | a set of categories that can be exclusive (one ) or not (many ) |
.wording(lang, wordings...) | declares wording for categories |
.translate(lang, wordings...) | adds a translation for category wordings |
.types.glossary('one' | 'many', choices...) | a set of categories that allows custom values and can be exclusive (one ) or not (many ) |
.wording(lang, wordings...) | declares wording for categories |
.translate(lang, wordings...) | adds a translation for category wordings |
Example
b.page('INCL')
.translate('fr', 'Inclusion')
.translate('en', 'English')
.question('Date de la visite', 'DATE_VIS', b.types.date())
.translate('en', 'Visit date')
.required()
.inRange('#2000-01-01#', b.computed('@TODAY'))
.question('Sexe', 'SEX',
b.types.choice('one', 'H', 'F', 'ND')
.wording('Homme', 'Femme', 'Non déterminé')
.translate('en', 'Male', 'Female', 'Undetermined'))
)
.translate('en', 'Sex')
Computed formulas
A formula are composed of :
operators
operator + - * /
arithmetic operators < > <= >= == !=
comparison operators ? ... : ...
ternary operator && || == !=
logical operators (...)
sub expressions values
value 0, 1, 2 ...
numbers (may be decimals) '...'
or"..."
texts #YYYY-MM-DD#
dates variables
variable X, Y, Z ...
current value of a variable declared in the questions $X, $Y, $Z ...
previous value of a variable declared in the questions @LASTIN
last input date for the participant @UNDEF
undefined value @ACK
acknowledged or true value @THISYEAR
current year @TODAY
current date functions
function ~IN(VAR, value)
returns @ACK
if given variable of type.choice('many')
contains the given value
Examples
b.page('...')
.question('Où souffrez-vous ?', 'LOC', b.types.choice('many', 'MAIN', 'DOS', 'JAMBE', 'TÊTE', 'AUTRE')
.question('Si AUTRE, précisez', 'LOC_AUTRE', b.types.text)
.activatedWhen(~IN('LOC, "Autre")')
b.page('...')
question('Date de la visite', 'DATE_VIS', b.types.date())
.question('Confirmez que la date est postérieure à ce jour', '__DATE_POST', b.types.acknowledge)
.required()
.visibleWhen('DATE_VIS>@TODAY')
Question layouts
Questions that are related may be viewed as tables or recordsets
A table represents variables that can be arranged as
column A colmumn B row1 A1 B1 row2 A2 B2
A recordset represents variables that can have multiple instances in the same form
column A colmumn B A(1) B(1) A(2) B(2) ... ...
This is achieved by using DSL syntax in the question wordings :
wording | layout |
---|---|
row -> column | table |
-> column | recordset |
-> row -> column | a table nested in a recordset |
Examples
b.page('...')
.question('Red cells -> Result', 'RES_BLOOD', b.types.real)
.question('Red cells -> Interpretation', 'INT_BLOOD', b.choice('one', 'normal', 'abnormal'))
.question('Haemoglobin -> Result', 'RES_BLOOD', b.types.real)
.question('Haemoglobin -> Interpretation', 'INT_HAEMO', b.choice('one', 'normal', 'abnormal'))
Résult Interpretation Red cells RES_BLOOD INT_BLOOD Haemoglobin RES_BLOOD INT_HAEMO
b.page('...')
.question('-> Year', 'YEAR', b.types.integer)
.question('-> Treatment', 'TREAT', b.types.text)
.question('-> Dosage', 'DOS', b.types.text)
YEAR Treatment Dosage YEAR(1) TREAT(1) DOS(1) YEAR(2) TREAT(2) DOS(2) ... ... ...
Informationnal classes and directives
The question comments can be used to add information to a question.
Directives gives richer wording capabilities and classes are question categories indications.
Comment can be written as : directives...{classes...}
. Classes begins with a dot .
directive or class | |
---|---|
(...) | simple information |
<... | ...> | left and right labels |
.row | the categories should be disposed on a row |
.column | the categories should be disposed on a column |
.pad or .pad1 | pad the question on the left |
.pad2 | pad the question on the left with 2 levels |
.pad3 | pad the question on the left with 3 levels |
.no-specials | the field won't be modifiable, thus do not display specials |
Example
b.page(' ')
.question('Pain scale', 'PAIN', b.types.scale(1, 10))
.comment('<No pain | Maximum pain>{.row}')
Pain scale No pain 1 2 3 4 5 6 7 8 9 10 Maximum pain