diff --git a/.gitignore b/.gitignore index 789dd37..69bc636 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ **/.lighthouseci/** - +env/* +.vscode/* # postgres persisted data pgdata/ + +.gitsecret/keys/random_seed +!*.secret diff --git a/.gitsecret/keys/pubring.kbx b/.gitsecret/keys/pubring.kbx new file mode 100644 index 0000000..47ce7c0 Binary files /dev/null and b/.gitsecret/keys/pubring.kbx differ diff --git a/.gitsecret/keys/pubring.kbx~ b/.gitsecret/keys/pubring.kbx~ new file mode 100644 index 0000000..67af14b Binary files /dev/null and b/.gitsecret/keys/pubring.kbx~ differ diff --git a/.gitsecret/keys/trustdb.gpg b/.gitsecret/keys/trustdb.gpg new file mode 100644 index 0000000..90e4459 Binary files /dev/null and b/.gitsecret/keys/trustdb.gpg differ diff --git a/.gitsecret/paths/mapping.cfg b/.gitsecret/paths/mapping.cfg new file mode 100644 index 0000000..e69de29 diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 0000000..59ee0a5 --- /dev/null +++ b/db/.gitignore @@ -0,0 +1 @@ +**/__pycache__/** diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..820f0d3 --- /dev/null +++ b/db/README.md @@ -0,0 +1,29 @@ +# Database Management + +How to load a backup into your postgres docker container + +First, decrypt the pg_dump files. +``` +git secret reveal +``` +Second, restore the pg_dump into the container. +```bash +gunzip -c ./backups/version.sql.gz | docker exec -i hdhs_database_1 psql -U postgres -d app_db +``` + +## Running Notes + +generate backups: +```bash +docker exec hdhs_database_1 pg_dump -U postgres app_db | gzip > backups/20200408.tar.gz +``` + +Enter database container +```bash +docker-compose -f dev-docker-compose.yml run database bash +``` + +Enter DB inside container +```bash +psql --host=database --username=postgres --dbname=app_db +``` \ No newline at end of file diff --git a/db/clean-python.bash b/db/clean-python.bash new file mode 100755 index 0000000..5e65ecd --- /dev/null +++ b/db/clean-python.bash @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Author: "abpwrs" +# Date: 20200409 + +# script: +isort -rc . +black . + diff --git a/db/db.py b/db/db.py new file mode 100644 index 0000000..51056fa --- /dev/null +++ b/db/db.py @@ -0,0 +1,71 @@ +from abc import ABC, abstractmethod + +import numpy as np +import pandas as pd + +from utils import edit_distance + + +""" +DESC: + Abstract Base Class (ABC) for a Database Object + Extracts shared logic from the FMP and PSQL classes +""" + + +class DB(ABC): + def __init__(self): + self._model = None + self.datetime_format = "%Y%m%d_%H%M" + self._table_names = [] + + # ABSTRACT METHDOS TO BE IMPLEMENTED BY CHILD CLASSES + # ################################################### + + @abstractmethod + def read_table(self, table_name) -> pd.DataFrame: + pass + + @abstractmethod + def write_table(self, data: pd.DataFrame, table_name): + pass + + # ################################################### + + @property + def table_names(self) -> list: + return self._table_names + + def get_tablename_from_fieldname(self, field_name: str): + try: + assert field_name[:3] == "pk_" or field_name[:3] == "fk_" + except AssertionError as e: + print( + "Can only get meaningful table names from field names on primary or foreign keys" + ) + raise (e) + + # remove "pk_" or "fk_" --> we only enter this function if this has already matched + extracted_field_to_match = field_name[3:] + + # not all fields that start with "pk_" or "fk_" end in "_id" + if field_name[-3:] == "_id": + extracted_field_to_match = extracted_field_to_match[:-3] + else: + print(f"consider fixing fieldname: {field_name}") + + # function that returns the edit distance of the table to the extracted field name + matcher = lambda table_name: edit_distance(table_name, extracted_field_to_match) + + try: + # map lambda over list of table `names & extract min edit distance + index_of_match = int( + np.argmin(np.array(list(map(matcher, self.table_names))), axis=0) + ) + except TypeError as e: + print("FAILURE: two tables have equally close edit_distance") + # TODO: potentially add a backup lookup table here + # for now we will re-raise the error and crash the program + raise (e) + + return self.table_names[index_of_match] diff --git a/db/execute-sql-in-container.py b/db/execute-sql-in-container.py new file mode 100755 index 0000000..73a0181 --- /dev/null +++ b/db/execute-sql-in-container.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# Author: "hertz" +# Date: 20200310 +# #################### +# DESC: +# A script to execute a SQL script inside of postgres docker container +# #################### + + +# args: +# 1 -- name of container running postgres to execute the script inside of +# 2 -- path to .SQL script to be execute inside of the postgres container + + +import argparse +import pickle +import sys + +from sqlalchemy import create_engine + +from psql import PSQL + +engine = create_engine("postgres+psycopg2://postgres:HaitiCHI@0.0.0.0:5432/app_db") + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("command", help="SQL command to run in container") + return parser.parse_args() + + +def main(argv): + # TODO: extract this from database.env (ideally but it in the PSQL class) + psql = PSQL("postgres+psycopg2://postgres:HaitiCHI@0.0.0.0:5432/app_db") + result = psql.execute_command(argv.command) + print(result) + + +if __name__ == "__main__": + sys.exit(main(parse_args())) diff --git a/db/fmp.py b/db/fmp.py new file mode 100644 index 0000000..8f0e540 --- /dev/null +++ b/db/fmp.py @@ -0,0 +1,168 @@ +import json +from datetime import datetime + +import numpy as np +import pandas as pd +import pyodbc +from tqdm import tqdm + +from db import DB +from utils import edit_distance + + +""" +File Maker Pro Connection Object + +Desc: +This file contains the primary logic for interacting with the file maker pro database +""" + + +class FMP(DB): + def __init__(self, dsn=None, username=None, password=None, connect=True): + DB.__init__(self) + if dsn is None: + dsn = input("Enter the FMP DSN: ") + if username is None: + username = input("Enter your FMP username: ") + if password is None: + password = input("Enter your FMP password: ") + + self.__dsn = dsn + self.__username = username + self.__password = password + + self.__connection_string = "DSN={0};UID={1};PWD={2}".format( + self.__dsn, self.__username, self.__password + ) + + # TODO: consider extracting this list to the DB class + self._table_names = [ + "globals", + "clinics", + "patients", + "visits", + "diagnoses", + "diagnoses_made", + "meds", + "meds_dispensed", + "geonames", + "settings", + ] + + self._join_tables = [ + "diagnoses_made", + "meds_dispensed" + ] + + self.__python_types_to_sql = { + "str": "VARCHAR", + "date": "TIMESTAMP", + "float64": "FLOAT", + "NoneType": "VARCHAR", + } + + self.__pyodbc_connection = None + self.__cursor = None + if connect: + self.__connect() + + # METHODS FROM DB SUPER CLASS + # ########################### + + def read_table(self, table_name) -> pd.DataFrame: + query = "select * from {table}".format(table=table_name) + df = pd.read_sql(query, self.connection) + return df + + def write_table(self, data, table_name): + # TODO: implement write_table in FMP class + print("IMPLEMENT WRITE_TABLE IN FMP") + + # ########################### + + def __connect(self): + if self.__pyodbc_connection is None: + self.__pyodbc_connection = pyodbc.connect(self.__connection_string) + self.__pyodbc_connection + + def execute_command(self, command_string): + result = None + try: + result = pd.read_sql(command_string, self.connection) + except Exception as e: + print(f"Failure to execute: ['{command_string}']") + raise (e) # TODO: determine if crashing is appropriate + return result + + @property + def python_types_to_sql(self): + return self.__python_types_to_sql + + @property + def connection(self): + if self.__pyodbc_connection is None: + self.__connect() + return self.__pyodbc_connection + + @property + def cursor(self): + if self.__cursor is None: + self.__cursor = self.connection.cursor() + return self.__cursor + + # ABSTRACT BASE CLASS MEHTODS + def parse_database(self) -> dict(): + self._model = dict() + + for table_name in tqdm(self.table_names): + df = self.read_table(table_name) + + self._model[table_name] = {} + col_types = [ + df.iloc[0, index].__class__.__name__ for index in range(len(df.columns)) + ] + + smallest_pk_edit_distance = float("inf") + previous_pk_field = None + for field_name, field_type in zip(df.columns, col_types): + self._model[table_name][field_name] = {} + self._model[table_name][field_name]["type"] = field_type + + # extract primary keys + if field_name[:3] == "pk_": + # possible primary key + distance = edit_distance(field_name[3:-3], table_name) + if distance < smallest_pk_edit_distance: + # closest primary key so far + if previous_pk_field is not None: + # remove previously assumed pk + self._model[table_name][previous_pk_field]["pk"] = False + + # set current pk + self._model[table_name][field_name]["pk"] = True + previous_pk_field = field_name + smallest_pk_edit_distance = distance + else: + self._model[table_name][field_name]["pk"] = False + else: + self._model[table_name][field_name]["pk"] = False + + # extract foreign keys + if field_name[:3] == "fk_": + self._model[table_name][field_name]["fk"] = True + self._model[table_name][field_name][ + "fk_table" + ] = self.get_tablename_from_fieldname(field_name) + else: + self._model[table_name][field_name]["fk"] = False + + return self._model + + def write_model(self, model_object: dict = None): + curr_time = datetime.now().strftime(self.datetime_format) + with open("./models/{time}_model.json".format(time=curr_time), "w") as f: + if model_object is not None: + json.dump(model_object, f, indent=4) + else: + json.dump(self._model, f, indent=4) diff --git a/db/fmp_to_postgres.py b/db/fmp_to_postgres.py new file mode 100644 index 0000000..1a2353b --- /dev/null +++ b/db/fmp_to_postgres.py @@ -0,0 +1,148 @@ +import argparse +import os +import pickle +import sys + +import pandas as pd +import numpy as np +from tqdm import tqdm + +from fmp import FMP +from psql import PSQL +from utils import edit_distance + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("-d", "--dsn", help="dsn name") + parser.add_argument("-u", "--username", help="username for dsn") + parser.add_argument("-p", "--password", help="password for dsn") + return parser.parse_args() + + +def get_pk_and_fk_from_table(fmp: FMP, df: pd.DataFrame, table_name: str): + pk = None + fks = [] + smallest_pk_edit_distance = float("inf") + for field_name in df.columns: + # extract primary keys + if field_name[:3] == "pk_" and field_name[-3:] == "_id": + # possible primary key + distance = edit_distance(field_name[3:-3], table_name) + if distance < smallest_pk_edit_distance: + pk = field_name + smallest_pk_edit_distance = distance + + # extract foreign keys + if field_name[:3] == "fk_" and field_name[-3:] == "_id": + fks.append((field_name, fmp.get_tablename_from_fieldname(field_name))) + + return pk, fks + + +def save_keys(filename: str, all_data: dict): + np.save(filename, all_data) + + +def load_dict(filename: str): + np_load_old = np.load + np.load = lambda *a, **k,: np_load_old(*a, allow_pickle=True, **k) + np_data = np.load(filename)[()] + + # Restore np.load + np.load = np_load_old + + return np_data + +def main(argv): + fmp = FMP(argv.dsn, argv.username, argv.password) + # TODO: extract username, password, and database name from the database.env file + # TODO: encrypt the database.env file using git-secret + psql = PSQL("postgres+psycopg2://postgres:HaitiCHI@0.0.0.0:5432/app_db") + + all_data = dict() + for table_name in tqdm(fmp.table_names): + print(table_name) + df = fmp.read_table(table_name) + # drop all exact duplicates + df.drop_duplicates(inplace=True) + if table_name == "meds_dispensed": + new_df = df[df["pk_meds_dispensed"].notna()] + new_df = new_df[new_df["fk_visit_id"].notna()] + new_df = new_df[new_df["fk_med_key"].notna()] + new_df.drop_duplicates(subset=["pk_meds_dispensed","fk_visit_id", "fk_med_key"], inplace=True) + print(f"Table ({table_name}) lost ({len(df) - len(new_df)}) entries") + df = new_df + elif table_name == "diagnoses_made": + new_df = df[df["pk_diagnoses_made"].notna()] + new_df = new_df[new_df["fk_visit_id"].notna()] + new_df = new_df[new_df["fk_diagnosis_key"].notna()] + new_df.drop_duplicates(subset=["pk_diagnoses_made", "fk_visit_id", "fk_diagnosis_key"], inplace=True) + print(f"Table ({table_name}) lost ({len(df) - len(new_df)}) entries") + df = new_df + elif table_name == "settings": + df['pk_settings_id'] = df.index + elif table_name == "patients": + df.drop(["face_photo"], axis=1, inplace=True) + query = "SELECT GetAs(face_photo, 'JPEG') FROM patients" + result = fmp.cursor.execute(query) + data = result.fetchall() + data = [item[0] for item in data] + df["face_photo"] = data + + + psql.write_table(df, table_name, if_exists="replace") + + pk_field, fk_fields = get_pk_and_fk_from_table(fmp, df, table_name) + all_data[table_name] = (pk_field, fk_fields) + + filename = "table_data.npy" + save_keys(filename, all_data) + + + filename = "table_data.npy" + all_data = load_dict(filename) + + for table_name in all_data.keys(): + if table_name not in fmp._join_tables: + pk_field, _ = all_data[table_name] + if pk_field is not None: + psql.execute_command(f"ALTER TABLE {table_name} ALTER COLUMN {pk_field} TYPE numeric USING {pk_field}::numeric;") + psql.execute_command(f"ALTER TABLE {table_name} ADD PRIMARY KEY ({pk_field});") + else: + if table_name == "meds_dispensed": + for pk_field in ["pk_meds_dispensed", "fk_visit_id"]: + psql.execute_command(f"ALTER TABLE {table_name} ALTER COLUMN {pk_field} TYPE numeric USING {pk_field}::numeric;") + psql.execute_command(f"ALTER TABLE {table_name} ADD PRIMARY KEY (pk_meds_dispensed, fk_visit_id, fk_med_key);") + elif table_name == "diagnoses_made": + for pk_field in ["pk_diagnoses_made", "fk_visit_id"]: + psql.execute_command(f"ALTER TABLE {table_name} ALTER COLUMN {pk_field} TYPE numeric USING {pk_field}::numeric;") + psql.execute_command(f"ALTER TABLE {table_name} ADD PRIMARY KEY (pk_diagnoses_made, fk_visit_id, fk_diagnosis_key);") + + + for table_name in all_data.keys(): + _, fk_fields = all_data[table_name] + + for fk_field, fk_table in fk_fields: + pk_fk_table = all_data[fk_table][0] + # ASSUMES WE ARE ALWAYS LINKING TO THE PK OF THE TABLE + constraint_name = f"fk_{fk_table}_id" + + psql.execute_command(f"ALTER TABLE {table_name} ALTER COLUMN {fk_field} TYPE numeric USING {fk_field}::numeric;") + + ''' + psql.execute_command( + f"ALTER TABLE {table_name} DROP CONSTRAINT IF EXISTS {constraint_name};" + ) + ''' + + try: + psql.execute_command( + f"ALTER TABLE {table_name} ADD CONSTRAINT {constraint_name} FOREIGN KEY ({fk_field}) REFERENCES {fk_table}({pk_fk_table});" + ) + except Exception as e: + print(f"FAILURE TO IMPLEMENT CONSTRAINT ON ({table_name}.{fk_field}) to ({fk_table}.{pk_fk_table})") + + +if __name__ == "__main__": + sys.exit(main(parse_args())) diff --git a/db/logs/20200425.log b/db/logs/20200425.log new file mode 100644 index 0000000..089dd76 --- /dev/null +++ b/db/logs/20200425.log @@ -0,0 +1,58 @@ + 0%| | 0/10 [00:00 pd.DataFrame: + # TODO: implement read_table functionality in PSQL + print("NEED TO IMPLEMENT THIS") + + def write_table( + self, data_frame: pd.DataFrame, table_name: str, if_exists: str = "fail" + ): + data_frame.to_sql( + con=self.__engine, name=table_name, if_exists=if_exists, index=False + ) + + # ########################### + + def open_connection(self): + return self.__engine.connect() + + def execute_command(self, command_string): + print(f"PSQL EXECUTING: [{command_string}]") + result = None + try: + with self.open_connection() as connection: + result = connection.execute(command_string) + except Exception as e: + print(f"Failure to execute: ['{command_string}']") + raise (e) # TODO: determine if crashing is appropriate + + return result diff --git a/db/query_fmp.py b/db/query_fmp.py new file mode 100644 index 0000000..f64ad75 --- /dev/null +++ b/db/query_fmp.py @@ -0,0 +1,58 @@ +import argparse +import os +import pickle +import sys +import pyodbc + +import pandas as pd +from tqdm import tqdm + +from fmp import FMP +from psql import PSQL +from utils import edit_distance +from PIL import Image +import io + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("-d", "--dsn", help="dsn name") + parser.add_argument("-u", "--username", help="username for dsn") + parser.add_argument("-p", "--password", help="password for dsn") + return parser.parse_args() + + +def main(argv): + fmp = FMP(argv.dsn, argv.username, argv.password) + # query = "SELECT GetAs(face_photo, 'JPEG') FROM patients WHERE pk_patient_id=31883 OR pk_patient_id=1" + # result = fmp.cursor.execute(query) + # data = result.fetchall() + # data = [item[0] for item in data] + # print(data, len(data)) + # images = [Image.open(io.BytesIO(d)) for d in data] + # [image.show() for image in images] + + + # table_name = "diagnoses_made" + # print(table_name) + # res_1 = fmp.read_table(table_name) + # print(res_1) + + table_name = "globals" + print(table_name) + res_2 = fmp.read_table(table_name) + print(res_2.columns) + print(res_2) + # print(res_1 == res_2) + + # table_name = "clinics" + # print(table_name) + # res_3 = fmp.read_table(table_name) + # print(res_3) + + # print(type(fmp.execute_command("SELECT face_photo FROM patients WHERE pk_patient_id=31883")["face_photo"][0])) + # print(fmp.execute_command("SELECT face_photo FROM patients WHERE pk_patient_id=31883")["face_photo"][0]) + + +if __name__ == "__main__": + sys.exit(main(parse_args())) diff --git a/db/reset_postgres.py b/db/reset_postgres.py new file mode 100644 index 0000000..7ea58ac --- /dev/null +++ b/db/reset_postgres.py @@ -0,0 +1,8 @@ +from psql import PSQL + +psql = PSQL("postgres+psycopg2://postgres:HaitiCHI@0.0.0.0:5432/app_db") + +psql.execute_command("DROP SCHEMA public CASCADE") +psql.execute_command("CREATE SCHEMA public") +psql.execute_command("GRANT ALL ON SCHEMA public TO postgres") +psql.execute_command("GRANT ALL ON SCHEMA public TO public") diff --git a/db/utils.py b/db/utils.py new file mode 100644 index 0000000..ea9021d --- /dev/null +++ b/db/utils.py @@ -0,0 +1,22 @@ +def edit_distance(str1, str2): + return edit_distance_helper(str1, str2, len(str1) - 1, len(str2) - 1) + + +def edit_distance_helper(str1, str2, m, n): + if m == 0: + return n + + if n == 0: + return m + + if str1[m] == str2[n]: + return edit_distance_helper(str1, str2, m - 1, n - 1) + + return ( + min( + edit_distance_helper(str1, str2, m, n - 1), + edit_distance_helper(str1, str2, m - 1, n), + edit_distance_helper(str1, str2, m - 1, n - 1), + ) + + 1 + ) diff --git a/dev-docker-compose.yml b/dev-docker-compose.yml index 3fcc8f0..f90730d 100644 --- a/dev-docker-compose.yml +++ b/dev-docker-compose.yml @@ -24,7 +24,7 @@ services: ports: - "5432:5432" expose: - - "5422" + - "5432" environment: POSTGRES_DB: app_db POSTGRES_USER: postgres diff --git a/pi-docker-compose.yml b/pi-docker-compose.yml index ff7c11c..33dbdef 100644 --- a/pi-docker-compose.yml +++ b/pi-docker-compose.yml @@ -16,7 +16,7 @@ services: database: image: "postgres" # use latest official postgres version env_file: - - database.env # configure postgres + - ./database.env # configure postgres volumes: - "database-data:/var/lib/postgresql/data/" # persist data even if container shuts down ports: diff --git a/pwa/.eslintrc.js b/pwa/.eslintrc.js index 8c937cd..c154405 100644 --- a/pwa/.eslintrc.js +++ b/pwa/.eslintrc.js @@ -1,25 +1,26 @@ module.exports = { - parser: "@typescript-eslint/parser", // Specifies the ESLint parser - extends: [ - "plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react - "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin - "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier - "plugin:prettier/recommended", // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. - ], - parserOptions: { - ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features - sourceType: "module", // Allows for the use of imports - ecmaFeatures: { - jsx: true, // Allows for the parsing of JSX + parser: '@typescript-eslint/parser', // Specifies the ESLint parser + extends: [ + 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react + 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin + 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier + 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. + ], + parserOptions: { + ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features + sourceType: 'module', // Allows for the use of imports + ecmaFeatures: { + jsx: true, // Allows for the parsing of JSX + }, }, - }, - rules: { - "@typescript-eslint/no-empty-interface": "off", - "@typescript-eslint/ban-ts-ignore": "off", - }, - settings: { - react: { - version: "detect", // Tells eslint-plugin-react to automatically detect the version of React to use + rules: { + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/camelcase': 'off', }, - }, -}; + settings: { + react: { + version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use + }, + }, +} diff --git a/pwa/package-lock.json b/pwa/package-lock.json index b930bb7..d085876 100644 --- a/pwa/package-lock.json +++ b/pwa/package-lock.json @@ -2198,9 +2198,9 @@ }, "dependencies": { "acorn": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", - "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==" + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz", + "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==" } } }, @@ -4626,6 +4626,11 @@ "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", "dev": true }, + "debounce": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz", + "integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==" + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -5005,6 +5010,37 @@ } } }, + "easy-peasy": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/easy-peasy/-/easy-peasy-3.3.0.tgz", + "integrity": "sha512-wkTDAG+nRy+4g8Z3/k1vuTyrhxet19oY5MCmuRgOObm8dtZ2m+g5Mk/sdf3UWPuyx/sKIYH5Fy/HXab8iNKjTQ==", + "requires": { + "debounce": "^1.2.0", + "immer-peasy": "3.1.3", + "is-plain-object": "^3.0.0", + "memoizerific": "^1.11.3", + "prop-types": "^15.6.2", + "redux": "^4.0.5", + "redux-thunk": "^2.3.0", + "symbol-observable": "^1.2.0", + "ts-toolbelt": "^6.1.6" + }, + "dependencies": { + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "requires": { + "isobject": "^4.0.0" + } + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==" + } + } + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -7186,6 +7222,11 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz", "integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==" }, + "immer-peasy": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/immer-peasy/-/immer-peasy-3.1.3.tgz", + "integrity": "sha512-WzoZ96A93jOmcDOLNChMWAqy+ZU8vEYQx2DcKjgo7P5SToiJs+GL+5yQbWzH8X02Lhvv6xrGgVNa1xbki66Eow==" + }, "import-cwd": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", @@ -7825,9 +7866,9 @@ }, "dependencies": { "acorn": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", - "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==" + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz", + "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==" }, "jsdom": { "version": "14.1.0", @@ -8766,9 +8807,9 @@ }, "dependencies": { "acorn": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", - "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==" + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", + "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==" } } }, @@ -9451,6 +9492,11 @@ "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=" }, + "map-or-similar": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", + "integrity": "sha1-beJlMXSt+12e3DPGnT6Sobdvrwg=" + }, "map-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", @@ -9489,6 +9535,14 @@ "p-is-promise": "^2.0.0" } }, + "memoizerific": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", + "integrity": "sha1-fIekZGREwy11Q4VwkF8tvRsagFo=", + "requires": { + "map-or-similar": "^1.5.0" + } + }, "memory-fs": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", @@ -12929,6 +12983,20 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", + "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", + "requires": { + "loose-envify": "^1.4.0", + "symbol-observable": "^1.2.0" + } + }, + "redux-thunk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", + "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" + }, "regenerate": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", @@ -14861,8 +14929,7 @@ "symbol-observable": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", - "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", - "dev": true + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" }, "symbol-tree": { "version": "3.2.4", @@ -15282,6 +15349,11 @@ "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.5.tgz", "integrity": "sha512-ti7OGMOUOzo66wLF3liskw6YQIaSsBgc4GOAlWRnIEj8htCxJUxskanMUoJOD6MDCRAXo36goXJZch+nOS0VMA==" }, + "ts-toolbelt": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.5.1.tgz", + "integrity": "sha512-zjnZ/vy1eUA0li3H0JXecl0R3jiP42snpLimsrppt9V3LLbM4NM4jMgjXQ4S67hvehq+r9CxpX4Wj6RnFRzReA==" + }, "tslib": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", diff --git a/pwa/package.json b/pwa/package.json index 80c48ce..4b05093 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -13,6 +13,7 @@ "@types/react-dom": "^16.9.5", "@types/react-router-dom": "^5.1.4", "antd-mobile": "^2.3.1", + "easy-peasy": "^3.3.0", "node-sass": "^4.13.1", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/pwa/src/App.test.tsx b/pwa/src/App.test.tsx index c5bda6a..a35beee 100644 --- a/pwa/src/App.test.tsx +++ b/pwa/src/App.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import { render } from '@testing-library/react' import App from './App' -test('renders learn react link', () => { +test('renders login element', () => { const { getByText } = render() const linkElement = getByText(/Login/i) expect(linkElement).toBeInTheDocument() diff --git a/pwa/src/App.tsx b/pwa/src/App.tsx index 7d5b88e..a31860c 100644 --- a/pwa/src/App.tsx +++ b/pwa/src/App.tsx @@ -1,13 +1,19 @@ import React from 'react' import { BrowserRouter as Router, Switch, Route } from 'react-router-dom' +import { StoreProvider } from 'easy-peasy' +import store from './state/store' + import Login from './components/Login' import LandingPage from './components/LandingPage' import Wrapper from './Wrapper' import PatientInfoForm from './components/PatientInfoForm' +import PatientSearch from './components/PatientSearch' +import DiagnosisForm from './components/DiagnosisForm' import './scss/App.scss' import './antd-mobile.css' +<<<<<<< HEAD import TreatmentForm from './components/TreatmentForm' const App: React.FunctionComponent = () => { @@ -21,19 +27,57 @@ const App: React.FunctionComponent = () => { +======= +import DebugShowEasyPeasy from './components/DebugShowEasyPeasy' + +const App: React.FunctionComponent = () => { + return ( + + + + + + + + + + + + + + + + + + + + +>>>>>>> origin/dev - + - - - - - + + + + + + + + + ) } diff --git a/pwa/src/components/DebugShowEasyPeasy.tsx b/pwa/src/components/DebugShowEasyPeasy.tsx new file mode 100644 index 0000000..c3bb437 --- /dev/null +++ b/pwa/src/components/DebugShowEasyPeasy.tsx @@ -0,0 +1,16 @@ +/* istanbul ignore file */ +// This component is a debug utility to show +// all contents of easy-peasy when rendered + +import React, { ReactElement } from 'react' +import { useStoreState } from '../state/storeIndex' + +export default function DebugShowEasyPeasy(): ReactElement { + const patientInfoFormData = useStoreState( + (state) => state.patientInfoFormModel.fields, + ) + + console.log(patientInfoFormData) + + return
patientInfoFormData: {JSON.stringify(patientInfoFormData)}
+} diff --git a/pwa/src/components/DiagnosisFields.json b/pwa/src/components/DiagnosisFields.json new file mode 100644 index 0000000..7c43993 --- /dev/null +++ b/pwa/src/components/DiagnosisFields.json @@ -0,0 +1,189 @@ +{ + "diagnoses": [ + { + "name": "Asthma", + "id": "asthma" + }, + { + "name": "Allergies", + "id": "allergies" + }, + { + "name": "Anemia", + "id": "anemia" + }, + { + "name": "Malnutrition", + "id": "malnutrition" + }, + { + "name": "Depression", + "id": "depression" + }, + { + "name": "Diabetes", + "id": "diabetes" + }, + { + "id": "eye", + "group-title": "Eye", + "group": [ + { + "name": "Ptergium", + "id": "ptergium" + }, + { + "name": "Cataract", + "id": "cataract" + }, + { + "name": "Dry Eye", + "id": "dry-eye" + }, + { + "name": "Conjuntivitis", + "id": "conjuntivitis" + } + ] + }, + { + "name": "Fever", + "id": "fever" + }, + { + "name": "Possible Malaria", + "id": "malaria" + }, + { + "name": "Filariasis", + "id": "filariasis" + }, + { + "id": "intestinal", + "group-title": "Intestinal", + "group": [ + { "name": "Gastritis", "id": "gastritis" }, + { "name": "GERD", "id": "gerd" }, + { "name": "Gastroenteritis", "id": "gastroenteritis" }, + { "name": "Dysentery", "id": "dysentery" }, + { "name": "Possible Cholera", "id": "cholera" } + ] + }, + { + "name": "Headache", + "id": "headache" + }, + { + "group-title": "Hernia", + "id": "hernia", + "group": [ + { "name": "Inguinal", "id": "inguinal" }, + { "name": "Umbilical", "id": "umbilical" } + ] + }, + { + "id": "cardiovascular", + "group-title": "Cardiovascular", + "group": [ + { "name": "Hypertension", "id": "hypertension" }, + { "name": "Stroke", "id": "stroke" }, + { "name": "Angina", "id": "angina" }, + { "name": "CHF", "id": "chf" } + ] + }, + { + "name": "Healthy", + "id": "healthy" + }, + + { + "name": "Musculoskeletal Pain", + "id": "musculoskeletal" + }, + { + "group-title": "Otitis", + "id": "otitis", + "group": [ + { "name": "Right", "id": "otitis-right" }, + { "name": "Left", "id": "otitis-left" } + ] + }, + { + "name": "Pneumonia", + "id": "pneumonia" + }, + { + "id": "sinusitis", + "name": "Sinusitis" + }, + { + "name": "URI", + "id": "uri" + }, + { + "name": "Pregnancy", + "id": "pregnancy", + "text-input-title": "Weeks", + "text-input-type": "number", + "max-length": 2, + "text-input-id": "pregnancy-weeks" + }, + { + "group-title": "Dermatological", + "id": "dermatological", + "group": [ + { "name": "Atopic Derm", "id": "atopic-derm" }, + { "name": "Fungal", "id": "fungal" }, + { "name": "Scabies", "id": "scabies" }, + { "name": "Impetigo", "id": "Impetigo" }, + { "name": "Tinea Capitus (kerion)", "id": "tinea-capitus" } + ] + }, + { + "name": "Possible STD", + "id": "std" + }, + { + "name": "Possible TB", + "id": "tb" + }, + { + "name": "Possible Parasites", + "id": "parasites" + }, + { + "name": "UTI", + "id": "uti" + }, + { + "group-title": "Vaginal", + "id": "vaginal", + "group": [ + { "name": "Vaginitis", "id": "vaginitis" }, + { "name": "Candidal", "id": "candidal" }, + { "name": "Trich", "id": "trich" }, + { "name": "BV", "id": "bv" } + ] + }, + { + "name": "Needs to see Dentist", + "id": "dentist" + }, + { + "name": "Needs to see Eye Doctor", + "id": "eye-doctor" + }, + { + "name": "Surgical candidate", + "id": "surgical-candidate" + }, + { + "text-input-title": "Other Diagnosis", + "text-input-id": "other-diagnosis" + }, + { + "text-input-title": "Diagnoses Comments", + "text-input-id": "comments" + } + ] +} diff --git a/pwa/src/components/DiagnosisForm.test.tsx b/pwa/src/components/DiagnosisForm.test.tsx new file mode 100644 index 0000000..767f94a --- /dev/null +++ b/pwa/src/components/DiagnosisForm.test.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import DiagnosisForm from './DiagnosisForm' +import * as data from './DiagnosisFields.json' + +/* + +Diagnosis Fields are stored in DiagnosisFields.json in the format: + +{ + "diagnoses" : [ + { + "name": "Name", + "id": "id", + "text-input-title": "Title", + "text-input-type": "type" (i.e. number) + "group-title": "Group Title", + "group": [ + { "name": "Name1", "id": "id1" }, + { "name": "Name2", "id": "id2" } + ] + }, + {...} + ] +} +*/ + +const diagnosisFields = data.diagnoses + +test('renders the form', async () => { + const dom = render() + expect(dom.getByTestId('diagnosis-form-wrapper')).toBeTruthy() +}) + +test('renders each diagnoses.name data field', () => { + const { getByText } = render() + + diagnosisFields.forEach((diagnosis) => { + if (diagnosis.name != null) { + const re = new RegExp(diagnosis.name, 'gi') + const field = getByText(re) + expect(field).toBeInTheDocument() + } + }) +}) + +test(' each diagnoses.name has a corresponding checkbox and is able to be checked/unchecked', () => { + const { getByTestId } = render() + diagnosisFields.forEach((diagnosis) => { + if (diagnosis.name != null && diagnosis.id != null) { + const re = new RegExp(diagnosis.id, 'gi') + const checkboxItem = getByTestId(re) + expect(checkboxItem).toHaveClass('am-checkbox-item') + + const checkBox = checkboxItem.getElementsByTagName('span')[0] + expect(checkBox).not.toHaveClass('am-checkbox-checked') + const checkBoxInput = checkboxItem.getElementsByClassName( + 'am-checkbox-input', + )[0] + fireEvent.click(checkBoxInput) + expect(checkBox).toHaveClass('am-checkbox-checked') + } + }) +}) + +test('renders each diagnoses.text-input-title data field as a text entry field', () => { + const { getByTestId } = render() + + diagnosisFields.forEach((diagnosis) => { + if ( + diagnosis['text-input-title'] != null && + diagnosis['text-input-title'] != null + ) { + const re = new RegExp(diagnosis['text-input-id'], 'gi') + const field = getByTestId(re) + expect(field).toBeInTheDocument() + expect(field).toHaveAttribute('type', 'text') + } + }) +}) + +test('renders each diagnoses.group-title data field as a header', () => { + const { getByText } = render() + diagnosisFields.forEach((diagnosis) => { + if (diagnosis['group-title'] != null && diagnosis.group != null) { + const re = new RegExp(`\^${diagnosis['group-title']}\$`, 'gi') + const field = getByText(re) + expect(field).toBeInTheDocument() + expect(field).toHaveClass('am-list-header') + } + }) +}) + +test('renders each diagnoses.group entry in the same list element', () => { + const { getByTestId } = render() + diagnosisFields.forEach((diagnosis) => { + if (diagnosis.group != null && diagnosis.id != null) { + // get the list by diagnosis.id + const re = new RegExp(`\^${diagnosis.id}\$`, 'gi') + const field = getByTestId(re) + expect(field).toBeInTheDocument() + expect(field).toHaveClass('am-list') + + // For each element in the group, ensure it is contained in the list + diagnosis.group.forEach((groupElement) => { + const el = getByTestId(groupElement.id) + expect(field).toContainElement(el) + }) + } + }) +}) + +test('renders each diagnoses.[el] as a checkbox that can be checked/unchecked', () => { + const { getByTestId } = render() + diagnosisFields.forEach((diagnosis) => { + if (diagnosis.group != null) { + diagnosis.group.forEach((groupElement) => { + const re = new RegExp(groupElement.id, 'gi') + const groupCheckboxItem = getByTestId(re) + expect(groupCheckboxItem).toBeInTheDocument() + expect(groupCheckboxItem).toHaveClass('am-checkbox-item') + + const checkBox = groupCheckboxItem.getElementsByTagName( + 'span', + )[0] + expect(checkBox).not.toHaveClass('am-checkbox-checked') + const checkBoxInput = groupCheckboxItem.getElementsByClassName( + 'am-checkbox-input', + )[0] + fireEvent.click(checkBoxInput) + expect(checkBox).toHaveClass('am-checkbox-checked') + }) + } + }) +}) diff --git a/pwa/src/components/DiagnosisForm.tsx b/pwa/src/components/DiagnosisForm.tsx new file mode 100644 index 0000000..a242d96 --- /dev/null +++ b/pwa/src/components/DiagnosisForm.tsx @@ -0,0 +1,117 @@ +import React, { ReactElement } from 'react' +import { List, Checkbox, InputItem, WhiteSpace } from 'antd-mobile' +import '../scss/DiagnosisForm.scss' +import * as data from './DiagnosisFields.json' + +/* + +Diagnosis Fields are stored in DiagnosisFields.json in the format: + +{ + "diagnoses" : [ + { + "name": "Name", + "id": "id", + "text-input-title": "Title", + "text-input-id": "id" for `id` and `data-testid` attributes + "text-input-type": "type" (i.e. number) + "max-length": 3 + "group-title": "Group Title", + "group": [ + { "name": "Name1", "id": "id1" }, + { "name": "Name2", "id": "id2" } + ] + }, + {...} + ] +} +*/ + +const CheckboxItem = Checkbox.CheckboxItem +const diagnosisFields = data.diagnoses + +export default function DiagnosisForm(): ReactElement { + return ( +
+ {diagnosisFields.map((field) => { + const formElements: ReactElement[] = [] + + if (field.name != null) { + formElements.push( + + + {field.name} + + , + ) + } + + if ( + field['text-input-title'] != null && + field['text-input-id'] != null + ) { + formElements.push( + field['text-input-title']}> + + , + ) + } + + if (field.group != null) { + const groupElements: ReactElement[] = [] + + // add each group element to the internalElement array + field.group.forEach((ele: any) => { + if (ele.id != null && ele.name != null) { + groupElements.push( + + {ele.name} + , + ) + } + }) + + // Render the entire list with the internal elements + if (field['group-title'] != null && field.id !== null) { + formElements.push( + field['group-title']} + data-testid={field.id} + > + {groupElements} + + , + ) + } + } + + return <> {formElements} + })} +
+ ) +} diff --git a/pwa/src/components/LandingPage.test.tsx b/pwa/src/components/LandingPage.test.tsx index 6018d99..12cc9ac 100644 --- a/pwa/src/components/LandingPage.test.tsx +++ b/pwa/src/components/LandingPage.test.tsx @@ -1,11 +1,17 @@ import React from 'react' import { render } from '@testing-library/react' +import { BrowserRouter as Router } from 'react-router-dom' + import LandingPage from './LandingPage' -test('renders learn react link', () => { - const { getByText } = render() - const lookupButtonElement = getByText(/Patient Lookup/i) - const newPatientButtonElement = getByText(/New Patient/i) +test('renders landing buttons', () => { + const landingComponent = render( + + + , + ) + const lookupButtonElement = landingComponent.getByText(/Patient Lookup/i) + const newPatientButtonElement = landingComponent.getByText(/New Patient/i) expect(lookupButtonElement).toBeInTheDocument() expect(newPatientButtonElement).toBeInTheDocument() }) diff --git a/pwa/src/components/LandingPage.tsx b/pwa/src/components/LandingPage.tsx index 7d51c09..d4e11e2 100644 --- a/pwa/src/components/LandingPage.tsx +++ b/pwa/src/components/LandingPage.tsx @@ -1,16 +1,14 @@ import React, { ReactElement } from 'react' import { WhiteSpace, Button } from 'antd-mobile' import { UserAddOutlined } from '@ant-design/icons' +import { useHistory } from 'react-router-dom' import '../scss/LandingPage.scss' export default function LandingPage(): ReactElement { - /* istanbul ignore next */ - // TODO: these onClick functions could be mocked in a unit test - const patientLookup = () => { - console.log('Clicked Patient Lookup') - } + let history = useHistory() + // TODO: these onClick functions could be mocked in a unit test /* istanbul ignore next */ const newPatient = () => { console.log('Clicked New Patient') @@ -18,7 +16,7 @@ export default function LandingPage(): ReactElement { return (
- diff --git a/pwa/src/components/PatientInfoForm.test.tsx b/pwa/src/components/PatientInfoForm.test.tsx index 959b613..0311782 100644 --- a/pwa/src/components/PatientInfoForm.test.tsx +++ b/pwa/src/components/PatientInfoForm.test.tsx @@ -1,105 +1,147 @@ -import React from "react"; -import { render, fireEvent, prettyDOM } from "@testing-library/react"; -import PatientInfoForm from "./PatientInfoForm"; +import React from 'react' +import { render, fireEvent, prettyDOM } from '@testing-library/react' +import PatientInfoForm from './PatientInfoForm' +import { StoreProvider } from 'easy-peasy' +import store from '../state/store' -test("female-only div is only displayed if the sex is selected as female", async () => { - const femaleOnlyFormFields = ["Pregnant?", "LMP", "Wants Planning?"]; +test('female-only div is only displayed if the sex is selected as female', async () => { + const femaleOnlyFormFields = ['Pregnant?', 'LMP', 'Wants Planning?'] - const dom = render(); + const dom = render( + + + , + ) - // ensure the female-only div is not displayed - femaleOnlyFormFields.forEach((fieldText) => { - expect(dom.queryByText(fieldText)).toBeNull(); - }); + // ensure the female-only div is not displayed + femaleOnlyFormFields.forEach((fieldText) => { + expect(dom.queryByText(fieldText)).toBeNull() + }) - // click the radio button for 'female' - const femaleButton = dom.getByTestId("female-button"); - fireEvent.click(femaleButton); + // click the radio button for 'female' + const femaleButton = dom.getByTestId('female-button') + fireEvent.click(femaleButton) - femaleOnlyFormFields.forEach((fieldText) => { - expect(dom.queryByText(fieldText)).toBeTruthy(); - }); -}); + femaleOnlyFormFields.forEach((fieldText) => { + expect(dom.queryByText(fieldText)).toBeTruthy() + }) +}) -test("sets visit input", () => { - const dom = render(); - const firstVisitYesInput = dom.queryByTestId("first-visit-Yes"); - const checkBox = firstVisitYesInput.getElementsByTagName("span")[0]; +test('sets visit input', () => { + const dom = render( + + + , + ) + const firstVisitYesInput = dom.queryByTestId('first-visit-Yes') + const checkBox = firstVisitYesInput.getElementsByTagName('span')[0] - expect(checkBox).not.toHaveClass("am-radio-checked"); - fireEvent.click(firstVisitYesInput); - expect(checkBox).toHaveClass("am-radio-checked"); -}); + expect(checkBox).not.toHaveClass('am-radio-checked') + fireEvent.click(firstVisitYesInput) + expect(checkBox).toHaveClass('am-radio-checked') +}) -test("sets pregnant input", () => { - const dom = render(); - const femaleButton = dom.getByTestId("female-button"); - fireEvent.click(femaleButton); +test('sets pregnant input', () => { + const dom = render( + + + , + ) + const femaleButton = dom.getByTestId('female-button') + fireEvent.click(femaleButton) - const pregnantYesInput = dom.queryByTestId("pregnant-Yes"); - const checkBox = pregnantYesInput.getElementsByTagName("span")[0]; + const pregnantYesInput = dom.queryByTestId('pregnant-Yes') + const checkBox = pregnantYesInput.getElementsByTagName('span')[0] - expect(checkBox).not.toHaveClass("am-radio-checked"); - fireEvent.click(pregnantYesInput); - expect(checkBox).toHaveClass("am-radio-checked"); -}); + expect(checkBox).not.toHaveClass('am-radio-checked') + fireEvent.click(pregnantYesInput) + expect(checkBox).toHaveClass('am-radio-checked') +}) -test("sets planning input", () => { - const dom = render(); - const femaleButton = dom.getByTestId("female-button"); - fireEvent.click(femaleButton); +test('sets planning input', () => { + const dom = render( + + + , + ) + const femaleButton = dom.getByTestId('female-button') + fireEvent.click(femaleButton) - const planningYesInput = dom.queryByTestId("planning-Yes"); - const checkBox = planningYesInput.getElementsByTagName("span")[0]; + const planningYesInput = dom.queryByTestId('planning-Yes') + const checkBox = planningYesInput.getElementsByTagName('span')[0] - expect(checkBox).not.toHaveClass("am-radio-checked"); - fireEvent.click(planningYesInput); - expect(checkBox).toHaveClass("am-radio-checked"); -}); + expect(checkBox).not.toHaveClass('am-radio-checked') + fireEvent.click(planningYesInput) + expect(checkBox).toHaveClass('am-radio-checked') +}) -test("sets albendazole input", () => { - const dom = render(); - const femaleButton = dom.getByTestId("female-button"); +test('validates blood pressure input', () => { + const dom = render( + + + , + ) - const albendazoleYesInput = dom.queryByTestId("albendazole-Yes"); - const checkBox = albendazoleYesInput.getElementsByTagName("span")[0]; + const bpInput = dom.getByPlaceholderText( + 'Blood Pressure', + ) as HTMLInputElement + fireEvent.change(bpInput, { target: { value: '130/85' } }) + expect(bpInput.value).toBe('130/85') + fireEvent.change(bpInput, { target: { value: '' } }) + fireEvent.change(bpInput, { target: { value: 'abcd' } }) + expect(bpInput.value).toBe('') // Empty String +}) - expect(checkBox).not.toHaveClass("am-radio-checked"); - fireEvent.click(albendazoleYesInput); - expect(checkBox).toHaveClass("am-radio-checked"); -}); +test('sets albendazole input', () => { + const dom = render( + + + , + ) -test("renders the Patient Info Form fields", () => { - const formFields = [ - "First Name", - "First Name", - "Last Name", - "Last Name", - "Sex", - "Birth", - "Age", - "Phone", - "Town", - "Visit", - "Nickname", - "BP", - "Temp", - "Pulse", - "Weight", - "Height", - "Z-Score", - "Albendazole", - "Allergies", - "Medicines", - "Chief Complaint", - "History", - "Exam", - ]; + const albendazoleYesInput = dom.queryByTestId('albendazole-Yes') + const checkBox = albendazoleYesInput.getElementsByTagName('span')[0] - const { getByText } = render(); + expect(checkBox).not.toHaveClass('am-radio-checked') + fireEvent.click(albendazoleYesInput) + expect(checkBox).toHaveClass('am-radio-checked') +}) - formFields.forEach((fieldText) => { - let re = new RegExp(fieldText, "gi"); - expect(getByText(re)).toBeInTheDocument(); - }); -}); +test('renders the Patient Info Form fields', () => { + const formFields = [ + 'First Name', + 'First Name', + 'Last Name', + 'Last Name', + 'Sex', + 'Birth', + 'Age', + 'Phone', + 'Town', + 'Visit', + 'Nickname', + 'BP', + 'Temp', + 'Pulse', + 'Weight', + 'Height', + 'Z-Score', + 'Albendazole', + 'Allergies', + 'Medicines', + 'Chief Complaint', + 'History', + 'Exam', + ] + + const { getByText } = render( + + + , + ) + + formFields.forEach((fieldText) => { + const re = new RegExp(fieldText, 'gi') + expect(getByText(re)).toBeInTheDocument() + }) +}) diff --git a/pwa/src/components/PatientInfoForm.tsx b/pwa/src/components/PatientInfoForm.tsx index dd1dfb3..9530d4b 100644 --- a/pwa/src/components/PatientInfoForm.tsx +++ b/pwa/src/components/PatientInfoForm.tsx @@ -1,282 +1,388 @@ -import React, { ReactElement, useState } from "react"; +import React, { ReactElement, useState } from 'react' +import { useStoreActions } from '../state/storeIndex' +import { SEX } from '../state/enums' import { - InputItem, - List, - TextareaItem, - Button, - Radio, - WhiteSpace, -} from "antd-mobile"; -import "../scss/PatientInfoForm.scss"; + InputItem, + List, + TextareaItem, + Button, + Radio, + WhiteSpace, +} from 'antd-mobile' +import '../scss/PatientInfoForm.scss' -const RadioItem = Radio.RadioItem; +const RadioItem = Radio.RadioItem export default function Form(): ReactElement { - // State Hooks for Radio Buttons - // useState returns a value, and a function to set that value - const [sex, setSex] = useState(null as Number); // Default: null - const [albendazole, setAlbendazole] = useState(0); // Default: No - const [firstVisit, setFirstVisit] = useState(0); // Default: No - const [pregnant, setPregnant] = useState(0); // Default: No - const [planning, setPlanning] = useState(0); // Default: No - const [bloodPressureValue, setBloodPressureValue] = useState(""); + // State Hooks for Radio Buttons + // useState returns a value, and a function to set that value + const [sex, setSex] = useState(null as number) // Default: null + const [albendazole, setAlbendazole] = useState(0) // Default: No + const [firstVisit, setFirstVisit] = useState(0) // Default: No + const [pregnant, setPregnant] = useState(0) // Default: No + const [planning, setPlanning] = useState(0) // Default: No + const [bloodPressureValue, setBloodPressureValue] = useState('') + const updateCentralState = useStoreActions( + (actions) => actions.patientInfoFormModel.updateFields, + ) - // Data for Radio Buttons - const sexValues = [ - { value: 0, label: "Male" }, - { value: 1, label: "Female" }, - ]; + // Data for Radio Buttons + const sexValues = [ + { value: 0, label: 'Male' }, + { value: 1, label: 'Female' }, + ] - const radioYesNo = [ - { value: 0, label: "No" }, - { value: 1, label: "Yes" }, - ]; + const radioYesNo = [ + { value: 0, label: 'No' }, + { value: 1, label: 'Yes' }, + ] - /* istanbul ignore next */ - const onSubmit = () => { - if (process.env.NODE_ENV === "development") { - // This shows how to access the values of the form - const firstNameField: HTMLInputElement = document.getElementById( - "firstname" - ) as HTMLInputElement; - const lastNameField: HTMLInputElement = document.getElementById( - "lastname" - ) as HTMLInputElement; - const nicknameField: HTMLInputElement = document.getElementById( - "nickname" - ) as HTMLInputElement; - const birthdateField: HTMLInputElement = document.getElementById( - "birthdate" - ) as HTMLInputElement; - const complaintField1: HTMLInputElement = document.getElementById( - "complaint1" - ) as HTMLInputElement; - const complaintField2: HTMLInputElement = document.getElementById( - "complaint2" - ) as HTMLInputElement; - const complaintField3: HTMLInputElement = document.getElementById( - "complaint3" - ) as HTMLInputElement; - - console.log("First name:", firstNameField.value); - console.log("Last name:", lastNameField.value); - console.log("Nickname:", nicknameField.value); - console.log("Birth Date:", birthdateField.value); - console.log( - "Complaints: ", - complaintField1.value + - " " + - complaintField2.value + - " " + - complaintField3.value - ); - console.log("Wants Planning?: " + planning); + /* istanbul ignore next */ + const onSubmit = () => { + const firstNameField: HTMLInputElement = document.getElementById( + 'firstname', + ) as HTMLInputElement + const lastNameField: HTMLInputElement = document.getElementById( + 'lastname', + ) as HTMLInputElement + const nicknameField: HTMLInputElement = document.getElementById( + 'nickname', + ) as HTMLInputElement + const birthdateField: HTMLInputElement = document.getElementById( + 'birthdate', + ) as HTMLInputElement + const age: HTMLInputElement = document.getElementById( + 'age', + ) as HTMLInputElement + const phone: HTMLInputElement = document.getElementById( + 'phone', + ) as HTMLInputElement + const town: HTMLInputElement = document.getElementById( + 'town', + ) as HTMLInputElement + const gravida: HTMLInputElement = document.getElementById( + 'gravida', + ) as HTMLInputElement + const para: HTMLInputElement = document.getElementById( + 'para', + ) as HTMLInputElement + const abortus: HTMLInputElement = document.getElementById( + 'abortus', + ) as HTMLInputElement + const lmp: HTMLInputElement = document.getElementById( + 'lmp', + ) as HTMLInputElement + const weight: HTMLInputElement = document.getElementById( + 'weight', + ) as HTMLInputElement + const height: HTMLInputElement = document.getElementById( + 'height', + ) as HTMLInputElement + const temp: HTMLInputElement = document.getElementById( + 'temp', + ) as HTMLInputElement + const pulse: HTMLInputElement = document.getElementById( + 'pulse', + ) as HTMLInputElement + const zScore: HTMLInputElement = document.getElementById( + 'z-score', + ) as HTMLInputElement + const allergies: HTMLInputElement = document.getElementById( + 'allergies', + ) as HTMLInputElement + const medicines: HTMLInputElement = document.getElementById( + 'medicines', + ) as HTMLInputElement + const complaintField1: HTMLInputElement = document.getElementById( + 'complaint1', + ) as HTMLInputElement + const complaintField2: HTMLInputElement = document.getElementById( + 'complaint2', + ) as HTMLInputElement + const complaintField3: HTMLInputElement = document.getElementById( + 'complaint3', + ) as HTMLInputElement + const history: HTMLInputElement = document.getElementById( + 'history', + ) as HTMLInputElement + const exam: HTMLInputElement = document.getElementById( + 'exam', + ) as HTMLInputElement + updateCentralState({ + firstName: firstNameField.value, + lastName: lastNameField.value, + nickname: nicknameField.value, + sex: sex ? SEX.female : SEX.male, + birthDate: new Date(birthdateField.value), + age: parseInt(age.value), + phone: phone.value, + town: town.value, + firstVisit: firstVisit, + pregnant: pregnant, + G: sex ? parseInt(gravida.value) : null, + P: sex ? parseInt(para.value) : null, + A: sex ? parseInt(abortus.value) : null, + LMP: sex ? new Date(lmp.value) : null, + wantsPlanning: planning, + weight: parseFloat(weight.value), + height: parseFloat(height.value), + bloodPressure: bloodPressureValue, + temperature: parseFloat(temp.value), + pulse: parseFloat(pulse.value), + zScore: parseFloat(zScore.value), + albendazole: albendazole, + allergies: allergies.value, + medicines: medicines.value, + chiefComplaint: [ + complaintField1.value, + complaintField2.value, + complaintField3.value, + ], + history: history.value, + exam: exam.value, + }) } - }; - const validateAndSetBloodPressure = (value: string) => { - const newChar = value.slice(-1); - if (newChar.match(/[0-9]|\/|^$/) && value.length < 8) { - setBloodPressureValue(value); + const validateAndSetBloodPressure = (value: string) => { + const newChar = value.slice(-1) + if (newChar.match(/[0-9]|\/|^$/) && value.length < 8) { + setBloodPressureValue(value) + } } - }; - return ( -
- + return ( +
+ - + - + - "Sex"}> - {sexValues.map((i) => ( - setSex(i.value)} - > - {i.label} - - ))} - + 'Sex'}> + {sexValues.map((i) => ( + setSex(i.value)} + > + {i.label} + + ))} + - + - - Birth Date - + + Birth Date + - - Age{" "} - + + Age{' '} + - - Phone - + + Phone + - + - "First Visit?"}> - {radioYesNo.map((i) => ( - setFirstVisit(i.value)} - > - {i.label} - - ))} - + 'First Visit?'}> + {radioYesNo.map((i) => ( + setFirstVisit(i.value)} + > + {i.label} + + ))} + - {/* Only display the 'female-only' div if the patient is female */} - {sex ? ( -
- - "Pregnant?"}> - {radioYesNo.map((i) => ( - setPregnant(i.value)} - > - {i.label} - - ))} - + {/* Only display the 'female-only' div if the patient is female */} + {sex ? ( +
+ + 'Pregnant?'}> + {radioYesNo.map((i) => ( + setPregnant(i.value)} + > + {i.label} + + ))} + - - G - + + G + - - P - + + P + - - A - + + A + - - LMP - + + LMP + - "Wants Planning?"}> - {radioYesNo.map((i) => ( - setPlanning(i.value)} - > - {i.label} - - ))} - + 'Wants Planning?'}> + {radioYesNo.map((i) => ( + setPlanning(i.value)} + > + {i.label} + + ))} + +
+ ) : null} + + + + + Weight (kg) + + + + Height (cm) + + + + BP + + + + Temp (F) + + + + Pulse + + + + Z-Score + + + 'Albendazole?'}> + {radioYesNo.map((i) => ( + setAlbendazole(i.value)} + > + {i.label} + + ))} + + + 'Allergies'}> + + + + 'Medicines'}> + + + + 'Chief Complaint'}> + + + + + + 'History'}> + + + + 'Exam'}> + + + + + +
+ + + +
- ) : null} - - - - - Weight (kg) - - - - Height (cm) - - - - BP - - - - Temp (F) - - - - Pulse - - - - Z-Score - - - "Albendazole?"}> - {radioYesNo.map((i) => ( - setAlbendazole(i.value)} - > - {i.label} - - ))} - - - "Allergies"}> - - - - "Medicines"}> - - - - "Chief Complaint"}> - - - - - - "History"}> - - - - "Exam"}> - - - - - -
- - - -
-
- ); + ) } diff --git a/pwa/src/components/PatientSearch.test.tsx b/pwa/src/components/PatientSearch.test.tsx new file mode 100644 index 0000000..ebb5d75 --- /dev/null +++ b/pwa/src/components/PatientSearch.test.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import PatientSearch from './PatientSearch' + +// TODO: mock out patient data + +let searchComponent: any +let searchBar: HTMLInputElement + +beforeEach(() => { + searchComponent = render() + searchBar = searchComponent.getByTestId('search-bar') as HTMLInputElement +}) + +afterEach(() => { + searchComponent = null + searchBar = null +}) + +describe('search component initalization', () => { + test('it should have a search bar', () => { + expect(searchBar).toBeInTheDocument() + }) + + test('it should allow the search bar to be focused', () => { + expect(searchBar).not.toHaveFocus() + searchBar.focus() + expect(searchBar).toHaveFocus() + }) +}) + +describe('search bar functionality', () => { + test('it should not display a list when no search has been performed', () => { + expect(searchComponent.queryByTestId('match-list')).toBeFalsy() + }) + + test('it should not display a list when search bar is empty', () => { + expect(searchComponent.queryByTestId('match-list')).toBeFalsy() + fireEvent.change(searchBar, { target: { value: 'alex' } }) + expect(searchBar.value).toBe('alex') + const entryOne = searchComponent.queryByTestId('matched-patient-0') + expect(entryOne).toBeInTheDocument() + }) + + test('it should show one element for an exact match', () => { + expect(searchComponent.queryByTestId('match-list')).toBeFalsy() + fireEvent.change(searchBar, { target: { value: 'alex powers' } }) + expect(searchBar.value).toBe('alex powers') + const entryOne = searchComponent.queryByTestId('matched-patient-0') + expect(entryOne).toBeInTheDocument() + expect(searchComponent.queryByTestId('matched-patient-1')).toBeFalsy() + fireEvent.change(searchBar, { target: { value: '' } }) + expect(searchBar.value).toBe('') + expect(entryOne).not.toBeInTheDocument() + }) + + test('it should show add new patient for a no match', () => { + fireEvent.change(searchBar, { target: { value: 'not a real name' } }) + expect(searchBar.value).toBe('not a real name') + const entryOne = searchComponent.queryByTestId('no-matched-patient') + expect(entryOne).toBeInTheDocument() + + fireEvent.change(searchBar, { target: { value: '' } }) + expect(searchBar.value).toBe('') + expect(entryOne).not.toBeInTheDocument() + }) + + test('it should have a list and defocus the search bar on enter', () => { + expect(true).toBe(true) + }) +}) + +// TODO: make these methods more robust +// by evaluating the change of state once the navigation and new patient functions are implemented +describe('list element clicking', () => { + test('clicking an existing element should call navigateToPatientPage', () => { + // TODO: check something more robust than console log + const spy = jest.spyOn(console, 'log') + fireEvent.change(searchBar, { target: { value: 'Alex Powers' } }) + expect(searchBar.value).toBe('Alex Powers') + const entryOne = searchComponent.queryByTestId('matched-patient-0') + expect(entryOne).toBeInTheDocument() + + fireEvent.click(entryOne) + expect(spy).toHaveBeenCalled() + }) + + test('clicking an existing element should call navigateToPatientPage', () => { + // TODO: check something more robust than console log + const spy = jest.spyOn(console, 'log') + + fireEvent.change(searchBar, { target: { value: 'not a real name' } }) + expect(searchBar.value).toBe('not a real name') + const entryOne = searchComponent.queryByTestId('no-matched-patient') + expect(entryOne).toBeInTheDocument() + + fireEvent.click(entryOne) + expect(spy).toHaveBeenCalled() + }) +}) diff --git a/pwa/src/components/PatientSearch.tsx b/pwa/src/components/PatientSearch.tsx new file mode 100644 index 0000000..ae61866 --- /dev/null +++ b/pwa/src/components/PatientSearch.tsx @@ -0,0 +1,80 @@ +import React, { ReactElement, useState } from 'react' + +import { SearchBar, WhiteSpace, List } from 'antd-mobile' + +import '../scss/PatientSearch.scss' +import Patient from '../state/models/patient.model' + +const patientsSeed: Array = [ + { pk_patient_id: 1, first_name: 'Alex', last_name: 'Powers' }, + { pk_patient_id: 2, first_name: 'Meghan', last_name: 'McLaughlin' }, + { pk_patient_id: 3, first_name: 'Ben', last_name: 'Mitchinson' }, + { pk_patient_id: 4, first_name: 'Zain', last_name: 'Khan' }, + { pk_patient_id: 5, first_name: 'Hans', last_name: 'Johnson' }, +] + +export default function PatientSearch(): ReactElement { + const [matches, setMatches] = useState([]) + const [patients] = useState(patientsSeed) + const [searchValue, setSearchValue] = useState('') + + // TODO: replace this logic + // this could potentially use Fuse.js or fuzzy database queries + const searchForName = (value: string): void => { + setSearchValue(value) + if (value) { + const listOfMatches = patients.filter((element: Patient) => { + return `${element.first_name} ${element.last_name}` + .toLowerCase() + .includes(value.toLowerCase()) + }) + setMatches(listOfMatches) + } else { + setMatches([]) + } + } + + const addNewPatient = (): void => { + console.log('NO PATIENT FOUND, ADDING A NEW ONE') + } + + const navigateToPatientPage = (patient: Patient): void => { + console.log(`Navigating to patient ${JSON.stringify(patient)}`) + } + + return ( + <> + searchForName(value)} + /> + + {searchValue ? ( + <> + + {matches.map((matchElement: Patient, index: number) => ( + + navigateToPatientPage(matchElement) + } + >{`${matchElement.first_name} ${matchElement.last_name}`} + ))} + {matches.length === 0 && ( + addNewPatient()} + > + Add New Patient + + )} + + + ) : null} + + ) +} diff --git a/pwa/src/scss/DiagnosisForm.scss b/pwa/src/scss/DiagnosisForm.scss new file mode 100644 index 0000000..3ee4ed2 --- /dev/null +++ b/pwa/src/scss/DiagnosisForm.scss @@ -0,0 +1,5 @@ +#diagnosis-form-wrapper { + .list-whitespace { + background-color: #f5f5f9; + } +} diff --git a/pwa/src/scss/PatientSearch.scss b/pwa/src/scss/PatientSearch.scss new file mode 100644 index 0000000..417a2b9 --- /dev/null +++ b/pwa/src/scss/PatientSearch.scss @@ -0,0 +1,3 @@ +.am-search-clear-show { + display: none !important; +} diff --git a/pwa/src/state/enums.ts b/pwa/src/state/enums.ts new file mode 100644 index 0000000..f26a865 --- /dev/null +++ b/pwa/src/state/enums.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ + +export enum SEX { + male = 'male', + female = 'female', +} + +export enum YN { + YES = 1, + NO = 0, +} + +export default {} diff --git a/pwa/src/state/models/patient.model.ts b/pwa/src/state/models/patient.model.ts new file mode 100644 index 0000000..32b4801 --- /dev/null +++ b/pwa/src/state/models/patient.model.ts @@ -0,0 +1,6 @@ +interface Patient { + pk_patient_id?: number + first_name?: string + last_name?: string +} +export default Patient diff --git a/pwa/src/state/models/patientInfoForm.model.ts b/pwa/src/state/models/patientInfoForm.model.ts new file mode 100644 index 0000000..e9aefd7 --- /dev/null +++ b/pwa/src/state/models/patientInfoForm.model.ts @@ -0,0 +1,48 @@ +import { Action, action } from 'easy-peasy' +import { SEX, YN } from '../enums' + +interface PatientInfoFormFields { + firstName?: string + lastName?: string + nickname?: string + sex?: SEX + birthDate?: Date + age?: number + phone?: string + town?: string + firstVisit?: YN + pregnant?: YN + G?: number + P?: number + A?: number + LMP?: Date + wantsPlanning?: YN + weight?: number + height?: number + bloodPressure?: string + temperature?: number + pulse?: number + zScore?: number + albendazole?: YN + allergies?: string + medicines?: string + chiefComplaint?: string[] + history?: string + exam?: string +} + +export interface PatientInfoFormModel { + fields: PatientInfoFormFields + updateFields: Action +} + +/* istanbul ignore next */ +// Form submission requests validated in integration test +const patientInfoFormModel: PatientInfoFormModel = { + fields: {}, + updateFields: action((state, formObject) => { + state.fields = formObject + }), +} + +export default patientInfoFormModel diff --git a/pwa/src/state/store.ts b/pwa/src/state/store.ts new file mode 100644 index 0000000..7142739 --- /dev/null +++ b/pwa/src/state/store.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ + +import { createStore, persist } from 'easy-peasy' +import storeIndex from './storeIndex' + +const store = createStore(persist(storeIndex)) + +export default store diff --git a/pwa/src/state/storeIndex.ts b/pwa/src/state/storeIndex.ts new file mode 100644 index 0000000..b77da81 --- /dev/null +++ b/pwa/src/state/storeIndex.ts @@ -0,0 +1,17 @@ +/* istanbul ignore file */ +import { createTypedHooks } from 'easy-peasy' + +import patientInfoFormModel from './models/patientInfoForm.model' + +export const storeModels = { + patientInfoFormModel, +} + +export const { + useStore, + useStoreState, + useStoreActions, + useStoreDispatch, +} = createTypedHooks() + +export default storeModels