├── .husky ├── .gitignore └── pre-commit ├── src ├── apps │ ├── concepts │ │ ├── pages │ │ │ ├── tests │ │ │ │ └── e2e │ │ │ │ │ ├── testUtils.ts │ │ │ │ │ ├── ViewConceptsPage.test.ts │ │ │ │ │ └── CreateOrEditConceptPage.test.ts │ │ │ └── index.ts │ │ ├── redux │ │ │ ├── index.ts │ │ │ ├── constants.ts │ │ │ ├── actionTypes.ts │ │ │ └── selectors.ts │ │ ├── index.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ └── AlreadyAddedIcon.tsx │ │ ├── constants.ts │ │ ├── __test__ │ │ │ ├── components │ │ │ │ ├── EnhancedTableToolbar.test.tsx │ │ │ │ ├── EnhancedTableHead.test.tsx │ │ │ │ ├── ConceptForm.test.tsx │ │ │ │ └── ConceptsActionMenu.test.tsx │ │ │ ├── utils.test.ts │ │ │ └── redux │ │ │ │ └── reducer.test.ts │ │ ├── utils.ts │ │ └── Routes.tsx │ ├── notifications │ │ ├── index.ts │ │ ├── types.ts │ │ ├── __test__ │ │ │ └── components │ │ │ │ ├── EnhancedNotificationSummaryTableHead.test.tsx │ │ │ │ └── NotificationCard.test.tsx │ │ └── components │ │ │ └── EnhancedNotificationSummaryTableHead.tsx │ ├── authentication │ │ ├── pages │ │ │ ├── index.ts │ │ │ └── ViewUserProfilePage.tsx │ │ ├── redux │ │ │ ├── index.ts │ │ │ ├── actionTypes.ts │ │ │ ├── reducer.ts │ │ │ └── actions.ts │ │ ├── components │ │ │ ├── index.tsx │ │ │ ├── UserTokenDetails.tsx │ │ │ ├── UserOrganisationDetails.tsx │ │ │ └── AuthenticationRequired.tsx │ │ ├── index.ts │ │ ├── api.ts │ │ ├── types.ts │ │ ├── __test__ │ │ │ ├── components │ │ │ │ ├── UserTokenDetails.test.tsx │ │ │ │ ├── UserOrganisationDetails.test.tsx │ │ │ │ └── UserForm.test.tsx │ │ │ ├── redux │ │ │ │ └── reducer.test.ts │ │ │ ├── utils.test.ts │ │ │ └── test_data.ts │ │ ├── utils.ts │ │ └── tests │ │ │ └── api.test.ts │ ├── containers │ │ ├── types.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── EditButton.tsx │ │ │ ├── __test__ │ │ │ │ ├── EditMenu.test.tsx │ │ │ │ ├── EditButton.test.tsx │ │ │ │ └── ContainerSearch.test.tsx │ │ │ ├── EditMenu.tsx │ │ │ ├── ContainerOwnerTabs.tsx │ │ │ ├── ContainerPagination.tsx │ │ │ ├── FormUtils.tsx │ │ │ └── ContainerCards.tsx │ │ └── api.ts │ ├── organisations │ │ ├── redux │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── actionTypes.ts │ │ │ └── selectors.ts │ │ ├── constants.ts │ │ ├── utils.ts │ │ ├── index.ts │ │ ├── pages │ │ │ ├── index.ts │ │ │ ├── ViewPublicOrgs.tsx │ │ │ └── ViewPersonalOrgs.tsx │ │ ├── Routes.tsx │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── OrgSources.tsx │ │ │ ├── OrgDictionaries.tsx │ │ │ ├── OrgCards.tsx │ │ │ ├── MenuButton.tsx │ │ │ ├── ViewOrgs.tsx │ │ │ ├── OrgCard.tsx │ │ │ ├── OrgDetails.tsx │ │ │ └── DeleteMemberDialog.tsx │ │ ├── types.ts │ │ ├── __test__ │ │ │ └── components │ │ │ │ └── organisationDetails.test.tsx │ │ └── api.ts │ ├── sources │ │ ├── redux │ │ │ ├── index.ts │ │ │ ├── constants.ts │ │ │ ├── actionTypes.ts │ │ │ └── reducer.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── ViewSources.tsx │ │ │ └── SourceConceptsSummary.tsx │ │ ├── pages │ │ │ ├── index.ts │ │ │ ├── ViewOrgSourcesPage.tsx │ │ │ ├── ViewPublicSourcesPage.tsx │ │ │ └── ViewPersonalSourcesPage.tsx │ │ ├── index.ts │ │ ├── utils.ts │ │ ├── constants.ts │ │ ├── Routes.tsx │ │ ├── __test__ │ │ │ ├── utils.test.ts │ │ │ ├── pages │ │ │ │ ├── ViewOrgSourcesPage.test.tsx │ │ │ │ ├── ViewPublicSourcesPage.test.tsx │ │ │ │ └── ViewPersonalSourcesPage.test.tsx │ │ │ └── api.test.ts │ │ ├── types.ts │ │ └── api.ts │ └── dictionaries │ │ ├── redux │ │ ├── index.ts │ │ ├── constants.ts │ │ └── actionTypes.ts │ │ ├── components │ │ ├── index.ts │ │ ├── ViewDictionaries.tsx │ │ └── DictionaryConceptsSummary.tsx │ │ ├── __test__ │ │ └── utils.test.ts │ │ ├── constants.ts │ │ ├── pages │ │ ├── index.ts │ │ ├── tests │ │ │ └── e2e │ │ │ │ ├── CreateDictionaryPage.test.ts │ │ │ │ ├── ViewDictionaryPage.test.ts │ │ │ │ └── EditDictionaryPage.test.ts │ │ ├── ViewOrgDictionariesPage.tsx │ │ ├── ViewPublicDictionariesPage.tsx │ │ └── ViewPersonalDictionariesPage.tsx │ │ ├── index.ts │ │ ├── Routes.tsx │ │ └── utils.ts ├── react-app-env.d.ts ├── fonts │ ├── XRXV3I6Li01BKofINeaBTMnFcQ.woff2 │ ├── XRXV3I6Li01BKofIO-aBTMnFcQIG.woff2 │ ├── XRXV3I6Li01BKofIOuaBTMnFcQIG.woff2 │ └── nunito.css ├── utils │ ├── index.ts │ ├── components │ │ ├── index.ts │ │ ├── NestedErrorMessage.tsx │ │ ├── ConfirmationDialog.tsx │ │ └── ToastAlert.tsx │ ├── urlUtils.ts │ ├── tests │ │ └── unit │ │ │ └── utils.test.ts │ ├── types.ts │ └── hooks.ts ├── components │ ├── drag_handle-icon.svg │ ├── index.ts │ ├── VerifiedSource.tsx │ ├── DragHandle.tsx │ └── omrs-logo.svg ├── tests │ └── unit │ │ └── App.test.tsx ├── redux │ ├── index.ts │ ├── __test__ │ │ ├── reducer.test.ts │ │ └── utils.test.ts │ ├── types.ts │ └── reducer.ts ├── index.tsx ├── test-utils.test.tsx ├── api.ts └── index.scss ├── .coveralls.yml ├── public ├── robots.txt ├── favicon.ico ├── manifest.json └── index.html ├── cypress ├── .eslintrc.json ├── fixtures │ └── example.json ├── tsconfig.json ├── integration │ ├── organisation │ │ ├── deleteMember.feature │ │ ├── addMember.feature │ │ ├── edit.feature │ │ ├── create.feature │ │ └── details.feature │ ├── dictionary │ │ ├── copyDictionary.feature │ │ ├── edit.feature │ │ ├── createVersion.feature │ │ ├── create.feature │ │ ├── addBulkConceptsToDictionary.feature │ │ └── addConceptsToDictionary.feature │ └── concept │ │ ├── create.feature │ │ └── edit.feature ├── support │ ├── step_definitions │ │ ├── organisation │ │ │ ├── deleteMember │ │ │ │ └── deleteMember.ts │ │ │ ├── addMember │ │ │ │ └── addMember.ts │ │ │ ├── edit │ │ │ │ └── edit.ts │ │ │ └── details │ │ │ │ └── details.ts │ │ ├── concept │ │ │ ├── edit │ │ │ │ └── edit.ts │ │ │ └── create │ │ │ │ └── create.ts │ │ └── dictionary │ │ │ ├── copyDictionary │ │ │ └── copyDictionary.ts │ │ │ └── edit │ │ │ └── edit.ts │ ├── index.ts │ └── utils.ts └── plugins │ └── index.ts ├── .github ├── pull_request_template.md └── workflows │ ├── basic-dictionary.yml │ ├── organisation-management.yml │ └── dictionary-manager.yml ├── static.json ├── stop_local_instance.sh ├── run_e2e.sh ├── app.json ├── cypress.json ├── docker ├── default.conf ├── startup.sh └── Dockerfile ├── .dockerignore ├── docker-compose.yml ├── start_local_instance.sh ├── tsconfig.json ├── .gitignore ├── wait_for_url.sh └── .prettierignore /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /src/apps/concepts/pages/tests/e2e/testUtils.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/concepts/pages/tests/e2e/ViewConceptsPage.test.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: keCRFyNYCisG86YEXIaUM35uBSbuybOiB 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | -------------------------------------------------------------------------------- /src/apps/concepts/pages/tests/e2e/CreateOrEditConceptPage.test.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /cypress/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:cypress/recommended" 4 | ] 5 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmrs/openmrs-ocl-client/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/apps/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export { default as InProgressPage } from "./pages/ActionsInProgressPage"; 2 | -------------------------------------------------------------------------------- /src/apps/authentication/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ViewUserProfilePage } from "./ViewUserProfilePage"; 2 | -------------------------------------------------------------------------------- /src/apps/containers/types.ts: -------------------------------------------------------------------------------- 1 | export interface TabType { 2 | labelName: string; 3 | labelURL: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/apps/concepts/redux/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./selectors"; 2 | export * from "./actions"; 3 | export { default } from "./reducer"; 4 | -------------------------------------------------------------------------------- /src/apps/organisations/redux/constants.ts: -------------------------------------------------------------------------------- 1 | export const PUBLIC_ORGS_ACTION_INDEX = 0; 2 | export const PERSONAL_ORGS_ACTION_INDEX = 1; 3 | -------------------------------------------------------------------------------- /src/apps/sources/redux/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./reducer"; 2 | export * from "./selectors"; 3 | export * from "./actions"; 4 | -------------------------------------------------------------------------------- /src/apps/dictionaries/redux/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./reducer"; 2 | export * from "./selectors"; 3 | export * from "./actions"; 4 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # JIRA TICKET NAME: 2 | [JIRA ticket name](https://issues.openmrs.org/browse/) 3 | 4 | # Summary: 5 | -------------------------------------------------------------------------------- /src/fonts/XRXV3I6Li01BKofINeaBTMnFcQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmrs/openmrs-ocl-client/HEAD/src/fonts/XRXV3I6Li01BKofINeaBTMnFcQ.woff2 -------------------------------------------------------------------------------- /src/fonts/XRXV3I6Li01BKofIO-aBTMnFcQIG.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmrs/openmrs-ocl-client/HEAD/src/fonts/XRXV3I6Li01BKofIO-aBTMnFcQIG.woff2 -------------------------------------------------------------------------------- /src/fonts/XRXV3I6Li01BKofIOuaBTMnFcQIG.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmrs/openmrs-ocl-client/HEAD/src/fonts/XRXV3I6Li01BKofIOuaBTMnFcQIG.woff2 -------------------------------------------------------------------------------- /src/apps/concepts/redux/constants.ts: -------------------------------------------------------------------------------- 1 | export const ANSWERS_BATCH_INDEX = 0; 2 | export const SETS_BATCH_INDEX = 1; 3 | export const MAPPINGS_BATCH_INDEX = 2; 4 | -------------------------------------------------------------------------------- /src/apps/organisations/redux/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./reducer"; 2 | export * from "./actions"; 3 | export * from "./selectors"; 4 | export * from "./constants"; 5 | -------------------------------------------------------------------------------- /src/apps/sources/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ViewSourcesPage } from "./ViewSourcesPage"; 2 | export { default as SourceConceptDetails } from "./SourceConceptsSummary"; 3 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks"; 2 | export * from "./utils"; 3 | export * from "./components"; 4 | export * from "./constants"; 5 | export * from "./types"; 6 | export * from "./urlUtils"; 7 | -------------------------------------------------------------------------------- /src/apps/sources/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ViewPersonalSourcesPage } from "./ViewPersonalSourcesPage"; 2 | export { default as ViewSourcePage } from "./ViewSourcePage"; 3 | export { default as EditSourcePage } from "./EditSourcePage"; 4 | -------------------------------------------------------------------------------- /src/apps/concepts/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ViewConceptPage } from "./ViewConceptPage"; 2 | export { default as ViewConceptsPage } from "./ViewConceptsPage"; 3 | export { default as CreateOrEditConceptPage } from "./CreateOrEditConceptPage"; 4 | -------------------------------------------------------------------------------- /src/apps/dictionaries/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DictionaryForm } from "./DictionaryForm"; 2 | export { default as DictionaryDetails } from "./DictionaryConceptsSummary"; 3 | export { default as ViewDictionariesPage } from "./ViewDictionariesPage"; 4 | -------------------------------------------------------------------------------- /src/components/drag_handle-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "build/", 3 | "routes": { 4 | "/images/*": "/images/", 5 | "/static/media/*": "/static/media/", 6 | "/static/css/*": "/static/css/", 7 | "/static/js/*": "/static/js/", 8 | "/**": "index.html" 9 | }, 10 | "clean_urls": true 11 | } 12 | -------------------------------------------------------------------------------- /src/apps/notifications/types.ts: -------------------------------------------------------------------------------- 1 | export interface NotificationItemRow { 2 | expression: string; 3 | added: boolean; 4 | message: string | string[]; 5 | } 6 | 7 | export interface NotificationItem { 8 | meta?: any[]; 9 | result: NotificationItemRow[]; 10 | progress: string; 11 | } 12 | -------------------------------------------------------------------------------- /stop_local_instance.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Stopping App..." 4 | docker-compose down -v --remove-orphans || exit 1 5 | 6 | if [ -d oclapi ]; then 7 | echo "Stopping API..." 8 | pushd oclapi >/dev/null 9 | docker-compose down -v --remove-orphans || exit 1 10 | popd >/dev/null 11 | fi 12 | -------------------------------------------------------------------------------- /src/apps/organisations/constants.ts: -------------------------------------------------------------------------------- 1 | import { TabType } from "../containers/types"; 2 | 3 | export const TAB_LIST: TabType[] = [ 4 | { 5 | labelName: "My Organisations", 6 | labelURL: "/user/orgs/", 7 | }, 8 | { 9 | labelName: "Public Organisations", 10 | labelURL: "/orgs/" 11 | } 12 | ]; 13 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The litmus test for whether a component belongs here or in utils/components is 3 | * Can there be more than one instance of this component on a page without it being weird? 4 | */ 5 | export { default as Header } from "./Header"; 6 | export { default as NavDrawer } from "./NavDrawer"; 7 | -------------------------------------------------------------------------------- /src/tests/unit/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "../../App"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/apps/concepts/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Routes"; 2 | export { default as ViewConceptsPage } from "./pages/ViewConceptsPage"; 3 | export * from "./types"; 4 | export { default as conceptsReducer } from "./redux"; 5 | export { 6 | DICTIONARY_CONTAINER, 7 | DICTIONARY_VERSION_CONTAINER 8 | } from "./constants"; 9 | -------------------------------------------------------------------------------- /src/apps/sources/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Routes"; 2 | export { 3 | default as sourcesReducer, 4 | createSourceAction, 5 | createSourceErrorSelector, 6 | editSourceAction, 7 | editSourceErrorSelector, 8 | retrievePersonalSourcesLoadingSelector 9 | } from "./redux"; 10 | export * from "./types"; 11 | -------------------------------------------------------------------------------- /src/apps/authentication/redux/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | loginAction, 3 | getUserDetailsAction, 4 | getProfileAction, 5 | setNextPageAction, 6 | clearNextPageAction 7 | } from "./actions"; 8 | export { getUserDetailsLoadingSelector, profileSelector } from "./reducer"; 9 | export { LOGOUT_ACTION } from "./actionTypes"; 10 | -------------------------------------------------------------------------------- /src/redux/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createActionThunk, 3 | createActionType, 4 | resetAction, 5 | startAction, 6 | progressAction, 7 | completeAction, 8 | indexedAction, 9 | FAILURE 10 | } from "./utils"; 11 | export * from "./types"; 12 | export * from "./selectors"; 13 | export { default } from "./store"; 14 | -------------------------------------------------------------------------------- /src/apps/organisations/utils.ts: -------------------------------------------------------------------------------- 1 | export const getOrganisationTypeFromPreviousPath = (previousPath: String) => { 2 | switch (previousPath) { 3 | case "/orgs/": 4 | return "Public Organisations"; 5 | case "/user/orgs/": 6 | return "Your Organisations"; 7 | default: 8 | return "Organisations"; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/apps/concepts/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ConceptForm } from "./ConceptForm"; 2 | export { default as ConceptsTable } from "./ConceptsTable"; 3 | export { default as ViewConceptsHeader } from "./ViewConceptsHeader"; 4 | export { default as AddConceptsIcon } from "./AddConceptsIcon"; 5 | export { ConceptSpeedDial } from "./ConceptSpeedDial"; 6 | -------------------------------------------------------------------------------- /src/apps/organisations/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Routes"; 2 | export { 3 | ViewPublicOrganisationsPage, 4 | CreateOrganisationPage, 5 | EditOrganisationPage, 6 | ViewPersonalOrganisationsPage, 7 | ViewOrganisationPage 8 | } from "./pages"; 9 | export * from "./types"; 10 | export { default as organisationsReducer } from "./redux"; 11 | -------------------------------------------------------------------------------- /run_e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to run the e2e tests in a Dockerised environment 4 | docker container inspect odm-cypress 2>/dev/null 1>&2 5 | result=$? 6 | if [[ $result -ne 0 ]]; then 7 | docker rm odm-cypress 2>/dev/null 1>&2 8 | fi 9 | 10 | docker run -it --rm --name odm-cypress --network=host -v "$PWD:/e2e" -w /e2e cypress/included:7.5.0 $* 11 | -------------------------------------------------------------------------------- /src/apps/sources/redux/constants.ts: -------------------------------------------------------------------------------- 1 | const PERSONAL_SOURCES_ACTION_INDEX = 0; 2 | const ORG_SOURCES_ACTION_INDEX = 1; 3 | const PUBLIC_SOURCES_ACTION_INDEX = 2; 4 | const EDIT_BUTTON_TITLE = "Edit this Source"; 5 | 6 | export { 7 | PERSONAL_SOURCES_ACTION_INDEX, 8 | ORG_SOURCES_ACTION_INDEX, 9 | PUBLIC_SOURCES_ACTION_INDEX, 10 | EDIT_BUTTON_TITLE 11 | }; 12 | -------------------------------------------------------------------------------- /src/apps/authentication/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Login } from "./Login"; 2 | export { default as AuthenticationRequired } from "./AuthenticationRequired"; 3 | export { default as UserForm } from "./UserForm"; 4 | export { default as UserTokenDetails } from "./UserTokenDetails"; 5 | export { default as UserOrganisationDetails } from "./UserOrganisationDetails"; 6 | -------------------------------------------------------------------------------- /src/apps/authentication/index.ts: -------------------------------------------------------------------------------- 1 | export { getUserDetailsAction, getProfileAction } from "./redux"; 2 | export { default as LoginPage } from "./LoginPage"; 3 | export { AuthenticationRequired } from "./components"; 4 | export { profileSelector } from "./redux"; 5 | export * from "./types"; 6 | export { canModifyContainer } from "./utils"; 7 | export { LOGOUT_ACTION } from "./redux"; 8 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "OCL", 3 | "name": "OCL Client for OpenMRS", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/apps/dictionaries/redux/constants.ts: -------------------------------------------------------------------------------- 1 | const PUBLIC_DICTIONARIES_ACTION_INDEX = 0; 2 | const PERSONAL_DICTIONARIES_ACTION_INDEX = 1; 3 | const ORG_DICTIONARIES_ACTION_INDEX = 2; 4 | const EDIT_BUTTON_TITLE = "Edit this Dictionary"; 5 | export { 6 | PUBLIC_DICTIONARIES_ACTION_INDEX, 7 | ORG_DICTIONARIES_ACTION_INDEX, 8 | PERSONAL_DICTIONARIES_ACTION_INDEX, 9 | EDIT_BUTTON_TITLE 10 | }; 11 | -------------------------------------------------------------------------------- /src/apps/organisations/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CreateOrganisationPage } from "./CreateOrgPage"; 2 | export { default as EditOrganisationPage } from "./EditOrgPage"; 3 | export { default as ViewOrganisationPage } from "./ViewOrgPage"; 4 | export { default as ViewPersonalOrganisationsPage } from "./ViewPersonalOrgs"; 5 | export { default as ViewPublicOrganisationsPage } from "./ViewPublicOrgs"; 6 | -------------------------------------------------------------------------------- /src/apps/containers/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ContainerCards } from "./ContainerCards"; 2 | export { default as ContainerCard } from "./ContainerCard"; 3 | export { default as ContainerSearch } from "./ContainerSearch"; 4 | export { default as ContainerPagination } from "./ContainerPagination"; 5 | export { default as ContainerOwnerTabs } from "./ContainerOwnerTabs"; 6 | export * from "../types"; 7 | -------------------------------------------------------------------------------- /src/apps/sources/utils.ts: -------------------------------------------------------------------------------- 1 | export const getSourceTypeFromPreviousPath = (previousPath: String) => { 2 | switch (previousPath) { 3 | case "/sources/": 4 | return "Public Sources"; 5 | case "/user/sources/": 6 | return "Your Sources"; 7 | case "/user/orgs/sources/": 8 | return "Your Organisations' Sources"; 9 | default: 10 | return "Sources"; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/components/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * App wide reusable components 3 | */ 4 | export { default as AsyncSelect } from "./AsyncSelect"; 5 | export { default as NestedErrorMessage } from "./NestedErrorMessage"; 6 | export { default as ProgressOverlay } from "./ProgressOverlay"; 7 | export { default as ConfirmationDialog } from "./ConfirmationDialog"; 8 | export { default as ToastAlert } from "./ToastAlert"; 9 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": [], 3 | "buildpacks": [ 4 | { 5 | "url": "https://github.com/mars/create-react-app-buildpack.git" 6 | } 7 | ], 8 | "description": "OpenMRS Dictionary Manager", 9 | "env": {}, 10 | "formation": { 11 | "web": { 12 | "quantity": 1 13 | } 14 | }, 15 | "name": "openmrs-ocl-client", 16 | "scripts": { 17 | }, 18 | "stack": "heroku-18" 19 | } 20 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8080", 3 | "defaultCommandTimeout": 8000, 4 | "requestTimeout": 6000, 5 | "responseTimeout": 90000, 6 | "retries": { 7 | "runMode": 1, 8 | "openMode": 0 9 | }, 10 | "supportFile": "cypress/support/index.ts", 11 | "testFiles": "**/*.{feature,features}", 12 | "viewportWidth": 1000, 13 | "viewportHeight": 1000, 14 | "video": false 15 | } 16 | -------------------------------------------------------------------------------- /src/apps/concepts/components/AlreadyAddedIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { CheckOutlined as CheckedIcon } from "@mui/icons-material"; 4 | import { Tooltip } from "@mui/material"; 5 | 6 | export const AlreadyAddedIcon: React.FC = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/VerifiedSource.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { VerifiedUser } from "@mui/icons-material"; 3 | 4 | export const VerifiedSource: React.FC, 6 | HTMLDivElement 7 | >> = () => { 8 | return ( 9 |
10 | 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/DragHandle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactComponent as DragHandleIcon } from "./drag_handle-icon.svg"; 3 | 4 | export const DragHandle: React.FC, 6 | HTMLDivElement 7 | >> = props => { 8 | return ( 9 |
10 | 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": "../node_modules", 6 | "isolatedModules": false, 7 | "target": "es5", 8 | "lib": ["es5", "dom"], 9 | "types": [ 10 | "cypress", 11 | "@testing-library/cypress", 12 | "node", 13 | "cypress-wait-until" 14 | ] 15 | }, 16 | "include": ["./**/*.ts", "plugins/index.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /docker/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | location / { 5 | root /usr/share/nginx/html; 6 | index index.html index.htm; 7 | try_files $uri $uri/ /index.html; 8 | } 9 | # redirect server error pages to the static page /50x.html 10 | error_page 500 502 503 504 /50x.html; 11 | location = /50x.html { 12 | root /usr/share/nginx/html; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/apps/concepts/constants.ts: -------------------------------------------------------------------------------- 1 | import { PREFERRED_SOURCES } from "../../utils"; 2 | 3 | export const DICTIONARY_CONTAINER = "dictionary"; 4 | export const DICTIONARY_VERSION_CONTAINER = "dictionaryVersion"; 5 | export const SOURCE_VERSION_CONTAINER = "sourceVersion"; 6 | export const SOURCE_CONTAINER = "source"; 7 | 8 | export const FILTER_SOURCE_IDS = Object.keys(PREFERRED_SOURCES); 9 | export const CONCEPT_GENERAL: string[] = ["IncludeRetired"]; 10 | -------------------------------------------------------------------------------- /src/utils/urlUtils.ts: -------------------------------------------------------------------------------- 1 | import qs from "qs"; 2 | 3 | type QueryParams = { [key: string]: string | number } 4 | 5 | export const generateURLWithQueryParams = ( 6 | currentBaseUrl: string, 7 | params: QueryParams, 8 | currentQueryParams?: QueryParams 9 | ) => { 10 | const newParams: QueryParams = { 11 | ...currentQueryParams, 12 | ...params 13 | }; 14 | return `${currentBaseUrl}?${qs.stringify(newParams)}`; 15 | }; 16 | -------------------------------------------------------------------------------- /src/apps/authentication/api.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedInstance, unAuthenticatedInstance } from "../../api"; 2 | 3 | const api = { 4 | login: (username: string, password: string) => 5 | unAuthenticatedInstance.post("/users/login/", { username, password }), 6 | getProfile: () => authenticatedInstance.get("/user/"), 7 | getUserOrgs: (username: string) => 8 | authenticatedInstance.get(`/users/${username}/orgs/?limit=0`) 9 | }; 10 | 11 | export default api; 12 | -------------------------------------------------------------------------------- /src/apps/concepts/redux/actionTypes.ts: -------------------------------------------------------------------------------- 1 | export const UPSERT_CONCEPT_ACTION = "concepts/upsertConcept"; 2 | export const RETRIEVE_CONCEPT_ACTION = "concepts/retrieveConcept"; 3 | export const UPSERT_MAPPING_ACTION = "concepts/upsertMapping"; 4 | export const UPSERT_CONCEPT_AND_MAPPINGS = "concepts/createConceptAndMappings"; 5 | export const RETRIEVE_CONCEPTS_ACTION = "concepts/retrieveConcepts"; 6 | export const RETRIEVE_ACTIVE_CONCEPTS_ACTION = 7 | "concepts/retrieveActiveConcepts"; 8 | -------------------------------------------------------------------------------- /src/apps/dictionaries/__test__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { dictionaryNameFromUrl } from "../utils"; 2 | 3 | describe("dictionaryNameFromUrl", () => { 4 | it("should return undefined if url is empty", () => { 5 | expect(dictionaryNameFromUrl("")).toBeUndefined(); 6 | }); 7 | 8 | it("should return dictionary name based on url", () => { 9 | expect( 10 | dictionaryNameFromUrl("/users/john/collections/testDictonary/") 11 | ).toEqual("testDictonary"); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/apps/organisations/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Switch, useRouteMatch } from "react-router-dom"; 3 | 4 | import { ViewPublicOrganisationsPage } from "./pages"; 5 | 6 | const Routes: React.FC = () => { 7 | let { path } = useRouteMatch(); 8 | 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default Routes; 19 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import "./fonts/nunito.css"; 3 | import "./index.scss"; 4 | import App from "./App"; 5 | import * as serviceWorker from "./serviceWorker"; 6 | 7 | ReactDOM.render(, document.getElementById("root")); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /cypress/integration/organisation/deleteMember.feature: -------------------------------------------------------------------------------- 1 | Feature: Adding an organisation Member 2 | Background: 3 | Given the user is logged in 4 | 5 | 6 | @organisation 7 | @member 8 | Scenario: The user should be able to delete the new member 9 | Given an organization exists 10 | And a new user exists 11 | And a new member is added 12 | And the user is on the organisation detail page 13 | When the user clicks on the delete member button 14 | Then the member should be deleted 15 | -------------------------------------------------------------------------------- /src/apps/dictionaries/constants.ts: -------------------------------------------------------------------------------- 1 | import { TabType } from "../containers/types"; 2 | 3 | export const TAB_LIST: TabType[] = [ 4 | { 5 | labelName: "My Dictionaries", 6 | labelURL: "/user/collections/" 7 | }, 8 | { 9 | labelName: "Your Organizations' Dictionaries", 10 | labelURL: "/user/orgs/collections/" 11 | }, 12 | { 13 | labelName: "Public Dictionaries", 14 | labelURL: "/collections/" 15 | } 16 | ]; 17 | 18 | export const PER_PAGE = 20; 19 | export const TITLE = "Dictionaries"; 20 | -------------------------------------------------------------------------------- /src/utils/components/NestedErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { Field, FormikState, getIn } from "formik"; 2 | 3 | const NestedErrorMessage = ({ name }: { name: string }) => ( 4 | }) => { 8 | const error = getIn(form.errors, name); 9 | const touch = getIn(form.touched, name); 10 | return touch && error ? {error} : null; 11 | }} 12 | /> 13 | ); 14 | 15 | export default NestedErrorMessage; 16 | -------------------------------------------------------------------------------- /src/apps/organisations/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ViewOrganisations } from "./ViewOrgs"; 2 | export { default as OrganisationForm } from "./OrgForm"; 3 | export { default as OrganisationDetails } from "./OrgDetails"; 4 | export { default as OrganisationSources } from "./OrgSources"; 5 | export { default as OrganisationDictionaries } from "./OrgDictionaries"; 6 | export { default as OrganisationMembers } from "./OrgMembers"; 7 | export { default as ViewOrganisationsPage } from "./ViewOrgsPage"; 8 | export * from "./MenuButton"; 9 | -------------------------------------------------------------------------------- /src/apps/sources/constants.ts: -------------------------------------------------------------------------------- 1 | import { TabType } from "../containers/types"; 2 | 3 | export const OCL_SOURCE_TYPE = "Dictionary"; 4 | 5 | export const TAB_LIST: TabType[] = [ 6 | { 7 | labelName: "My Sources", 8 | labelURL: "/user/sources/" 9 | }, 10 | { 11 | labelName: "Your Organizations' Sources", 12 | labelURL: "/user/orgs/sources/" 13 | }, 14 | { 15 | labelName: "Public Sources", 16 | labelURL: "/sources/" 17 | } 18 | ]; 19 | 20 | export const PER_PAGE = 20; 21 | export const TITLE = "Sources"; 22 | -------------------------------------------------------------------------------- /src/utils/tests/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { buildPartialSearchQuery, findLocale } from "../../utils"; 2 | 3 | describe("findLocale", () => { 4 | it("should find the right locale", () => { 5 | expect(findLocale("fr")).toStrictEqual({ 6 | value: "fr", 7 | label: "French (fr)" 8 | }); 9 | }); 10 | it("should fallback to the right locale", () => { 11 | expect(findLocale("does not exist", "en")).toStrictEqual({ 12 | value: "en", 13 | label: "English (en)" 14 | }); 15 | }); 16 | }); 17 | 18 | export {}; 19 | -------------------------------------------------------------------------------- /src/apps/containers/api.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedInstance } from "../../api"; 2 | 3 | const api = { 4 | retrieve: (containerUrl: string) => 5 | authenticatedInstance.get(containerUrl, { 6 | params: { 7 | verbose: true, 8 | includeReferences: true 9 | } 10 | }), 11 | versions: { 12 | retrieve: (containerUrl: string) => 13 | authenticatedInstance.get(`${containerUrl}versions/`, { 14 | params: { 15 | verbose: true 16 | } 17 | }) 18 | } 19 | }; 20 | 21 | export default api; 22 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # npm artifacts 2 | node_modules/ 3 | build/ 4 | 5 | # oclapi 6 | oclapi/ 7 | 8 | # test stuff 9 | cypress 10 | coverage 11 | cypress.json 12 | 13 | # misc 14 | .DS_Store 15 | 16 | # npm debug logs 17 | npm-debug.log* 18 | 19 | # IDE stuff 20 | .vscode/ 21 | .idea/ 22 | *.iml 23 | *.code-workspace 24 | 25 | # CI infrastructure 26 | .github/ 27 | .gitignore 28 | .coveralls.yml 29 | .travis.yml 30 | 31 | # Docker-related stuff 32 | docker-compose.yml 33 | Dockerfile 34 | 35 | # scripts 36 | start_local_instance.sh 37 | stop_local_instance.sh 38 | -------------------------------------------------------------------------------- /src/apps/authentication/types.ts: -------------------------------------------------------------------------------- 1 | export interface APIProfile { 2 | username: string; 3 | name?: string; 4 | url?: string; 5 | organizations_url?: string; 6 | email: string; 7 | company?: string; 8 | location?: string; 9 | created_on?: string; 10 | } 11 | 12 | export interface APIOrg { 13 | id: string; 14 | name: string; 15 | url: string; 16 | } 17 | 18 | export interface AuthState { 19 | isLoggedIn: boolean; 20 | token?: string; 21 | profile?: APIProfile; 22 | orgs?: APIOrg[]; 23 | nextPage?: string; 24 | } 25 | 26 | export {}; 27 | -------------------------------------------------------------------------------- /src/apps/sources/redux/actionTypes.ts: -------------------------------------------------------------------------------- 1 | export const CREATE_SOURCE_ACTION = "sources/create"; 2 | export const EDIT_SOURCE_ACTION = "sources/edit"; 3 | export const RETRIEVE_SOURCES_ACTION = "sources/retrieveSources"; 4 | export const RETRIEVE_SOURCE_ACTION = "sources/retrieveSource"; 5 | export const RETRIEVE_SOURCE_VERSIONS_ACTION = "sources/retrieveVersions"; 6 | export const CREATE_SOURCE_VERSION_ACTION = "sources/createVersion"; 7 | export const EDIT_SOURCE_VERSION_ACTION = "sources/editVersion"; 8 | export const TOGGLE_SHOW_VERIFIED_ACTION = "sources/toggleShowVerified"; 9 | -------------------------------------------------------------------------------- /src/apps/dictionaries/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AddBulkConceptsPage } from "./AddBulkConceptsPage"; 2 | export { default as CreateDictionaryPage } from "./CreateDictionaryPage"; 3 | export { default as EditDictionaryPage } from "./EditDictionaryPage"; 4 | export { default as ViewPublicDictionariesPage } from "./ViewPublicDictionariesPage"; 5 | export { default as ViewDictionaryPage } from "./ViewDictionaryPage"; 6 | export { default as ViewPersonalDictionariesPage } from "./ViewPersonalDictionariesPage"; 7 | export { default as ViewOrgDictionariesPage } from "./ViewOrgDictionariesPage"; 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | oclclient: 5 | build: 6 | context: . 7 | dockerfile: docker/Dockerfile 8 | environment: 9 | - OCL_API_HOST=${OCL_API_HOST:-https://api.qa.openconceptlab.org/} 10 | - TRADITIONAL_OCL_HOST=${TRADITIONAL_OCL_HOST:-https://qa.openconceptlab.org} 11 | - OCL_SIGNUP_URL=${OCL_SIGNUP_URL:-https://app.qa.openconceptlab.org/#/accounts/signup} 12 | - ENVIRONMENT=${ENVIRONMENT:-dev} 13 | ports: 14 | - 8080:80 15 | restart: "no" 16 | healthcheck: 17 | test: ["CMD", "curl", "-sSf", "localhost"] 18 | -------------------------------------------------------------------------------- /src/apps/containers/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Fab, Tooltip } from "@mui/material"; 3 | import { EditOutlined as EditIcon } from "@mui/icons-material"; 4 | import { Link } from "react-router-dom"; 5 | 6 | interface Props { 7 | url: string; 8 | title: string; 9 | } 10 | 11 | export const EditButton: React.FC = ({ url, title }) => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/apps/authentication/redux/actionTypes.ts: -------------------------------------------------------------------------------- 1 | const LOGIN_ACTION = "authentication/login"; 2 | const LOGOUT_ACTION = "authentication/logout"; 3 | const GET_USER_DETAILS_ACTION = "authentication/getUserDetails"; 4 | const GET_PROFILE_ACTION = "authentication/getProfile"; 5 | const GET_USER_ORGS_ACTION = "authentication/getUserOrgs"; 6 | const SET_NEXT_PAGE_ACTION = "authentication/nextPage"; 7 | const CLEAR_NEXT_PAGE_ACTION = "authentication/nextPage"; 8 | 9 | export { 10 | LOGIN_ACTION, 11 | LOGOUT_ACTION, 12 | GET_USER_DETAILS_ACTION, 13 | GET_PROFILE_ACTION, 14 | GET_USER_ORGS_ACTION, 15 | SET_NEXT_PAGE_ACTION, 16 | CLEAR_NEXT_PAGE_ACTION 17 | }; 18 | -------------------------------------------------------------------------------- /start_local_instance.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Starting API..." 4 | docker-compose -f oclapi/docker-compose.yml up -d || exit $? 5 | api_endpoint=http://localhost:8000 6 | 7 | echo "Building App..." 8 | export OCL_API_HOST=$api_endpoint 9 | docker-compose build --compress --force-rm --build-arg OCL_BUILD=$(git rev-parse --short HEAD) || exit $? 10 | echo "Starting App..." 11 | docker-compose up -d || exit $? 12 | app_endpoint=http://localhost:8080 13 | 14 | # wait for the api and app to be live 15 | ./wait_for_url.sh $api_endpoint"/sources" || exit $? 16 | echo "API listening at "$api_endpoint 17 | 18 | ./wait_for_url.sh $app_endpoint || exit $? 19 | echo "App listening at "$app_endpoint 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": false, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ], 26 | "exclude": [ 27 | "**/tests/e2e/**" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | .pnp/ 6 | .pnp.js 7 | 8 | # testing 9 | coverage/ 10 | 11 | # build 12 | build/ 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | .talismanrc 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | public/index.html~HEAD 28 | 29 | #test snapshots 30 | **/__snapshots__/** 31 | 32 | # IDE settings 33 | .vscode/ 34 | .idea/ 35 | *.iml 36 | *.code-workspace 37 | 38 | # cypress 39 | cypress/screenshots/** 40 | cypress/videos/** 41 | 42 | # env-config 43 | public/env-config.js 44 | junit.xml 45 | -------------------------------------------------------------------------------- /cypress/integration/dictionary/copyDictionary.feature: -------------------------------------------------------------------------------- 1 | Feature: Copying a dictionary 2 | Background: 3 | Given the user is logged in 4 | And a dictionary exists 5 | And a version exists 6 | And a version is released 7 | 8 | @dictionary 9 | @version 10 | Scenario: The user should be able to copy dictionary 11 | Given the user is on the dictionary page 12 | When the user clicks the more actions button 13 | And the user selects the "Copy Dictionary" menu list item 14 | And the user is on copy dictionary form 15 | And the user enters the new dictionary information 16 | And the user submits the form 17 | Then the new dictionary should be created 18 | And the new source should be created 19 | -------------------------------------------------------------------------------- /src/redux/__test__/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { LOGOUT_ACTION } from "../../apps/authentication/redux/actionTypes"; 2 | import loadingAndErroredReducer from "../reducer"; 3 | import { LoadingAndErroredState } from "../types"; 4 | 5 | const initialState = {}; 6 | const currentState: LoadingAndErroredState = { 7 | "authentication/getUserOrgsMeta": [["root"]] 8 | }; 9 | describe("reducer on logout action", () => { 10 | it("should return empty status state on logout action ", () => { 11 | const logoutAction = { 12 | type: LOGOUT_ACTION, 13 | actionIndex: 0, 14 | payload: [] 15 | }; 16 | //@ts-ignore 17 | expect(loadingAndErroredReducer(currentState, logoutAction)).toEqual( 18 | initialState 19 | ); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/test-utils.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import store from "./redux"; 3 | import { render as rtlRender } from "@testing-library/react"; 4 | import { Provider } from "react-redux"; 5 | import { BrowserRouter as Router } from "react-router-dom"; 6 | function render( 7 | ui: any, 8 | { initialState = store.getState(), store: any = store, ...renderOptions } = {} 9 | ) { 10 | function Wrapper({ children }: any) { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | return rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); 18 | } 19 | // re-export everything 20 | export * from "@testing-library/react"; 21 | // override render method 22 | export { render }; 23 | -------------------------------------------------------------------------------- /src/apps/authentication/__test__/components/UserTokenDetails.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "../../../../test-utils.test"; 3 | import { UserTokenDetails } from "../../components"; 4 | import { testToken } from "../test_data"; 5 | 6 | type userTokenDetailsProps = React.ComponentProps; 7 | 8 | const baseProps: userTokenDetailsProps = { 9 | token: testToken 10 | }; 11 | 12 | function renderUI(props: Partial = {}) { 13 | return render(); 14 | } 15 | 16 | describe("UserTokenDetails", () => { 17 | it("should match snapshot", () => { 18 | const { container } = renderUI(baseProps); 19 | 20 | expect(container).toMatchSnapshot(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /cypress/support/step_definitions/organisation/deleteMember/deleteMember.ts: -------------------------------------------------------------------------------- 1 | import { Given, Then, When } from "cypress-cucumber-preprocessor/steps"; 2 | import { getOrganisationId } from "../../../utils"; 3 | 4 | Given("the user is on the organisation detail page", () => { 5 | cy.visit(`/orgs/${getOrganisationId()}/`); 6 | cy.findByText("Members").should("be.visible"); 7 | cy.waitUntil(() => cy.contains("li", "openmrs", { timeout: 10000 })); 8 | }); 9 | When("the user clicks on the delete member button", () =>{ 10 | cy 11 | .contains("li", "openmrs") 12 | .find("button") 13 | .click(); 14 | cy.findByRole("button", { name: /Yes/i }).click(); 15 | } 16 | ); 17 | 18 | Then("the member should be deleted", () => { 19 | cy.get("li").should("not.contain", "openmrs"); 20 | }); 21 | -------------------------------------------------------------------------------- /wait_for_url.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | curl -V > /dev/null 4 | if [ $? -ne 0 ]; then 5 | echo "$0 requires curl to be installed" 6 | exit 1 7 | fi 8 | 9 | if [ -n "$1" ]; then 10 | url=$1 11 | else 12 | echo "Usage: $0 url [max_attempts]" 13 | exit 1 14 | fi 15 | 16 | if [ -n "$2" ]; then 17 | max_attempts=$2 18 | else 19 | max_attempts=5 20 | fi 21 | 22 | if [ -z "$max_attempts" -o $max_attempts -lt 1 ]; then 23 | max_attempts=60 24 | fi 25 | 26 | echo "Waiting for $1" 27 | 28 | attempt_counter=1 29 | until $(curl --output /dev/null --silent --head --fail $url); do 30 | if [ ${attempt_counter} -eq ${max_attempts} ];then 31 | echo "Max attempts reached" 32 | exit 1 33 | fi 34 | printf "." 35 | attempt_counter=$(expr $attempt_counter + 1) 36 | sleep 15 37 | done 38 | -------------------------------------------------------------------------------- /.github/workflows/basic-dictionary.yml: -------------------------------------------------------------------------------- 1 | name: Basic Dictionary Management 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: '10' 18 | - run: npm install 19 | - name: Start OCL instance 20 | run: bash ./start_local_instance.sh 21 | - run: npm run basicDictionaryManagement 22 | # capture the screenshots on failure 23 | - uses: actions/upload-artifact@v2 24 | if: failure() 25 | with: 26 | name: cypress-screenshots 27 | path: cypress/screenshots 28 | if-no-files-found: ignore 29 | -------------------------------------------------------------------------------- /.github/workflows/organisation-management.yml: -------------------------------------------------------------------------------- 1 | name: Organization Management 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: '10' 18 | - run: npm install 19 | - name: Start OCL instance 20 | run: bash ./start_local_instance.sh 21 | - run: npm run organisationManagement 22 | # capture the screenshots on failure 23 | - uses: actions/upload-artifact@v2 24 | if: failure() 25 | with: 26 | name: cypress-screenshots 27 | path: cypress/screenshots 28 | if-no-files-found: ignore 29 | -------------------------------------------------------------------------------- /cypress/integration/organisation/addMember.feature: -------------------------------------------------------------------------------- 1 | Feature: Adding an organisation Member 2 | Background: 3 | Given the user is logged in 4 | 5 | @organisation 6 | Scenario: The user should be able to click the button to add a new member 7 | Given an organization exists 8 | And the user is on the organisation detail page 9 | When the user clicks on the add new member button 10 | Then the user should be on the add new member dialog box 11 | 12 | @organisation 13 | @member 14 | Scenario: The user should be able to add a new member 15 | Given an organization exists 16 | And a new user exists 17 | And the user is on the add new member dialog box 18 | When the user enters the member information 19 | And the user submits the form 20 | Then the new member should be added 21 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | .pnp/ 6 | .pnp.js 7 | 8 | # testing 9 | coverage/ 10 | 11 | # build 12 | build/ 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .talismanrc 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | public/index.html~HEAD 27 | 28 | #test snapshots 29 | **/__snapshots__/** 30 | src/components/dashboard/container/ListContainer.jsx 31 | .env 32 | 33 | # IDE settings 34 | .vscode/ 35 | .idea/ 36 | *.iml 37 | 38 | # cypress 39 | cypress/screenshots/** 40 | cypress/videos/** 41 | 42 | # env-config 43 | public/env-config.js 44 | junit.xml 45 | oclapi/ 46 | 47 | *.svg 48 | *.ico 49 | *.feature 50 | -------------------------------------------------------------------------------- /cypress/integration/organisation/edit.feature: -------------------------------------------------------------------------------- 1 | Feature: Editing a organisation 2 | Background: 3 | Given the user is logged in 4 | 5 | @organisation 6 | Scenario: The user should be able to make a public organization private 7 | Given a public organization exists 8 | And the user is on the edit organization page 9 | When the user selects "None" Public Access 10 | And the user submits the form 11 | Then the organization should not be publicly visible 12 | 13 | @organisation 14 | Scenario: The user should be able to make a private organization public 15 | Given a private organization exists 16 | And the user is on the edit organization page 17 | When the user selects "View" Public Access 18 | And the user submits the form 19 | Then the organization should be publicly visible 20 | 21 | -------------------------------------------------------------------------------- /src/apps/sources/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Switch, useRouteMatch } from "react-router-dom"; 3 | import { ViewSourcePage, EditSourcePage } from "./pages"; 4 | 5 | interface Props { 6 | viewSource?: boolean; 7 | editSource?: boolean; 8 | concepts: boolean; 9 | } 10 | 11 | const Routes: React.FC = ({ 12 | viewSource = true, 13 | editSource = true, 14 | concepts = true 15 | }) => { 16 | let { path } = useRouteMatch(); 17 | return ( 18 | 19 | {!viewSource ? null : ( 20 | 21 | 22 | 23 | )} 24 | {!editSource ? null : ( 25 | 26 | 27 | 28 | )} 29 | 30 | ); 31 | }; 32 | 33 | export default Routes; 34 | -------------------------------------------------------------------------------- /src/components/omrs-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/apps/sources/__test__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { getSourceTypeFromPreviousPath } from "../utils"; 2 | 3 | describe("getSourceTypeFromPreviousPath", () => { 4 | it("should return public sources for /sources/", () => { 5 | expect(getSourceTypeFromPreviousPath("/sources/")).toEqual( 6 | "Public Sources" 7 | ); 8 | }); 9 | 10 | it("should return your sources for /user/sources/", () => { 11 | expect(getSourceTypeFromPreviousPath("/user/sources/")).toEqual( 12 | "Your Sources" 13 | ); 14 | }); 15 | 16 | it("should return organisation sources for /user/orgs/sources/", () => { 17 | expect(getSourceTypeFromPreviousPath("/user/orgs/sources/")).toEqual( 18 | "Your Organisations' Sources" 19 | ); 20 | }); 21 | 22 | it("should return sources for any other", () => { 23 | expect(getSourceTypeFromPreviousPath("/test/")).toEqual("Sources"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/apps/containers/components/__test__/EditMenu.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { Provider } from "react-redux"; 3 | import store from "../../../../redux"; 4 | import { BrowserRouter as Router } from "react-router-dom"; 5 | import * as React from "react"; 6 | import { EditMenu } from "../EditMenu"; 7 | 8 | type editMenuProps = React.ComponentProps; 9 | 10 | const baseProps: editMenuProps = { 11 | backUrl: "url" 12 | }; 13 | function renderUI(props: Partial = {}) { 14 | return render( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | describe("EditMenu", () => { 24 | it("EditMenu snapshot test", () => { 25 | const { container } = renderUI(); 26 | expect(container).toMatchSnapshot(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /cypress/integration/concept/create.feature: -------------------------------------------------------------------------------- 1 | Feature: Creating a custom concept 2 | Background: 3 | Given the user is logged in 4 | 5 | @dictionary 6 | Scenario: The user should be able to go to the create custom concept page 7 | Given a dictionary exists 8 | And the user is on the dictionary concepts page 9 | When the user clicks the add concepts button 10 | And the user selects the "Create custom concept" menu list item 11 | And the user selects the "Other kind" menu list item 12 | Then the user should be on the create concept page 13 | 14 | @dictionary 15 | @concept 16 | Scenario: The user should be able to create a custom concept 17 | Given a dictionary exists 18 | And the user is on the create concept page 19 | When the user enters the concept information 20 | And the user submits the form 21 | Then the new concept should be created 22 | -------------------------------------------------------------------------------- /cypress/integration/dictionary/edit.feature: -------------------------------------------------------------------------------- 1 | Feature: Editing a dictionary 2 | Background: 3 | Given the user is logged in 4 | 5 | @dictionary 6 | Scenario: The user should be able to make a public dictionary private 7 | Given a public dictionary exists 8 | And the user is on the edit dictionary page 9 | When the user selects "Private" visibility 10 | And the user submits the form 11 | Then the dictionary should not be publicly visible 12 | And the source should not be publicly visible 13 | 14 | @dictionary 15 | Scenario: The user should be able to make a private dictionary public 16 | Given a private dictionary exists 17 | And the user is on the edit dictionary page 18 | When the user selects "Public" visibility 19 | And the user submits the form 20 | Then the dictionary should be publicly visible 21 | And the source should be publicly visible 22 | -------------------------------------------------------------------------------- /src/apps/containers/components/__test__/EditButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { Provider } from "react-redux"; 3 | import store from "../../../../redux"; 4 | import { BrowserRouter as Router } from "react-router-dom"; 5 | import * as React from "react"; 6 | import { EditButton } from "../EditButton"; 7 | 8 | type editButtonProps = React.ComponentProps; 9 | 10 | const baseProps: editButtonProps = { 11 | url: "url", 12 | title: "edit" 13 | }; 14 | function renderUI(props: Partial = {}) { 15 | return render( 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | describe("EditButton", () => { 25 | it("EditButton snapshot test", () => { 26 | const { container } = renderUI(); 27 | expect(container).toMatchSnapshot(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/apps/dictionaries/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module handles what the api calls 'collections', but what in this app we call 'dictionaries' 3 | * Since we don't really care about collections themselves, but in the 'linked source' behaviour we create 4 | */ 5 | export { default } from "./Routes"; 6 | export { CreateDictionaryPage, ViewPublicDictionariesPage } from "./pages"; 7 | export { 8 | default as dictionariesReducer, 9 | addConceptsToDictionaryLoadingListSelector, 10 | addConceptsToDictionaryProgressListSelector, 11 | addConceptsToDictionaryErrorListSelector, 12 | recursivelyAddConceptsToDictionaryAction, 13 | addConceptsToDictionaryAction, 14 | removeReferencesFromDictionaryAction, 15 | createDictionaryVersionAction, 16 | createDictionaryVersionLoadingSelector, 17 | createDictionaryVersionErrorSelector 18 | } from "./redux"; 19 | export { buildAddConceptToDictionaryMessage } from "./utils"; 20 | export * from "./types"; 21 | -------------------------------------------------------------------------------- /src/apps/sources/pages/ViewOrgSourcesPage.tsx: -------------------------------------------------------------------------------- 1 | import { AppState } from "../../../redux"; 2 | import { 3 | retrieveOrgSourcesAction, 4 | retrieveOrgSourcesLoadingSelector, 5 | toggleShowVerifiedAction 6 | } from "../redux"; 7 | import { connect } from "react-redux"; 8 | import { ViewSourcesPage } from "../components"; 9 | import { ORG_SOURCES_ACTION_INDEX } from "../redux/constants"; 10 | 11 | export const mapStateToProps = (state: AppState) => ({ 12 | loading: retrieveOrgSourcesLoadingSelector(state), 13 | sources: state.sources.sources[ORG_SOURCES_ACTION_INDEX]?.items, 14 | meta: state.sources.sources[ORG_SOURCES_ACTION_INDEX]?.responseMeta, 15 | showOnlyVerified: state.sources.showOnlyVerified 16 | }); 17 | 18 | export const mapDispatchToProps = { 19 | retrieveSources: retrieveOrgSourcesAction, 20 | toggleShowVerified: toggleShowVerifiedAction 21 | }; 22 | 23 | export default connect(mapStateToProps, mapDispatchToProps)(ViewSourcesPage); 24 | -------------------------------------------------------------------------------- /src/apps/sources/pages/ViewPublicSourcesPage.tsx: -------------------------------------------------------------------------------- 1 | import { AppState } from "../../../redux"; 2 | import { 3 | retrievePublicSourcesAction, 4 | retrievePublicSourcesLoadingSelector, 5 | toggleShowVerifiedAction 6 | } from "../redux"; 7 | import { connect } from "react-redux"; 8 | import { ViewSourcesPage } from "../components"; 9 | import { PUBLIC_SOURCES_ACTION_INDEX } from "../redux/constants"; 10 | 11 | export const mapStateToProps = (state: AppState) => ({ 12 | loading: retrievePublicSourcesLoadingSelector(state), 13 | sources: state.sources.sources[PUBLIC_SOURCES_ACTION_INDEX]?.items, 14 | meta: state.sources.sources[PUBLIC_SOURCES_ACTION_INDEX]?.responseMeta, 15 | showOnlyVerified: state.sources.showOnlyVerified 16 | }); 17 | 18 | export const mapDispatchToProps = { 19 | retrieveSources: retrievePublicSourcesAction, 20 | toggleShowVerified: toggleShowVerifiedAction 21 | }; 22 | 23 | export default connect(mapStateToProps, mapDispatchToProps)(ViewSourcesPage); 24 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export interface Option { 2 | label: string | [string, React.ReactNode]; 3 | value: string; 4 | } 5 | 6 | export interface OptionResponse { 7 | options: Option[]; 8 | hasMore: boolean; 9 | additional: {}; 10 | } 11 | 12 | export interface Extras { 13 | [key: string]: string | undefined; 14 | } 15 | 16 | export interface BaseConceptContainer { 17 | name: string; 18 | short_code: string; 19 | description: string; 20 | public_access: string; 21 | default_locale: string; 22 | extras?: Extras; 23 | } 24 | 25 | export interface EditableConceptContainerFields { 26 | description?: string; 27 | name?: string; 28 | supported_locales?: string; 29 | default_locale?: string; 30 | preferred_source?: string; 31 | public_access?: string; 32 | } 33 | 34 | export interface Version { 35 | id: string; 36 | released: boolean; 37 | description?: string; 38 | external_id: string; 39 | extras?: Extras; 40 | created_on?: string; 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/dictionary-manager.yml: -------------------------------------------------------------------------------- 1 | name: Dictionary Manager 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: '10' 18 | - run: npm install 19 | - name: Unit tests 20 | run: npm run test:ci 21 | - name: Start OCL instance 22 | run: bash ./start_local_instance.sh 23 | - run: npm run test:integration 24 | # capture the screenshots on failure 25 | - uses: actions/upload-artifact@v2 26 | if: failure() 27 | with: 28 | name: cypress-screenshots 29 | path: cypress/screenshots 30 | if-no-files-found: ignore 31 | - uses: coverallsapp/github-action@v1.1.2 32 | with: 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /src/apps/sources/pages/ViewPersonalSourcesPage.tsx: -------------------------------------------------------------------------------- 1 | import { AppState } from "../../../redux"; 2 | import { 3 | retrievePersonalSourcesAction, 4 | retrievePersonalSourcesLoadingSelector, 5 | toggleShowVerifiedAction 6 | } from "../redux"; 7 | import { connect } from "react-redux"; 8 | 9 | import { PERSONAL_SOURCES_ACTION_INDEX } from "../redux/constants"; 10 | import { ViewSourcesPage } from "../components"; 11 | 12 | export const mapStateToProps = (state: AppState) => ({ 13 | loading: retrievePersonalSourcesLoadingSelector(state), 14 | sources: state.sources.sources[PERSONAL_SOURCES_ACTION_INDEX]?.items, 15 | meta: state.sources.sources[PERSONAL_SOURCES_ACTION_INDEX]?.responseMeta, 16 | showOnlyVerified: state.sources.showOnlyVerified 17 | }); 18 | 19 | export const mapDispatchToProps = { 20 | retrieveSources: retrievePersonalSourcesAction, 21 | toggleShowVerified: toggleShowVerifiedAction 22 | }; 23 | 24 | export default connect(mapStateToProps, mapDispatchToProps)(ViewSourcesPage); 25 | -------------------------------------------------------------------------------- /src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { useLocation } from "react-router"; 3 | import qs from "qs"; 4 | 5 | export function usePrevious(value: T) { 6 | const ref = useRef(); 7 | useEffect(() => { 8 | ref.current = value; 9 | }); 10 | return ref.current; 11 | } 12 | 13 | export function useQueryParams(): QueryParamsType { 14 | return (qs.parse(useLocation().search, { 15 | ignoreQueryPrefix: true 16 | }) as unknown) as QueryParamsType; 17 | } 18 | 19 | export function useAnchor(): [ 20 | null | HTMLElement, 21 | (event: React.MouseEvent) => void, 22 | () => void 23 | ] { 24 | const [anchorEl, setAnchorEl] = useState(null); 25 | const handleClick = (event: React.MouseEvent) => { 26 | setAnchorEl(event.currentTarget); 27 | }; 28 | const handleClose = () => { 29 | setAnchorEl(null); 30 | }; 31 | 32 | return [anchorEl, handleClick, handleClose]; 33 | } 34 | -------------------------------------------------------------------------------- /cypress/integration/concept/edit.feature: -------------------------------------------------------------------------------- 1 | Feature: Edit and retire a custom concept 2 | Background: 3 | Given the user is logged in 4 | 5 | @dictionary 6 | @concept 7 | Scenario: The user should be able edit a custom concept 8 | Given a dictionary exists 9 | And a concept exists 10 | And the user is on the edit concept page 11 | When the user edits the concept name 12 | And the user submits the form 13 | # Then the concept should be updated TO be done later 14 | 15 | @dictionary 16 | @concept 17 | Scenario: The user should be able to retire a custom concept 18 | Given a dictionary exists 19 | And a concept exists 20 | And the user is on the edit concept page 21 | When the user clicks the Menu button 22 | And the user selects the "Retire concept" menu list item 23 | Then the concept should be retired 24 | -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | const browserify = require("@cypress/browserify-preprocessor"); 15 | const cucumber = require("cypress-cucumber-preprocessor").default; 16 | 17 | /** 18 | * @type {Cypress.PluginConfig} 19 | */ 20 | const plugins: Cypress.PluginConfig = (on) => { 21 | on( 22 | "file:preprocessor", 23 | cucumber({ 24 | ...browserify.defaultOptions, 25 | typescript: require.resolve("typescript") 26 | }) 27 | ); 28 | }; 29 | 30 | export default plugins; 31 | -------------------------------------------------------------------------------- /cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.ts using ES2015 syntax: 17 | import "cypress-wait-until"; 18 | import "./commands"; 19 | 20 | Cypress.on('uncaught:exception', (err, runnable) => { 21 | if (err) { 22 | // tslint:disable: no-console 23 | console.log('error', err) 24 | console.log('runnable', runnable) 25 | } 26 | // returning false here prevents Cypress from 27 | // failing the test 28 | return false; 29 | }); 30 | -------------------------------------------------------------------------------- /src/apps/dictionaries/pages/tests/e2e/CreateDictionaryPage.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { login, logout } from "../../../../authentication/tests/e2e/testUtils"; 4 | import { createDictionary, newDictionary } from "./testUtils"; 5 | 6 | describe("Create Dictionary", () => { 7 | beforeEach(() => { 8 | login(); 9 | }); 10 | 11 | afterEach(() => { 12 | logout(); 13 | }); 14 | 15 | it("Happy flow: Should create a dictionary", () => { 16 | // todo improve this test to check actual values 17 | const [dictionary, dictionaryUrl] = newDictionary(); 18 | 19 | cy.visit(dictionaryUrl); 20 | cy.findByText( 21 | "Could not load dictionary. Refresh the page to retry" 22 | ).should("exist"); 23 | 24 | createDictionary([dictionary, dictionaryUrl]); 25 | 26 | cy.visit(dictionaryUrl); 27 | cy.queryByText( 28 | "Could not load dictionary. Refresh the page to retry" 29 | ).should("not.exist"); 30 | cy.findByText("General Details").should("exist"); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/apps/notifications/__test__/components/EnhancedNotificationSummaryTableHead.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Table } from "@mui/material"; 3 | import { render } from "@testing-library/react"; 4 | import { EnhancedNotificationSummaryTableHead } from "../../components/EnhancedNotificationSummaryTableHead"; 5 | 6 | type enhancedNotificationSummaryTableHeadProps = React.ComponentProps< 7 | typeof EnhancedNotificationSummaryTableHead 8 | >; 9 | const baseProps: enhancedNotificationSummaryTableHeadProps = { 10 | order: "asc", 11 | orderBy: "status", 12 | onRequestSort: jest.fn 13 | }; 14 | 15 | function renderUI( 16 | props: Partial = {} 17 | ) { 18 | return render( 19 | 20 | 21 |
22 | ); 23 | } 24 | 25 | describe("EnhancedNotificationSummaryTableHead", () => { 26 | it("match snapshot", () => { 27 | const { container } = renderUI(); 28 | expect(container).toMatchSnapshot(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/apps/dictionaries/pages/ViewOrgDictionariesPage.tsx: -------------------------------------------------------------------------------- 1 | import { AppState } from "../../../redux"; 2 | import { 3 | retrieveOrgDictionariesAction, 4 | retrieveOrgDictionariesLoadingSelector, 5 | toggleShowVerifiedAction 6 | } from "../redux"; 7 | import { connect } from "react-redux"; 8 | import { ViewDictionariesPage } from "../components"; 9 | import { ORG_DICTIONARIES_ACTION_INDEX } from "../redux/constants"; 10 | 11 | const mapStateToProps = (state: AppState) => ({ 12 | loading: retrieveOrgDictionariesLoadingSelector(state), 13 | dictionaries: 14 | state.dictionaries.dictionaries[ORG_DICTIONARIES_ACTION_INDEX]?.items, 15 | meta: 16 | state.dictionaries.dictionaries[ORG_DICTIONARIES_ACTION_INDEX] 17 | ?.responseMeta, 18 | showOnlyVerified: state.dictionaries.showOnlyVerified 19 | }); 20 | 21 | const mapDispatchToProps = { 22 | retrieveDictionaries: retrieveOrgDictionariesAction, 23 | toggleShowVerified: toggleShowVerifiedAction 24 | }; 25 | 26 | export default connect( 27 | mapStateToProps, 28 | mapDispatchToProps 29 | )(ViewDictionariesPage); 30 | -------------------------------------------------------------------------------- /src/apps/containers/components/EditMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Fab, Menu, MenuItem, Tooltip } from "@mui/material"; 2 | import { MoreVert as MenuIcon } from "@mui/icons-material"; 3 | import { Link } from "react-router-dom"; 4 | import React from "react"; 5 | import { useAnchor } from "../../../utils"; 6 | 7 | interface Props { 8 | backUrl: string; 9 | } 10 | 11 | export const EditMenu: React.FC = ({ backUrl }: Props) => { 12 | const [menuAnchor, handleMenuClick, handleMenuClose] = useAnchor(); 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 27 | 28 | 29 | Discard changes and view 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/apps/organisations/pages/ViewPublicOrgs.tsx: -------------------------------------------------------------------------------- 1 | import { AppState } from "../../../redux"; 2 | import { retrievePublicOrganisationsLoadingSelector } from "../redux"; 3 | import { connect } from "react-redux"; 4 | import { 5 | retrievePublicOrganisationsAction, 6 | toggleShowVerifiedAction 7 | } from "../redux/actions"; 8 | import { ViewOrganisationsPage } from "../components"; 9 | import { PUBLIC_ORGS_ACTION_INDEX } from "../redux/constants"; 10 | 11 | const mapStateToProps = (state: AppState) => ({ 12 | loading: retrievePublicOrganisationsLoadingSelector(state), 13 | organisations: 14 | state.organisations.organisations[PUBLIC_ORGS_ACTION_INDEX]?.items, 15 | meta: 16 | state.organisations.organisations[PUBLIC_ORGS_ACTION_INDEX]?.responseMeta, 17 | showOnlyVerified: state.organisations.showOnlyVerified 18 | }); 19 | 20 | const mapDispatchToProps = { 21 | retrieveOrganisations: retrievePublicOrganisationsAction, 22 | toggleShowVerified: toggleShowVerifiedAction 23 | }; 24 | 25 | export default connect( 26 | mapStateToProps, 27 | mapDispatchToProps 28 | )(ViewOrganisationsPage); 29 | -------------------------------------------------------------------------------- /docker/startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ENV_FILE="/usr/share/nginx/html/env-config.js" 4 | 5 | echo "// Version: $(date -u)" > ${ENV_FILE} 6 | 7 | [ -n "${TRADITIONAL_OCL_HOST}" ] && \ 8 | echo "var TRADITIONAL_OCL_HOST = \"${TRADITIONAL_OCL_HOST}\";" >> ${ENV_FILE} 9 | [ -n "${OCL_API_HOST}" ] && \ 10 | echo "var OCL_API_HOST = \"${OCL_API_HOST}\";" >> ${ENV_FILE} 11 | [ -n "${OCL_SIGNUP_URL}" ] && \ 12 | echo "var OCL_SIGNUP_URL = \"${OCL_SIGNUP_URL}\";" >> ${ENV_FILE} 13 | [ -n "${OCL_BUILD}" ] && \ 14 | echo "var OCL_BUILD = \"${OCL_BUILD}\";" >> ${ENV_FILE} 15 | # converts a space separated list of GA tokens into a JS array 16 | if [ -n "${OCL_GA_TOKENS}" ]; then 17 | for token in ${OCL_GA_TOKENS}; 18 | do 19 | if [ -z "${TOKEN_STRING}" ]; then 20 | TOKEN_STRING="\"${token}\"" 21 | else 22 | TOKEN_STRING="${TOKEN_STRING},\"${token}\"" 23 | fi 24 | done 25 | echo "var OCL_GA_TOKENS = [${TOKEN_STRING}];" >> ${ENV_FILE} 26 | fi 27 | 28 | echo "Using env-config.js:" 29 | cat ${ENV_FILE} 30 | echo "----" 31 | 32 | echo "Starting up the server..." 33 | nginx -g "daemon off;" 34 | -------------------------------------------------------------------------------- /src/apps/authentication/__test__/redux/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { LOGOUT_ACTION } from "../../redux/actionTypes"; 2 | import { AuthState } from "../../types"; 3 | import reducer from "../../redux/reducer"; 4 | import { AnyAction } from "redux"; 5 | 6 | const logoutAuthState: AuthState = { isLoggedIn: false, token: undefined }; 7 | const currentState: AuthState = { 8 | isLoggedIn: true, 9 | profile: { 10 | username: "test", 11 | email: "test@test.com" 12 | }, 13 | orgs: [ 14 | { 15 | id: "OCL", 16 | name: "Open Concept Lab", 17 | url: "/orgs/OCL/" 18 | }, 19 | { 20 | id: "CIEL", 21 | name: "CIEL", 22 | url: "/orgs/CIEL/" 23 | } 24 | ], 25 | token: "1234567890" 26 | }; 27 | 28 | describe("auth reducer on logout action", () => { 29 | it("should return auth state only with isLoggedIn as false on logout action ", () => { 30 | const logoutAction: AnyAction = { 31 | type: LOGOUT_ACTION, 32 | actionIndex: 0, 33 | payload: [] 34 | }; 35 | expect(reducer(currentState, logoutAction)).toEqual(logoutAuthState); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/apps/organisations/components/OrgSources.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Grid, Paper, Typography, List, ListItem } from "@mui/material"; 3 | import { OrgSource } from "../types"; 4 | import { Link } from "react-router-dom"; 5 | interface Props { 6 | sources?: OrgSource[]; 7 | } 8 | 9 | const OrganisationSources: React.FC = ({ sources }) => { 10 | return ( 11 | 12 | 13 |
14 | 15 | Sources 16 | 17 | 18 | {sources?.length ? ( 19 | sources.map(s => ( 20 | 21 | {s.name} 22 | 23 | )) 24 | ) : ( 25 | No sources found! 26 | )} 27 | 28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | export default OrganisationSources; 35 | -------------------------------------------------------------------------------- /cypress/integration/organisation/create.feature: -------------------------------------------------------------------------------- 1 | Feature: Creating an organisation 2 | Background: 3 | Given the user is logged in 4 | 5 | Scenario: The user should be able to click the button to create a new organisation 6 | Given the user is on the organisations page 7 | When the user clicks on the create new organisation button 8 | Then the user should be on the create new organisation page 9 | 10 | @organisation 11 | Scenario: The user should be able to create a new organisation 12 | Given the user is on the create new organisation page 13 | When the user enters the organisation information 14 | And the user submits the form 15 | Then the new organisation should be created 16 | 17 | @organisation 18 | Scenario: The user should be able to create a public organisation 19 | Given the user is on the create new organisation page 20 | When the user enters the organisation information 21 | And the user selects "View" view 22 | And the user submits the form 23 | Then the new organisation should be created 24 | And the organisation should be publicly visible 25 | -------------------------------------------------------------------------------- /src/apps/dictionaries/pages/ViewPublicDictionariesPage.tsx: -------------------------------------------------------------------------------- 1 | import { AppState } from "../../../redux"; 2 | import { 3 | retrievePublicDictionariesLoadingSelector, 4 | toggleShowVerifiedAction 5 | } from "../redux"; 6 | import { connect } from "react-redux"; 7 | import { retrievePublicDictionariesAction } from "../redux/actions"; 8 | import { ViewDictionariesPage } from "../components"; 9 | import { PUBLIC_DICTIONARIES_ACTION_INDEX } from "../redux/constants"; 10 | 11 | const mapStateToProps = (state: AppState) => ({ 12 | loading: retrievePublicDictionariesLoadingSelector(state), 13 | dictionaries: 14 | state.dictionaries.dictionaries[PUBLIC_DICTIONARIES_ACTION_INDEX]?.items, 15 | meta: 16 | state.dictionaries.dictionaries[PUBLIC_DICTIONARIES_ACTION_INDEX] 17 | ?.responseMeta, 18 | showOnlyVerified: state.dictionaries.showOnlyVerified 19 | }); 20 | 21 | const mapDispatchToProps = { 22 | retrieveDictionaries: retrievePublicDictionariesAction, 23 | toggleShowVerified: toggleShowVerifiedAction 24 | }; 25 | 26 | export default connect( 27 | mapStateToProps, 28 | mapDispatchToProps 29 | )(ViewDictionariesPage); 30 | -------------------------------------------------------------------------------- /src/apps/organisations/components/OrgDictionaries.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Grid, Paper, Typography, List, ListItem } from "@mui/material"; 3 | import { OrgCollection } from "../types"; 4 | import { Link } from "react-router-dom"; 5 | 6 | interface Props { 7 | collections?: OrgCollection[]; 8 | } 9 | 10 | const OrganisationDictionaries: React.FC = ({ collections }) => { 11 | return ( 12 | 13 | 14 |
15 | 16 | Dictionaries 17 | 18 | 19 | {collections?.length ? ( 20 | collections.map(c => ( 21 | 22 | {c.name} 23 | 24 | )) 25 | ) : ( 26 | No dictionaries found! 27 | )} 28 | 29 |
30 |
31 |
32 | ); 33 | }; 34 | 35 | export default OrganisationDictionaries; 36 | -------------------------------------------------------------------------------- /src/redux/types.ts: -------------------------------------------------------------------------------- 1 | import { AuthState } from "../apps/authentication"; 2 | import { DictionaryState } from "../apps/dictionaries"; 3 | import { ConceptsState } from "../apps/concepts"; 4 | import { AnyAction } from "redux"; 5 | import { SourceState } from "../apps/sources"; 6 | import { OrganisationState } from "../apps/organisations"; 7 | 8 | export interface StatusState { 9 | [key: string]: any[]; 10 | } 11 | 12 | export interface AppState { 13 | auth: AuthState; 14 | status: StatusState; 15 | dictionaries: DictionaryState; 16 | concepts: ConceptsState; 17 | sources: SourceState; 18 | organisations: OrganisationState; 19 | } 20 | 21 | export interface LoadingAndErroredState { 22 | [key: string]: (boolean | {} | undefined)[]; 23 | } 24 | 25 | export interface Action extends AnyAction { 26 | type: string; 27 | payload?: any; 28 | actionIndex: number; 29 | meta: any[]; 30 | responseMeta?: {}; 31 | } 32 | 33 | export interface IndexedAction { 34 | actionType: string; 35 | actionIndex: number; 36 | } 37 | 38 | export interface TaskResponse { 39 | data: T; 40 | status?: number; 41 | headers?: Record; 42 | } 43 | -------------------------------------------------------------------------------- /src/apps/dictionaries/pages/ViewPersonalDictionariesPage.tsx: -------------------------------------------------------------------------------- 1 | // todo: make this handle both user and orgs dictionaries 2 | import { AppState } from "../../../redux"; 3 | import { 4 | retrievePersonalDictionariesAction, 5 | retrievePersonalDictionariesLoadingSelector, 6 | toggleShowVerifiedAction 7 | } from "../redux"; 8 | import { connect } from "react-redux"; 9 | import { ViewDictionariesPage } from "../components"; 10 | import { PERSONAL_DICTIONARIES_ACTION_INDEX } from "../redux/constants"; 11 | 12 | const mapStateToProps = (state: AppState) => ({ 13 | loading: retrievePersonalDictionariesLoadingSelector(state), 14 | dictionaries: 15 | state.dictionaries.dictionaries[PERSONAL_DICTIONARIES_ACTION_INDEX]?.items, 16 | meta: 17 | state.dictionaries.dictionaries[PERSONAL_DICTIONARIES_ACTION_INDEX] 18 | ?.responseMeta, 19 | showOnlyVerified: state.dictionaries.showOnlyVerified 20 | }); 21 | 22 | const mapDispatchToProps = { 23 | retrieveDictionaries: retrievePersonalDictionariesAction, 24 | toggleShowVerified: toggleShowVerifiedAction 25 | }; 26 | 27 | export default connect( 28 | mapStateToProps, 29 | mapDispatchToProps 30 | )(ViewDictionariesPage); 31 | -------------------------------------------------------------------------------- /src/apps/concepts/__test__/components/EnhancedTableToolbar.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EnhancedTableToolbar } from "../../components/EnhancedTableToolbar"; 3 | import { render } from "../../../../test-utils.test"; 4 | import "@testing-library/jest-dom"; 5 | import { theme } from "../../../../App"; 6 | import { ThemeProvider } from "@mui/material/styles"; 7 | 8 | type enhancedTableToolbarProps = React.ComponentProps< 9 | typeof EnhancedTableToolbar 10 | >; 11 | const baseProps: enhancedTableToolbarProps = { 12 | numSelected: 0, 13 | q: "", 14 | search: function() {}, 15 | setQ: function() {}, 16 | toggleShowOptions: function() {}, 17 | showAddConcepts: false, 18 | addSelectedConcepts: function() {} 19 | }; 20 | 21 | function renderUI(props: Partial = {}) { 22 | return render( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | describe("EnhancedTableToolbar", () => { 30 | it("should match snapshot", () => { 31 | const { container } = renderUI(); 32 | 33 | expect(container).toMatchSnapshot(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /cypress/support/step_definitions/concept/edit/edit.ts: -------------------------------------------------------------------------------- 1 | // 2 | /// 3 | import { Given, Then, When } from "cypress-cucumber-preprocessor/steps"; 4 | import { getConceptVersionUrl } from "../../../utils"; 5 | 6 | Given("the user is on the edit concept page", () => { 7 | cy.visit(`${getConceptVersionUrl()}edit/`); 8 | }); 9 | 10 | When("the user edits the concept name", () => { 11 | cy.get("input[name='names[0].name']").type("test concept edited"); 12 | }); 13 | 14 | When("the user submits the form", () => cy.get("form").submit()); 15 | 16 | // This has some weird behaviour, we shall come back to it later 17 | // Then("the concept should be updated", () => { 18 | // cy.url().should("not.contain", '/edit'); 19 | // cy.findByText("test concept edited").should("be.visible"); 20 | // }); 21 | 22 | When("the user clicks the Menu button", () => { 23 | cy.findByLabelText("Menu").click(); 24 | }); 25 | 26 | When(/the user selects the "(.+)" menu list item/, menuItem => { 27 | cy.findByText(menuItem).click(); 28 | }); 29 | 30 | Then("the concept should be retired", () => { 31 | cy.url().should("not.contain", "/edit"); 32 | }); 33 | -------------------------------------------------------------------------------- /src/apps/authentication/__test__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { canModifyContainer } from "../utils"; 2 | import { USER_TYPE, ORG_TYPE } from "../../../utils"; 3 | 4 | describe("canModifyContainer", () => { 5 | const profile: any = { 6 | username: "Foo" 7 | }; 8 | 9 | const usersOrgs: any = { 10 | id: "Foo" 11 | }; 12 | 13 | it("should return true, when owner type is user and profile username matches owner name", () => { 14 | expect(canModifyContainer(USER_TYPE, "Foo", profile, [])).toBe(true); 15 | }); 16 | 17 | it("should return false, when owner type is user and profile username doesn`t matches owner name", () => { 18 | expect(canModifyContainer(USER_TYPE, "Foo1", profile, [])).toBe(false); 19 | }); 20 | 21 | it("should return true, when owner type is org and current signed in user is part of that org", () => { 22 | expect(canModifyContainer(ORG_TYPE, "Foo", profile, [usersOrgs])).toBe( 23 | true 24 | ); 25 | }); 26 | 27 | it("should return false, when owner type is org and current signed in user is part of that org", () => { 28 | expect(canModifyContainer(ORG_TYPE, "Foo1", profile, [usersOrgs])).toBe( 29 | false 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/apps/organisations/pages/ViewPersonalOrgs.tsx: -------------------------------------------------------------------------------- 1 | import { AppState } from "../../../redux"; 2 | import { profileSelector } from "../../authentication/redux/reducer"; 3 | import { retrievePersonalOrganisationsLoadingSelector } from "../redux"; 4 | import { connect } from "react-redux"; 5 | import { 6 | retrievePersonalOrganisationsAction, 7 | toggleShowVerifiedAction 8 | } from "../redux/actions"; 9 | import { ViewOrganisationsPage } from "../components"; 10 | import { PERSONAL_ORGS_ACTION_INDEX } from "../redux/constants"; 11 | 12 | const mapStateToProps = (state: AppState) => ({ 13 | loading: retrievePersonalOrganisationsLoadingSelector(state), 14 | profile: profileSelector(state), 15 | organisations: 16 | state.organisations.organisations[PERSONAL_ORGS_ACTION_INDEX]?.items, 17 | meta: 18 | state.organisations.organisations[PERSONAL_ORGS_ACTION_INDEX]?.responseMeta, 19 | showOnlyVerified: state.organisations.showOnlyVerified 20 | }); 21 | 22 | const mapDispatchToProps = { 23 | retrieveOrganisations: retrievePersonalOrganisationsAction, 24 | toggleShowVerified: toggleShowVerifiedAction 25 | }; 26 | 27 | export default connect( 28 | mapStateToProps, 29 | mapDispatchToProps 30 | )(ViewOrganisationsPage); 31 | -------------------------------------------------------------------------------- /src/apps/organisations/redux/actionTypes.ts: -------------------------------------------------------------------------------- 1 | export const GET_USER_ORGS_ACTION = "authentication/getUserOrgs"; 2 | export const RETRIEVE_ORGS_ACTION = "organisations"; 3 | export const GET_PROFILE_ACTION = "authentication/getProfile"; 4 | export const CREATE_ORGANISATION_ACTION = "organisations/create"; 5 | export const DELETE_ORGANISATION_ACTION = "organisations/create"; 6 | export const EDIT_ORGANISATION_ACTION = "organisations/update"; 7 | export const GET_ORG_ACTION = "organisation/retrieve"; 8 | export const GET_ORG_SOURCES_ACTION = "organisation/retrieveSources"; 9 | export const GET_ORG_COLLECTIONS_ACTION = "organisation/retrieveCollections"; 10 | export const GET_ORG_MEMBERS_ACTION = "organisation/retrieveMembers"; 11 | export const CREATE_ORG_MEMBER_ACTION = "organisation/addMember"; 12 | export const DELETE_ORG_MEMBER_ACTION = "organisation/deleteMember"; 13 | export const SHOW_ADD_MEMBER_DIALOG = "organisation/showAddMember"; 14 | export const HIDE_ADD_MEMBER_DIALOG = "organisation/hideAddMember"; 15 | export const SHOW_DELETE_MEMBER_DIALOG = "organisation/showDeleteMember"; 16 | export const HIDE_DELETE_MEMBER_DIALOG = "organisation/hideDeleteMember"; 17 | export const TOGGLE_SHOW_VERIFIED_ACTION = "organisation/toggleShowVerified"; 18 | -------------------------------------------------------------------------------- /src/fonts/nunito.css: -------------------------------------------------------------------------------- 1 | /* vietnamese */ 2 | @font-face { 3 | font-family: "Nunito"; 4 | font-style: normal; 5 | font-weight: 400; 6 | font-display: swap; 7 | src: local("Nunito Regular"), local("Nunito-Regular"), 8 | url(XRXV3I6Li01BKofIOuaBTMnFcQIG.woff2) format("woff2"); 9 | unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB; 10 | } 11 | 12 | /* latin-ext */ 13 | @font-face { 14 | font-family: "Nunito"; 15 | font-style: normal; 16 | font-weight: 400; 17 | font-display: swap; 18 | src: local("Nunito Regular"), local("Nunito-Regular"), 19 | url(XRXV3I6Li01BKofIO-aBTMnFcQIG.woff2) format("woff2"); 20 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 21 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 22 | } 23 | 24 | /* latin */ 25 | @font-face { 26 | font-family: "Nunito"; 27 | font-style: normal; 28 | font-weight: 400; 29 | font-display: swap; 30 | src: local("Nunito Regular"), local("Nunito-Regular"), 31 | url(XRXV3I6Li01BKofINeaBTMnFcQ.woff2) format("woff2"); 32 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 33 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 34 | U+FEFF, U+FFFD; 35 | } 36 | -------------------------------------------------------------------------------- /src/apps/organisations/components/OrgCards.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Grid, Typography, Box } from "@mui/material"; 3 | 4 | import OrganisationCard from "./OrgCard"; 5 | export interface Card { 6 | name: string; 7 | url: string; 8 | id: string; 9 | } 10 | interface Props { 11 | cards: Card[]; 12 | title: string; 13 | } 14 | 15 | const OrganisationCards: React.FC = ({ cards, title }) => { 16 | return ( 17 | 18 | 26 | {cards.length === 0 ? ( 27 | 28 | No {title} 29 | 30 | ) : ( 31 | "" 32 | )} 33 | {cards.map(({ name, url, id }, index) => ( 34 | 41 | ))} 42 | 43 | 44 | ); 45 | }; 46 | 47 | export default OrganisationCards; 48 | -------------------------------------------------------------------------------- /src/apps/authentication/utils.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import store from "../../redux/store"; 3 | // resist the temptation to make this like the rest of the action creators 4 | // because of the potential of a circular dependency(auth/utils->api->auth/api->auth/redux/actions->auth->utils) 5 | import { LOGOUT_ACTION } from "./redux"; 6 | import { AppState } from "../../redux"; 7 | import { USER_TYPE } from "../../utils"; 8 | import { APIOrg, APIProfile } from "./types"; 9 | import { action } from "../../redux/utils"; 10 | 11 | export const redirectIfNotLoggedIn = (response: AxiosResponse) => { 12 | if (response.status === 401) { 13 | store.dispatch(action(LOGOUT_ACTION)); 14 | } 15 | return response; 16 | }; 17 | 18 | export const addAuthToken = (data: any, headers?: any) => { 19 | headers["Authorization"] = `Token ${ 20 | (store.getState() as AppState).auth.token 21 | }`; 22 | return data; 23 | }; 24 | 25 | export const canModifyContainer = ( 26 | ownerType: string, 27 | owner: string, 28 | profile?: APIProfile, 29 | usersOrgs: APIOrg[] = [] 30 | ) => 31 | Boolean( 32 | ownerType === USER_TYPE 33 | ? profile?.username === owner 34 | : usersOrgs.map(org => org.id).includes(owner) 35 | ); 36 | -------------------------------------------------------------------------------- /src/apps/concepts/__test__/components/EnhancedTableHead.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EnhancedTableHead } from "../../components/EnhancedTableHead"; 3 | import { render } from "../../../../test-utils.test"; 4 | import "@testing-library/jest-dom"; 5 | import Table from "@mui/material/Table"; 6 | 7 | type enhancedTableHeadProps = React.ComponentProps; 8 | const baseProps: enhancedTableHeadProps = { 9 | classes: { 10 | paper: "makeStyles-paper-89", 11 | root: "makeStyles-root-88", 12 | table: "makeStyles-table-90", 13 | tableWrapper: "makeStyles-tableWrapper-91", 14 | visuallyHidden: "makeStyles-visuallyHidden-92" 15 | }, 16 | numSelected: 0, 17 | order: "sortAsc", 18 | orderBy: "id", 19 | onSelectAllClick: function() {}, 20 | onRequestSort: function() {}, 21 | rowCount: 10 22 | }; 23 | 24 | function renderUI(props: Partial = {}) { 25 | return render( 26 | 27 | 28 |
29 | ); 30 | } 31 | 32 | describe("EnhancedTableHead", () => { 33 | it("should match snapshot", () => { 34 | const { container } = renderUI(); 35 | 36 | expect(container).toMatchSnapshot(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/apps/dictionaries/redux/actionTypes.ts: -------------------------------------------------------------------------------- 1 | export const CREATE_DICTIONARY_ACTION = "dictionaries/create"; 2 | export const CREATE_SOURCE_AND_DICTIONARY_ACTION = 3 | "dictionaries/createSourceAndDictionary"; 4 | export const RETRIEVE_DICTIONARY_ACTION = "dictionaries/retrieveDictionary"; 5 | export const RETRIEVE_DICTIONARIES_ACTION = "dictionaries/retrieveDictionaries"; 6 | export const EDIT_SOURCE_AND_DICTIONARY_ACTION = 7 | "dictionaries/editSourceAndDictionary"; 8 | export const CREATE_AND_ADD_LINKED_SOURCE_ACTION = 9 | "dictionaries/createAndAddLinkedSource"; 10 | export const EDIT_DICTIONARY_ACTION = "dictionaries/edit"; 11 | export const RETRIEVE_DICTIONARY_VERSIONS_ACTION = 12 | "dictionaries/retrieveVersions"; 13 | export const CREATE_DICTIONARY_VERSION_ACTION = "dictionaries/createVersion"; 14 | export const EDIT_DICTIONARY_VERSION_ACTION = "dictionaries/editVersion"; 15 | export const ADD_CONCEPTS_TO_DICTIONARY = 16 | "dictionaries/Add concept(s) to dictionary"; 17 | export const REMOVE_REFERENCES_FROM_DICTIONARY = 18 | "dictionaries/Remove reference(s) from dictionary"; 19 | export const TOGGLE_SHOW_VERIFIED_ACTION = "dictionary/toggleShowVerified"; 20 | export const RETRIEVE_DICTIONARY_CONCEPT_COUNTS_ACTION = "dictionary/retrieveConceptCounts"; 21 | -------------------------------------------------------------------------------- /src/apps/concepts/utils.ts: -------------------------------------------------------------------------------- 1 | import { APIOrg, APIProfile } from "../authentication"; 2 | import { USER_TYPE } from "../../utils"; 3 | // @ts-ignore 4 | import { getParams } from "url-matcher"; 5 | 6 | export function getContainerIdFromUrl(sourceUrl?: string) { 7 | // /orgs/FOO/sources/FOO/ => FOO 8 | // /orgs/FOO/collections/FOO/ => FOO 9 | // / => All Public Concepts 10 | 11 | if (!sourceUrl) return undefined; 12 | const withoutTrailingSlash = sourceUrl.endsWith("/") 13 | ? sourceUrl.substring(0, sourceUrl.lastIndexOf("/")) 14 | : sourceUrl; 15 | const sourceName = withoutTrailingSlash.substring( 16 | withoutTrailingSlash.lastIndexOf("/") + 1 17 | ); 18 | 19 | return sourceName ? sourceName : "All Public Concepts"; 20 | } 21 | 22 | export function canModifyConcept( 23 | conceptUrl: string, 24 | profile?: APIProfile, 25 | usersOrgs: APIOrg[] = [] 26 | ) { 27 | const CONCEPT_PATTERN = 28 | "/:ownerType/:owner/(collections/:collectionId)(sources/:sourceId)/concepts/:conceptId/"; 29 | 30 | const matches = getParams(CONCEPT_PATTERN, conceptUrl); 31 | if (!matches) return false; 32 | 33 | if (matches.ownerType === USER_TYPE) 34 | return profile?.username === matches.owner; 35 | else return usersOrgs.map(org => org.id).includes(matches.owner); 36 | } 37 | -------------------------------------------------------------------------------- /src/apps/sources/__test__/pages/ViewOrgSourcesPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | mapDispatchToProps, 3 | mapStateToProps 4 | } from "../../pages/ViewOrgSourcesPage"; 5 | import { retrieveOrgSourcesAction } from "../../redux"; 6 | import { currentState, orgSources, testSource } from "../test_data"; 7 | 8 | const appState = currentState(orgSources); 9 | describe("ViewOrgSourcesPage", () => { 10 | it("should list down all the props of the state", () => { 11 | expect(mapStateToProps(appState).loading).not.toBeNull(); 12 | expect(mapStateToProps(appState).sources).not.toBeNull(); 13 | expect(mapStateToProps(appState).meta).not.toBeNull(); 14 | }); 15 | 16 | it("should update the loading status with current state", () => { 17 | expect(mapStateToProps(appState).loading).toEqual(false); 18 | }); 19 | 20 | it("should assign existing sources to the sources prop", () => { 21 | expect(mapStateToProps(appState).sources).toEqual([testSource]); 22 | }); 23 | 24 | it("should update the response meta data from the current state", () => { 25 | expect(mapStateToProps(appState).meta).toEqual({ key: false }); 26 | }); 27 | 28 | it("should point to correct dispatch action", () => { 29 | expect(mapDispatchToProps.retrieveSources).toBe(retrieveOrgSourcesAction); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /cypress/support/step_definitions/organisation/addMember/addMember.ts: -------------------------------------------------------------------------------- 1 | import { Given, Then, When } from "cypress-cucumber-preprocessor/steps"; 2 | import { getOrganisationId } from "../../../utils"; 3 | 4 | Given("the user is on the organisation detail page", () => { 5 | cy.visit(`/orgs/${getOrganisationId()}/`); 6 | cy.findByText("Members").should("be.visible"); 7 | cy.contains("ul", "ocladmin"); 8 | }); 9 | When("the user clicks on the add new member button", () => 10 | cy.findByRole("button", { name: /Add New Member/i }).click() 11 | ); 12 | Then("the user should be on the add new member dialog box", () => 13 | cy.waitUntil(() => cy.findByText("Add new member", { timeout: 10000 })) 14 | ); 15 | 16 | Given("the user is on the add new member dialog box", () => { 17 | cy.visit(`/orgs/${getOrganisationId()}/`); 18 | cy.findByRole("button", { name: /Add New Member/i }).click(); 19 | cy.waitUntil(() => cy.findByText("Add new member", { timeout: 10000 })); 20 | }); 21 | When("the user enters the member information", () => { 22 | cy.findByLabelText("Username").type("openmrs"); 23 | }); 24 | When("the user submits the form", () => { 25 | cy.findByRole("button", { name: /Submit/i }).click(); 26 | }); 27 | Then("the new member should be added", () => { 28 | cy.contains("ul", "openmrs"); 29 | }); 30 | -------------------------------------------------------------------------------- /src/apps/sources/__test__/pages/ViewPublicSourcesPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | mapDispatchToProps, 3 | mapStateToProps 4 | } from "../../pages/ViewPublicSourcesPage"; 5 | import { retrievePublicSourcesAction } from "../../redux"; 6 | import { currentState, publicSources, testSource } from "../test_data"; 7 | 8 | const appState = currentState(publicSources); 9 | describe("ViewPublicSourcesPage", () => { 10 | it("should list down all the props of the state", () => { 11 | expect(mapStateToProps(appState).loading).not.toBeNull(); 12 | expect(mapStateToProps(appState).sources).not.toBeNull(); 13 | expect(mapStateToProps(appState).meta).not.toBeNull(); 14 | }); 15 | 16 | it("should update the loading status with current state", () => { 17 | expect(mapStateToProps(appState).loading).toEqual(false); 18 | }); 19 | 20 | it("should assign existing sources to the sources prop", () => { 21 | expect(mapStateToProps(appState).sources).toEqual([testSource]); 22 | }); 23 | 24 | it("should update the response meta data from the current state", () => { 25 | expect(mapStateToProps(appState).meta).toEqual({ key: false }); 26 | }); 27 | 28 | it("should point to correct dispatch action", () => { 29 | expect(mapDispatchToProps.retrieveSources).toBe( 30 | retrievePublicSourcesAction 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/apps/organisations/components/MenuButton.tsx: -------------------------------------------------------------------------------- 1 | import { Fab, Menu, MenuItem, Tooltip } from "@mui/material"; 2 | import { 3 | MoreVert as MenuIcon, 4 | DeleteSweepOutlined as DeleteIcon 5 | } from "@mui/icons-material"; 6 | import { Link } from "react-router-dom"; 7 | import React from "react"; 8 | import { useAnchor } from "../../../utils"; 9 | 10 | interface Props { 11 | backUrl: string; 12 | confirmDelete?: () => void; 13 | } 14 | 15 | export const MenuButton: React.FC = ({ 16 | backUrl, 17 | confirmDelete 18 | }: Props) => { 19 | const [menuAnchor, handleMenuClick, handleMenuClose] = useAnchor(); 20 | 21 | return ( 22 | <> 23 | 24 | 25 | 26 | 27 | 28 | 34 | 35 | 36 | Discard changes and view 37 | 38 | 39 | 40 | 41 | Delete Organisation 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/apps/authentication/tests/api.test.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedInstance, unAuthenticatedInstance } from "../../../api"; 2 | import api from "../api"; 3 | 4 | jest.mock("../../../api", () => ({ 5 | authenticatedInstance: { 6 | put: jest.fn(), 7 | get: jest.fn(() => ({ 8 | userOrgs: [{ id: "OCL", name: "Open Concept Lab", url: "/orgs/OCL/" }] 9 | })), 10 | post: jest.fn() 11 | }, 12 | unAuthenticatedInstance: { 13 | get: jest.fn(() => ({ userOrgs: [] })) 14 | } 15 | })); 16 | 17 | describe("api", () => { 18 | it("should make authenticated get call with given url and params", async () => { 19 | const url: string = "/users/root/orgs/?limit=0"; 20 | const username: string = "root"; 21 | const response = api.getUserOrgs(username); 22 | 23 | expect(authenticatedInstance.get).toHaveBeenCalledWith(url); 24 | expect(response).toStrictEqual({ 25 | userOrgs: [{ id: "OCL", name: "Open Concept Lab", url: "/orgs/OCL/" }] 26 | }); 27 | }); 28 | 29 | it("should make unauthenticated get call with given url and params", async () => { 30 | const url: string = "/users/root/orgs/?limit=0"; 31 | const response = unAuthenticatedInstance.get(url); 32 | 33 | expect(unAuthenticatedInstance.get).toHaveBeenCalledWith(url); 34 | expect(response).toStrictEqual({ userOrgs: [] }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/apps/sources/__test__/pages/ViewPersonalSourcesPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | mapDispatchToProps, 3 | mapStateToProps 4 | } from "../../pages/ViewPersonalSourcesPage"; 5 | import { retrievePersonalSourcesAction } from "../../redux"; 6 | import { currentState, personalSources, testSource } from "../test_data"; 7 | 8 | const appState = currentState(personalSources); 9 | describe("ViewPersonalSourcesPage", () => { 10 | it("should list down all the props of the state", () => { 11 | expect(mapStateToProps(appState).loading).not.toBeNull(); 12 | expect(mapStateToProps(appState).sources).not.toBeNull(); 13 | expect(mapStateToProps(appState).meta).not.toBeNull(); 14 | }); 15 | 16 | it("should update the loading status with current state", () => { 17 | expect(mapStateToProps(appState).loading).toEqual(false); 18 | }); 19 | 20 | it("should assign existing sources to the sources prop", () => { 21 | expect(mapStateToProps(appState).sources).toEqual([testSource]); 22 | }); 23 | 24 | it("should update the response meta data from the current state", () => { 25 | expect(mapStateToProps(appState).meta).toEqual({ key: false }); 26 | }); 27 | 28 | it("should point to correct dispatch action", () => { 29 | expect(mapDispatchToProps.retrieveSources).toBe( 30 | retrievePersonalSourcesAction 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/apps/sources/components/ViewSources.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { APISource } from "../types"; 3 | import { 4 | ContainerCards, 5 | ContainerPagination, 6 | ContainerSearch 7 | } from "../../containers/components"; 8 | 9 | interface Props { 10 | sources: APISource[]; 11 | numFound: number; 12 | onPageChange: Function; 13 | onSearch: Function; 14 | page: number; 15 | perPage: number; 16 | initialQ: string; 17 | title: string; 18 | showOnlyVerified: boolean; 19 | toggleShowVerified: () => void; 20 | } 21 | 22 | const ViewSources: React.FC = ({ 23 | sources, 24 | numFound, 25 | onPageChange, 26 | onSearch, 27 | page, 28 | perPage, 29 | initialQ, 30 | title, 31 | toggleShowVerified, 32 | showOnlyVerified 33 | }) => { 34 | return ( 35 | <> 36 | 43 | 44 | 50 | 51 | ); 52 | }; 53 | 54 | export default ViewSources; 55 | -------------------------------------------------------------------------------- /src/apps/dictionaries/components/ViewDictionaries.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { APIDictionary } from "../types"; 3 | import { 4 | ContainerCards, 5 | ContainerPagination, 6 | ContainerSearch 7 | } from "../../containers/components"; 8 | 9 | interface Props { 10 | dictionaries: APIDictionary[]; 11 | numFound: number; 12 | onPageChange: Function; 13 | onSearch: Function; 14 | page: number; 15 | perPage: number; 16 | initialQ: string; 17 | title: string; 18 | showOnlyVerified: boolean; 19 | toggleShowVerified: () => void; 20 | } 21 | const ViewDictionaries: React.FC = ({ 22 | dictionaries, 23 | numFound, 24 | onPageChange, 25 | onSearch, 26 | page, 27 | perPage, 28 | initialQ, 29 | title, 30 | showOnlyVerified, 31 | toggleShowVerified 32 | }) => { 33 | return ( 34 | <> 35 | 42 | 43 | 49 | 50 | ); 51 | }; 52 | 53 | export default ViewDictionaries; 54 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosTransformer } from "axios"; 2 | import { BASE_URL } from "./utils"; 3 | import { 4 | addAuthToken, 5 | redirectIfNotLoggedIn 6 | } from "./apps/authentication/utils"; // failed to respect module here because of a circular import issue 7 | 8 | axios.defaults.headers.common["Content-Type"] = "application/json"; 9 | 10 | const defaultRequestTransformers = (): AxiosTransformer[] => { 11 | const { transformRequest } = axios.defaults; 12 | if (!transformRequest) { 13 | return []; 14 | } else if (transformRequest instanceof Array) { 15 | return transformRequest; 16 | } else { 17 | return [transformRequest]; 18 | } 19 | }; 20 | 21 | const defaultResponseTransformers = (): AxiosTransformer[] => { 22 | const { transformResponse } = axios.defaults; 23 | if (!transformResponse) { 24 | return []; 25 | } else if (transformResponse instanceof Array) { 26 | return transformResponse; 27 | } else { 28 | return [transformResponse]; 29 | } 30 | }; 31 | 32 | const authenticatedInstance = axios.create({ 33 | baseURL: BASE_URL, 34 | transformRequest: [...defaultRequestTransformers(), addAuthToken], 35 | transformResponse: [...defaultResponseTransformers(), redirectIfNotLoggedIn] 36 | }); 37 | 38 | const unAuthenticatedInstance = axios.create({ 39 | baseURL: BASE_URL 40 | }); 41 | 42 | export { authenticatedInstance, unAuthenticatedInstance }; 43 | -------------------------------------------------------------------------------- /src/apps/containers/components/ContainerOwnerTabs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Tab, Tabs } from "@mui/material"; 3 | import { Link } from "react-router-dom"; 4 | import { TabType } from "../types"; 5 | import { makeStyles } from "@mui/styles"; 6 | 7 | interface Props { 8 | currentPageUrl: string; 9 | tabList: TabType[]; 10 | } 11 | 12 | const useStyles = makeStyles({ 13 | fullWidth: { 14 | width: "100%", 15 | color: "black" 16 | } 17 | }); 18 | 19 | const ContainerOwnerTabs: React.FC = ({ currentPageUrl, tabList }) => { 20 | const classes = useStyles(); 21 | 22 | return ( 23 | 31 | {tabList.map(({ labelName, labelURL }, index) => ( 32 | 39 | ))} 40 | ; 41 | 42 | ); 43 | }; 44 | 45 | interface LinkTabProps { 46 | label: string; 47 | value: string; 48 | to: string; 49 | } 50 | 51 | function LinkTab(props: LinkTabProps) { 52 | return ; 53 | } 54 | 55 | export default ContainerOwnerTabs; 56 | -------------------------------------------------------------------------------- /src/apps/organisations/components/ViewOrgs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BaseAPIOrganisation } from "../types"; 3 | import { 4 | ContainerSearch, 5 | ContainerPagination 6 | } from "../../containers/components"; 7 | import OrganisationCards from "./OrgCards"; 8 | interface Props { 9 | organisations: BaseAPIOrganisation[]; 10 | title: string; 11 | onSearch: Function; 12 | initialQ: string; 13 | page: number; 14 | perPage: number; 15 | numFound: number; 16 | onPageChange: Function; 17 | showOnlyVerified: boolean; 18 | toggleShowVerified: () => void; 19 | } 20 | 21 | const ViewOrganisations: React.FC = ({ 22 | organisations, 23 | title, 24 | onSearch, 25 | initialQ, 26 | page, 27 | perPage, 28 | numFound, 29 | onPageChange, 30 | showOnlyVerified, 31 | toggleShowVerified 32 | }) => { 33 | return ( 34 | <> 35 | 42 | 43 | 49 | 50 | ); 51 | }; 52 | 53 | export default ViewOrganisations; 54 | -------------------------------------------------------------------------------- /src/apps/authentication/components/UserTokenDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Paper, Tooltip, Typography } from "@mui/material"; 3 | import CopyToClipboard from "react-copy-to-clipboard"; 4 | import { makeStyles } from "@mui/styles"; 5 | 6 | interface Props { 7 | token?: string; 8 | } 9 | 10 | const useStyles = makeStyles({ 11 | token: { 12 | wordBreak: "break-all", 13 | color: "black", 14 | textAlign: "center", 15 | filter: "blur(5px)", 16 | "&:hover": { 17 | filter: "none" 18 | } 19 | }, 20 | container: { 21 | minWidth: "0" 22 | } 23 | }); 24 | 25 | const UserTokenDetails: React.FC = ({ token }) => { 26 | const classes = useStyles(); 27 | 28 | return ( 29 | 30 |
31 | 32 | API Token 33 | 34 | 35 | {token} 36 | 37 | 38 | 39 | 42 | 43 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default UserTokenDetails; 50 | -------------------------------------------------------------------------------- /cypress/support/step_definitions/organisation/edit/edit.ts: -------------------------------------------------------------------------------- 1 | import { Given, Then, When } from "cypress-cucumber-preprocessor/steps"; 2 | import { getOrganisationId } from "../../../utils"; 3 | 4 | Given(/a (public|private) organization exists/, type => { 5 | const organisationId = getOrganisationId(); 6 | cy.createOrganisation(organisationId, type === "public"); 7 | }); 8 | 9 | Given("the user is on the edit organization page", () => 10 | cy.visit(`/orgs/${getOrganisationId()}/edit/`) 11 | ); 12 | 13 | When(/the user selects "(.+)" Public Access/, public_access => { 14 | switch (public_access) { 15 | case "View": 16 | cy.get("#public_access").type("{uparrow}{uparrow}{enter}"); 17 | break; 18 | case "None": 19 | cy.get("#public_access").type("{downarrow}{downarrow}{enter}"); 20 | break; 21 | default: 22 | throw `Unknown Public Access ${public_access}. I only know how to handle "View" and "None".`; 23 | } 24 | }); 25 | 26 | When("the user submits the form", () => { 27 | cy.get("form").submit(); 28 | cy.url().should("not.contain", `/edit/`); 29 | }); 30 | 31 | Then("the organization should not be publicly visible", () => 32 | cy 33 | .getOrganisation(getOrganisationId()) 34 | .its("public_access") 35 | .should("eq", "None") 36 | ); 37 | 38 | Then("the organization should be publicly visible", () => 39 | cy 40 | .getOrganisation(getOrganisationId()) 41 | .its("public_access") 42 | .should("eq", "View") 43 | ); 44 | -------------------------------------------------------------------------------- /src/apps/containers/components/ContainerPagination.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Grid, Theme, TablePagination } from "@mui/material"; 3 | import { createStyles, makeStyles } from "@mui/styles"; 4 | 5 | interface Props { 6 | num_found: number; 7 | per_page: number; 8 | page: number; 9 | onPageChange: Function; 10 | } 11 | 12 | const useStyles = makeStyles((theme: Theme) => 13 | createStyles({ 14 | pagination: { 15 | justifyItems: "center", 16 | display: "grid", 17 | position: "fixed", 18 | bottom: 0, 19 | background: theme.palette.background.default, 20 | width: "100%" 21 | } 22 | }) 23 | ); 24 | const ContainerPagination: React.FC = ({ 25 | num_found, 26 | per_page, 27 | page, 28 | onPageChange 29 | }) => { 30 | const classes = useStyles(); 31 | 32 | return ( 33 | 34 | onPageChange(page + 1)} 47 | data-testid="pagination" 48 | /> 49 | 50 | ); 51 | }; 52 | 53 | export default ContainerPagination; 54 | -------------------------------------------------------------------------------- /src/utils/components/ConfirmationDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Button, 4 | ButtonGroup, 5 | Dialog, 6 | DialogActions, 7 | DialogTitle 8 | } from "@mui/material"; 9 | 10 | interface Props { 11 | open: boolean; 12 | setOpen: Function; 13 | onConfirm: Function; 14 | message: JSX.Element | string; 15 | cancelButtonText: string; 16 | confirmButtonText: string; 17 | } 18 | 19 | const ConfirmationDialog: React.FC = ({ 20 | open, 21 | setOpen, 22 | onConfirm, 23 | message, 24 | cancelButtonText, 25 | confirmButtonText 26 | }) => { 27 | return ( 28 | setOpen(false)} 34 | > 35 | 39 | {message} 40 | 41 | 42 | 43 | 46 | 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default ConfirmationDialog; 56 | -------------------------------------------------------------------------------- /cypress/integration/organisation/details.feature: -------------------------------------------------------------------------------- 1 | Feature: Organisation details Page 2 | Background: 3 | Given the user is logged in 4 | 5 | @organisation 6 | Scenario: The user should see all details of the organisation 7 | Given an organization exists 8 | And the user is on the organisation details page 9 | Then the organisation name should be displayed 10 | And the organisation sources should be displayed 11 | And the organisation members should be displayed 12 | And the organisation dictionaries should be displayed 13 | 14 | @organisation 15 | Scenario: The user should see organisation sources 16 | Given an organization exists 17 | And a source exists in the organisation 18 | When the user is on the organisation details page 19 | Then the user should see the organisation source 20 | When the user clicks on the source 21 | Then the user should be on the org source page 22 | 23 | @organisation 24 | Scenario: The user should see organisation dictionary 25 | Given an organization exists 26 | And a dictionary exists in the organisation 27 | When the user is on the organisation details page 28 | Then the user should see the organisation dictionary 29 | When the user clicks on the dictionary 30 | Then the user should be on the org dictionary page 31 | -------------------------------------------------------------------------------- /src/apps/dictionaries/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Switch, useRouteMatch } from "react-router-dom"; 3 | import { 4 | EditDictionaryPage, 5 | ViewDictionaryPage, 6 | ViewPersonalDictionariesPage 7 | } from "./pages"; 8 | import AddBulkConceptsPage from "./pages/AddBulkConceptsPage"; 9 | 10 | interface Props { 11 | viewDictionaries?: boolean; 12 | viewDictionary?: boolean; 13 | editDictionary?: boolean; 14 | concepts: boolean; 15 | } 16 | 17 | const Routes: React.FC = ({ 18 | viewDictionaries = true, 19 | viewDictionary = true, 20 | editDictionary = true, 21 | concepts = true 22 | }) => { 23 | let { path } = useRouteMatch(); 24 | 25 | return ( 26 | 27 | {!viewDictionaries ? null : ( 28 | // see to do at the top of ViewPersonalDictionariesPage 29 | 30 | 31 | 32 | )} 33 | {!viewDictionary ? null : ( 34 | 35 | 36 | 37 | )} 38 | {!editDictionary ? null : ( 39 | 40 | 41 | 42 | )} 43 | {!concepts ? null : ( 44 | <> 45 | 46 | 47 | 48 | 49 | )} 50 | 51 | ); 52 | }; 53 | 54 | export default Routes; 55 | -------------------------------------------------------------------------------- /src/apps/dictionaries/utils.ts: -------------------------------------------------------------------------------- 1 | export const buildAddConceptToDictionaryMessage = ( 2 | results: { expression: string; added: boolean }[] 3 | ) => { 4 | if (!Array.isArray(results)) { 5 | return ""; 6 | } 7 | 8 | const conceptResults = results.filter(result => 9 | result.expression.includes("/concepts/") 10 | ); 11 | 12 | const addedCount = conceptResults.filter(result => result.added).length; 13 | const alreadyInDictionaryCount = conceptResults.length - addedCount; 14 | 15 | const wasOrWere = (length: number) => (length > 1 ? "s were" : " was"); 16 | 17 | const addedConceptsMessage = 18 | addedCount > 0 19 | ? ` ${addedCount} concept${wasOrWere(addedCount)} added.` 20 | : ""; 21 | const alreadyAddedMessage = 22 | alreadyInDictionaryCount > 0 23 | ? ` ${alreadyInDictionaryCount} concept${wasOrWere( 24 | alreadyInDictionaryCount 25 | )} skipped` 26 | : ""; 27 | 28 | return addedConceptsMessage + alreadyAddedMessage; 29 | }; 30 | 31 | export const dictionaryNameFromUrl = (url: string): string => { 32 | let words = url.split("/"); 33 | return words[words.length - 2]; 34 | }; 35 | 36 | export const getDictionaryTypeFromPreviousPath = (previousPath: String) => { 37 | switch (previousPath) { 38 | case "/collections/": 39 | return "Public Dictionaries"; 40 | case "/user/collections/": 41 | return "Your Dictionaries"; 42 | case "/user/orgs/collections/": 43 | return "Your Organisations' Dictionaries"; 44 | default: 45 | return "Dictionaries"; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /cypress/support/step_definitions/dictionary/copyDictionary/copyDictionary.ts: -------------------------------------------------------------------------------- 1 | import "cypress-wait-until"; 2 | import { Given, Then, When } from "cypress-cucumber-preprocessor/steps"; 3 | import { getDictionaryId, getUser } from "../../../utils"; 4 | 5 | Given("the user is on the dictionary page", () => { 6 | cy.visit(`/users/${getUser()}/collections/${getDictionaryId()}/`); 7 | cy.findByText("Versions").should("be.visible"); 8 | }); 9 | When("the user clicks the more actions button", () => 10 | cy.findByTestId("more-actions").click() 11 | ); 12 | When(/the user selects the "(.+)" menu list item/, menuItem => 13 | cy.findByText(menuItem).click() 14 | ); 15 | When("the user is on copy dictionary form", () => 16 | cy.url().should("contain", `/collections/new/`) 17 | ); 18 | When("the user enters the new dictionary information", () => { 19 | cy.findByLabelText(/Dictionary Name/i).type(`${getDictionaryId()}-new`); 20 | cy.findByLabelText(/Short Code/i).type(`${getDictionaryId()}-new`); 21 | }); 22 | When("the user submits the form", () => { 23 | cy.get("form").submit(); 24 | cy.waitUntil( 25 | () => 26 | cy 27 | .url() 28 | .should( 29 | "contain", 30 | `/users/${getUser()}/collections/${getDictionaryId()}-new/` 31 | ), 32 | { timeout: 10000 } 33 | ); 34 | }); 35 | Then("the new dictionary should be created", () => 36 | cy.getDictionary(`${getDictionaryId()}-new`).should("exist") 37 | ); 38 | Then("the new source should be created", () => 39 | cy.getSource(`${getDictionaryId()}-new`).should("exist") 40 | ); 41 | -------------------------------------------------------------------------------- /src/utils/components/ToastAlert.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Snackbar, SnackbarCloseReason, Alert, Theme } from "@mui/material"; 3 | import { makeStyles } from "@mui/styles"; 4 | 5 | interface Props { 6 | message?: string; 7 | type: string; 8 | open: boolean; 9 | setOpen: Function; 10 | } 11 | 12 | const useStyles = makeStyles((theme: Theme) => ({ 13 | root: { 14 | width: "100%", 15 | "& > * + *": { 16 | marginTop: theme.spacing(1) 17 | } 18 | } 19 | })); 20 | 21 | const ToastAlert = ({ message = "", type, open, setOpen }: Props) => { 22 | const handleClose = (_: any, reason: SnackbarCloseReason) => { 23 | if (reason === "clickaway") { 24 | return; 25 | } 26 | setOpen(false); 27 | }; 28 | 29 | const classes = useStyles(); 30 | return message !== "" ? ( 31 |
32 | {type === "error" ? ( 33 | 39 | {message} 40 | 41 | ) : ( 42 | 48 | {message} 49 | 50 | )} 51 |
52 | ) : null; 53 | }; 54 | 55 | export default ToastAlert; 56 | -------------------------------------------------------------------------------- /src/apps/authentication/__test__/components/UserOrganisationDetails.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "../../../../test-utils.test"; 3 | import { UserOrganisationDetails } from "../../components"; 4 | import { testAPIOrgList } from "../test_data"; 5 | 6 | type userOrganisationDetailsProps = React.ComponentProps< 7 | typeof UserOrganisationDetails 8 | >; 9 | 10 | const baseProps: userOrganisationDetailsProps = { 11 | orgs: testAPIOrgList 12 | }; 13 | 14 | function renderUI(props: Partial = {}) { 15 | return render(); 16 | } 17 | 18 | describe("UserOrganisationDetails", () => { 19 | it("should match snapshot", () => { 20 | const { container } = renderUI(baseProps); 21 | 22 | expect(container).toMatchSnapshot(); 23 | }); 24 | }); 25 | 26 | describe("View User Organisations", () => { 27 | it("should not show no user organisations message when user is part of at least one organisation", () => { 28 | const { queryByTestId } = renderUI(baseProps); 29 | 30 | expect(queryByTestId("noUserOrgsMessage")).toBeNull(); 31 | }); 32 | 33 | it("should show no user organisations message when user is not part of at least one organisation", () => { 34 | const { queryByTestId } = renderUI({ 35 | orgs: [] 36 | }); 37 | const noUserOrgsMessage = queryByTestId("noUserOrgsMessage") || { 38 | textContent: null 39 | }; 40 | 41 | expect(noUserOrgsMessage.textContent).toBe( 42 | "You're not part of any Organisation" 43 | ); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/apps/concepts/__test__/components/ConceptForm.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { v4 as uuid } from "uuid"; 3 | import { render } from "../../../../test-utils.test"; 4 | import { ConceptForm } from "../../components"; 5 | import { Concept } from "../../types"; 6 | 7 | type conceptFormProps = React.ComponentProps; 8 | 9 | const initialValues: Concept = { 10 | concept_class: "", 11 | datatype: "Coded", 12 | descriptions: [], 13 | external_id: uuid(), 14 | id: "", 15 | answers: [], 16 | sets: [], 17 | mappings: [], 18 | names: [], 19 | retired: false, 20 | extras: {} 21 | }; 22 | 23 | const baseProps: conceptFormProps = { 24 | onSubmit: () => {}, 25 | loading: true, 26 | status: "", 27 | errors: {}, 28 | savedValues: initialValues, 29 | context: "view", 30 | allMappingErrors: [], 31 | conceptClass: "Diagnosis", 32 | supportLegacyMappings: true, 33 | defaultLocale: "", 34 | supportedLocales: [] 35 | }; 36 | 37 | function renderUI(props: Partial = {}) { 38 | return render(); 39 | } 40 | 41 | describe("ConceptForm ", () => { 42 | let queryByTestId: Function; 43 | beforeEach(() => { 44 | const queries = renderUI(baseProps); 45 | queryByTestId = queries.queryByTestId; 46 | }); 47 | 48 | it("should show set members sections", () => { 49 | expect(queryByTestId("set-members")).not.toBeNull(); 50 | }); 51 | 52 | it("should show answers sections if datatype is coded", () => { 53 | expect(queryByTestId("answers")).not.toBeNull(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /cypress/integration/dictionary/createVersion.feature: -------------------------------------------------------------------------------- 1 | Feature: Creating a dictionary version, releasing it and copy subscription URL 2 | Background: 3 | Given the user is logged in 4 | 5 | @dictionary 6 | Scenario: The user should be able to click the button to create a new version 7 | Given a dictionary exists 8 | And the user is on the dictionary page 9 | When the user clicks the create new version button 10 | Then the user should be on the create new version dialog box 11 | 12 | @dictionary 13 | @version 14 | Scenario: The user should be able to create a new version 15 | Given a dictionary exists 16 | And the user clicks on the create new version dialog box 17 | When the user enters the version information 18 | And the user submits the form 19 | Then the new version should be created 20 | 21 | @dictionary 22 | @version 23 | Scenario: The user should be able to release a version 24 | Given a dictionary exists 25 | And a version exists 26 | And the user is on the dictionary page 27 | When the user clicks release status switch 28 | And the release dialog opens 29 | And the user clicks yes button 30 | Then the version should be released 31 | 32 | @dictionary 33 | @version 34 | @released 35 | Scenario: The user should be able to copy subscription URL 36 | Given a dictionary exists 37 | And a version exists 38 | And a version is released 39 | And the user is on the dictionary page 40 | When the user clicks the more actions button 41 | And the user selects the "Copy Subscription URL" menu list item 42 | Then the subscription url should be copied -------------------------------------------------------------------------------- /src/apps/concepts/__test__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { canModifyConcept } from "../utils"; 2 | 3 | describe("canModifyConcept", () => { 4 | const profile: any = { 5 | username: "Foo" 6 | }; 7 | 8 | const usersOrgs: any = { 9 | id: "Foo" 10 | }; 11 | 12 | it("should return false if CONCEPT_PATTERN doesn`t match conceptUrl", () => { 13 | const conceptUrl = "/orgs/Foo/sources/Foo/"; 14 | expect(canModifyConcept(conceptUrl, profile, [usersOrgs])).toBe(false); 15 | }); 16 | 17 | it("should return true if owner type is org, CONCEPT_PATTERN matches conceptUrl and profile username matches owner name", () => { 18 | const conceptUrl = "/orgs/Foo/sources/Foo/concepts/Foo/"; 19 | expect(canModifyConcept(conceptUrl, profile, [usersOrgs])).toBe(true); 20 | }); 21 | 22 | it("should return false if owner type is org, CONCEPT_PATTERN matches conceptUrl and profile username doesn`t matches owner name", () => { 23 | const conceptUrl = "/orgs/FooFa/sources/Foo/concepts/Foo/"; 24 | expect(canModifyConcept(conceptUrl, profile, [usersOrgs])).toBe(false); 25 | }); 26 | 27 | it("should return true if owner type is user, CONCEPT_PATTERN matches conceptUrl and profile username matches owner name", () => { 28 | const conceptUrl = "/users/Foo/sources/Foo/concepts/Foo/"; 29 | expect(canModifyConcept(conceptUrl, profile, [usersOrgs])).toBe(true); 30 | }); 31 | 32 | it("should return false if owner type is user, CONCEPT_PATTERN matches conceptUrl and profile username doesn`t matches owner name", () => { 33 | const conceptUrl = "/users/FooFa/sources/Foo/concepts/Foo/"; 34 | expect(canModifyConcept(conceptUrl, profile, [usersOrgs])).toBe(false); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/apps/containers/components/FormUtils.tsx: -------------------------------------------------------------------------------- 1 | import { ListSubheader, MenuItem } from "@mui/material"; 2 | import React from "react"; 3 | import { APIOrg, APIProfile } from "../../authentication"; 4 | import { BaseConceptContainer, LOCALES } from "../../../utils"; 5 | 6 | export const showUserName = (profile: APIProfile | undefined) => { 7 | return profile ? ( 8 | {profile.username}(You) 9 | ) : null; 10 | }; 11 | 12 | export const showOrganisationHeader = (userOrgs: APIOrg[]) => { 13 | return userOrgs.length > 0 ? ( 14 | Your Organizations 15 | ) : null; 16 | }; 17 | 18 | export const showUserOrganisations = (userOrgs: APIOrg[]) => { 19 | return userOrgs.length > 0 20 | ? userOrgs.map(org => ( 21 | 22 | {org.name} 23 | 24 | )) 25 | : null; 26 | }; 27 | 28 | function pushLocale(labels: Array, value: string, label: string) { 29 | return labels.push( 30 | 31 | {label} 32 | 33 | ); 34 | } 35 | 36 | export const supportedLocalesLabel = ( 37 | values: BaseConceptContainer | { default_locale: string } 38 | ) => { 39 | const labels: Array = []; 40 | LOCALES.filter( 41 | ({ value }) => value !== values.default_locale 42 | ).map(({ value, label }) => pushLocale(labels, value, label)); 43 | return labels; 44 | }; 45 | 46 | export function showDefaultLocale() { 47 | const labels: Array = []; 48 | LOCALES.map(({ value, label }) => pushLocale(labels, value, label)); 49 | return labels; 50 | } 51 | -------------------------------------------------------------------------------- /src/apps/authentication/redux/reducer.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction } from "redux"; 2 | import { errorSelector, loadingSelector } from "../../../redux"; 3 | import { 4 | CLEAR_NEXT_PAGE_ACTION, 5 | GET_PROFILE_ACTION, 6 | GET_USER_DETAILS_ACTION, 7 | GET_USER_ORGS_ACTION, 8 | LOGIN_ACTION, 9 | LOGOUT_ACTION, 10 | SET_NEXT_PAGE_ACTION 11 | } from "./actionTypes"; 12 | import { AuthState } from "../types"; 13 | 14 | const initialState: AuthState = { 15 | isLoggedIn: false 16 | }; 17 | 18 | const reducer = (state = initialState, action: AnyAction) => { 19 | switch (action.type) { 20 | case LOGIN_ACTION: 21 | return { ...state, isLoggedIn: true, token: action.payload.token }; 22 | case LOGOUT_ACTION: 23 | return { ...initialState }; 24 | case GET_PROFILE_ACTION: 25 | return { ...state, profile: action.payload }; 26 | case GET_USER_ORGS_ACTION: 27 | return { ...state, orgs: action.payload }; 28 | default: 29 | return state; 30 | case SET_NEXT_PAGE_ACTION: 31 | return { ...state, nextPage: action.payload }; 32 | case CLEAR_NEXT_PAGE_ACTION: 33 | return { ...state, nextPage: undefined }; 34 | } 35 | }; 36 | 37 | const authLoadingSelector = loadingSelector(LOGIN_ACTION); 38 | const authErrorsSelector = errorSelector(LOGIN_ACTION); 39 | 40 | const getUserDetailsLoadingSelector = loadingSelector(GET_USER_DETAILS_ACTION); 41 | const profileSelector = ({ auth }: { auth: AuthState }) => auth.profile; 42 | const orgsSelector = ({ auth }: { auth: AuthState }) => auth.orgs; 43 | 44 | export { 45 | reducer as default, 46 | authLoadingSelector, 47 | authErrorsSelector, 48 | getUserDetailsLoadingSelector, 49 | profileSelector, 50 | orgsSelector 51 | }; 52 | -------------------------------------------------------------------------------- /src/apps/containers/components/ContainerCards.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Grid, Typography, Box } from "@mui/material"; 3 | import ContainerCard from "./ContainerCard"; 4 | 5 | export interface Card { 6 | name: string; 7 | short_code: string; 8 | owner: string; 9 | owner_type: string; 10 | description: string; 11 | url: string; 12 | } 13 | 14 | interface Props { 15 | cards: Card[]; 16 | title: string; 17 | } 18 | 19 | const ContainerCards: React.FC = ({ cards, title }) => { 20 | return ( 21 | 22 | 30 | {cards.length === 0 ? ( 31 | 32 | No {title} 33 | 34 | ) : ( 35 | "" 36 | )} 37 | {cards.map( 38 | ( 39 | { 40 | name, 41 | short_code: shortCode, 42 | owner, 43 | owner_type: ownerType, 44 | description, 45 | url 46 | }, 47 | index 48 | ) => ( 49 | 59 | ) 60 | )} 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default ContainerCards; 67 | -------------------------------------------------------------------------------- /src/redux/__test__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { errorMsgResponse } from "../utils"; 2 | 3 | const errorMsgs: { [key: string]: string } = { 4 | OPENMRS_ONE_FULLY_SPECIFIED_NAME_PER_LOCALE: 5 | "A concept may not have more than one fully specified name in any locale", 6 | OPENMRS_NO_MORE_THAN_ONE_SHORT_NAME_PER_LOCALE: 7 | "A concept cannot have more than one short name in a locale", 8 | OPENMRS_NAMES_EXCEPT_SHORT_MUST_BE_UNIQUE: 9 | "All names except short names must be unique for a concept and locale", 10 | OPENMRS_FULLY_SPECIFIED_NAME_UNIQUE_PER_SOURCE_LOCALE: 11 | "Concept fully specified name must be unique for same source and locale", 12 | OPENMRS_MUST_HAVE_EXACTLY_ONE_PREFERRED_NAME: 13 | "A concept may not have more than one preferred name (per locale)", 14 | OPENMRS_SHORT_NAME_CANNOT_BE_PREFERRED: 15 | "A short name cannot be marked as locale preferred", 16 | OPENMRS_AT_LEAST_ONE_FULLY_SPECIFIED_NAME: 17 | "A concept must have at least one fully specified name", 18 | OPENMRS_PREFERRED_NAME_UNIQUE_PER_SOURCE_LOCALE: 19 | "Concept preferred name must be unique for same source and locale" 20 | }; 21 | 22 | describe("errorResponseMsg", () => { 23 | for (let error in errorMsgs) { 24 | const response = { 25 | data: { 26 | name: [errorMsgs[error]] 27 | } 28 | }; 29 | it(`should return appropriate error msg for ${error} scenario `, () => { 30 | expect(errorMsgResponse(response)).toEqual(errorMsgs[error]); 31 | }); 32 | } 33 | 34 | it("should return generics error msg if no error response from server", () => { 35 | const response = {}; 36 | expect(errorMsgResponse(response)).toEqual( 37 | "Action could not be completed. Please retry." 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /cypress/integration/dictionary/create.feature: -------------------------------------------------------------------------------- 1 | Feature: Creating a dictionary 2 | Background: 3 | Given the user is logged in 4 | 5 | Scenario: The user should be able to click the button to create a new dictionary 6 | Given the user is on the dictionaries page 7 | When the user clicks on the create new dictionary button 8 | Then the user should be on the create new dictionary page 9 | 10 | @dictionary 11 | Scenario: The user should be able to create a new dictionary 12 | Given the user is on the create new dictionary page 13 | When the user enters the dictionary information 14 | And the user submits the form 15 | Then the new dictionary should be created 16 | And the new source should be created 17 | 18 | @dictionary 19 | Scenario: The user should be able to create a public dictionary 20 | Given the user is on the create new dictionary page 21 | When the user enters the dictionary information 22 | And the user selects "Public" visibility 23 | And the user submits the form 24 | Then the new dictionary should be created 25 | And the dictionary should be publicly visible 26 | And the new source should be created 27 | And the source should be publicly visible 28 | 29 | @dictionary 30 | Scenario: The user should be able to create a private dictionary 31 | Given the user is on the create new dictionary page 32 | When the user enters the dictionary information 33 | And the user selects "Private" visibility 34 | And the user submits the form 35 | Then the new dictionary should be created 36 | And the dictionary should not be publicly visible 37 | And the new source should be created 38 | And the source should not be publicly visible 39 | -------------------------------------------------------------------------------- /cypress/support/step_definitions/concept/create/create.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import { Given, Then, When } from "cypress-cucumber-preprocessor/steps"; 4 | import { getDictionaryId, getUser, getConceptId } from "../../../utils"; 5 | 6 | Given("the user is on the dictionary concepts page", () => { 7 | cy.visit(`/users/${getUser()}/collections/${getDictionaryId()}/concepts/`); 8 | // delay until the dictionary is fully loaded 9 | cy.findByTestId("addConceptsIcon").should('be.visible'); 10 | }); 11 | 12 | When("the user clicks the add concepts button", () => { 13 | cy.findByTestId("addConceptsIcon").click(); 14 | }); 15 | 16 | When(/the user selects the "(.+)" menu list item/, menuItem => 17 | cy.findByText(menuItem).click() 18 | ); 19 | 20 | Then("the user should be on the create concept page", () => 21 | cy.url().should("contain", `/concepts/new/`) 22 | ); 23 | 24 | Given("the user is on the create concept page", () => { 25 | cy.visit( 26 | `/users/${getUser()}/sources/${getDictionaryId()}/concepts/new/?linkedDictionary=/users/${getUser()}/collections/${getDictionaryId()}/` 27 | ); 28 | cy.findByText("Create concept").should("be.visible"); 29 | }); 30 | 31 | When("the user enters the concept information", () => { 32 | cy.findByLabelText("OCL ID").type(getConceptId()); 33 | cy.get("#concept_class").type("{enter}"); 34 | cy.get("#datatype").type("{enter}"); 35 | cy.get("input[name='names[0].name']").type("test concept"); 36 | }); 37 | 38 | When("the user submits the form", () => { 39 | cy.get("form").submit(); 40 | }); 41 | 42 | Then("the new concept should be created", () => { 43 | cy.url().should("not.contain", `/new`); 44 | cy.findByText("test concept").should("be.visible"); 45 | }); 46 | -------------------------------------------------------------------------------- /src/apps/concepts/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Switch, useRouteMatch } from "react-router-dom"; 3 | import { 4 | CreateOrEditConceptPage, 5 | ViewConceptPage, 6 | ViewConceptsPage 7 | } from "./pages"; 8 | 9 | interface Props { 10 | viewConcepts?: boolean; 11 | newConcept?: boolean; 12 | viewConcept?: boolean; 13 | editConcept?: boolean; 14 | viewDictConcepts?: boolean; 15 | containerType: string; 16 | } 17 | 18 | const Routes: React.FC = ({ 19 | containerType, 20 | viewConcepts = false, 21 | newConcept = false, 22 | viewConcept = false, 23 | editConcept = false, 24 | viewDictConcepts = false 25 | }) => { 26 | let { path } = useRouteMatch(); 27 | 28 | return ( 29 | 30 | {!viewConcepts ? null : ( 31 | 32 | 37 | 38 | )} 39 | {!newConcept ? null : ( 40 | 41 | 42 | 43 | )} 44 | {!viewConcept ? null : ( 45 | 46 | 47 | 48 | )} 49 | {!viewConcept ? null : ( 50 | 51 | 52 | 53 | )} 54 | {!editConcept ? null : ( 55 | 56 | 57 | 58 | )} 59 | 60 | ); 61 | }; 62 | 63 | export default Routes; 64 | -------------------------------------------------------------------------------- /cypress/support/utils.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "redux"; 2 | import { AppState } from "../../src/redux/types"; 3 | 4 | export const getStore = (): Cypress.Chainable> => 5 | cy.window().its("store"); 6 | export const isLoggedIn = (): Cypress.Chainable => 7 | getStore() 8 | .invoke("getState") 9 | .its("auth") 10 | .its("isLoggedIn") || cy.wrap(false); 11 | export const currentUser = (): Cypress.Chainable => 12 | getStore() 13 | .invoke("getState") 14 | .its("auth") 15 | .its("profile") 16 | .its("username") || cy.wrap("admin"); 17 | export const getAuthToken = () => 18 | getStore() 19 | .invoke("getState") 20 | .its("auth") 21 | .its("token") 22 | .then(token => `Token ${token}`); 23 | export const getUser = () => Cypress.env("USERNAME") || "ocladmin"; 24 | export const getPassword = () => Cypress.env("PASSWORD") || "Root123"; 25 | export const getDictionaryId = () => Cypress.env("dictionaryId"); 26 | export const setDictionaryId = (dictionaryId: string) => 27 | Cypress.env("dictionaryId", dictionaryId); 28 | export const getConceptId = () => Cypress.env("conceptId"); 29 | export const setConceptId = (conceptId: string) => 30 | Cypress.env("conceptId", conceptId); 31 | export const getOrganisationId = () => Cypress.env("organisationId"); 32 | export const setOrganisationId = (organisationId: string) => 33 | Cypress.env("organisationId", organisationId); 34 | export const getVersionId = () => Cypress.env("versionId"); 35 | export const setVersionId = (versionId: string) => 36 | Cypress.env("versionId", versionId); 37 | export const getNewUser = () => Cypress.env("newUser"); 38 | export const setNewUser = (newUser: string) => Cypress.env("newUser", newUser); 39 | export const getConceptVersionUrl=()=> Cypress.env("url"); 40 | export const setConceptVersionUrl=(url: string) => Cypress.env("url", url); 41 | -------------------------------------------------------------------------------- /src/apps/concepts/__test__/redux/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { reducer } from "../../redux/reducer"; 2 | import { APIConcept, ConceptsState } from "../../types"; 3 | import { RETRIEVE_ACTIVE_CONCEPTS_ACTION } from "../../redux/actionTypes"; 4 | 5 | const testConcept: APIConcept = { 6 | display_name: "testConcept", 7 | url: "url", 8 | version_url: "versionUrl", 9 | mappings: [], 10 | retired: false, 11 | source: "source", 12 | source_url: "sourceUrl", 13 | id: "testId", 14 | external_id: "extId", 15 | concept_class: "", 16 | datatype: "TEXT", 17 | names: [], 18 | descriptions: [], 19 | extras: {} 20 | }; 21 | 22 | const initialState: ConceptsState = { 23 | mappings: [], 24 | concepts: { 25 | items: [], 26 | responseMeta: undefined 27 | }, 28 | activeConcepts: { 29 | items: [], 30 | responseMeta: undefined 31 | } 32 | }; 33 | 34 | describe("RETRIEVE_ACTIVE_CONCEPTS_ACTION", () => { 35 | it("should return initial state when empty payload is passed", () => { 36 | const startAction = { 37 | type: RETRIEVE_ACTIVE_CONCEPTS_ACTION, 38 | action: { 39 | payload: [], 40 | responseMeta: undefined 41 | } 42 | }; 43 | expect(reducer(initialState, startAction)).toEqual(initialState); 44 | }); 45 | 46 | it("should return updated state with given payload", () => { 47 | const startAction = { 48 | type: RETRIEVE_ACTIVE_CONCEPTS_ACTION, 49 | payload: [testConcept], 50 | responseMeta: { num_found: 1 } 51 | }; 52 | 53 | const expectedState = { 54 | mappings: [], 55 | concepts: { 56 | items: [], 57 | responseMeta: undefined 58 | }, 59 | activeConcepts: { 60 | items: [testConcept], 61 | responseMeta: { num_found: 1 } 62 | } 63 | }; 64 | 65 | expect(reducer(initialState, startAction)).toEqual(expectedState); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/apps/concepts/redux/selectors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AppState, 3 | errorListSelector, 4 | loadingSelector, 5 | progressSelector 6 | } from "../../../redux"; 7 | import { 8 | RETRIEVE_ACTIVE_CONCEPTS_ACTION, 9 | RETRIEVE_CONCEPT_ACTION, 10 | RETRIEVE_CONCEPTS_ACTION, 11 | UPSERT_CONCEPT_ACTION, 12 | UPSERT_CONCEPT_AND_MAPPINGS, 13 | UPSERT_MAPPING_ACTION 14 | } from "./actionTypes"; 15 | import { errorSelector } from "../../../redux/selectors"; 16 | import { removeReferencesFromDictionaryLoadingSelector } from "../../dictionaries/redux"; 17 | 18 | export const upsertConceptAndMappingsLoadingSelector = loadingSelector( 19 | UPSERT_CONCEPT_AND_MAPPINGS 20 | ); 21 | export const upsertConceptAndMappingsProgressSelector = progressSelector( 22 | UPSERT_CONCEPT_AND_MAPPINGS 23 | ); 24 | export const upsertConceptErrorsSelector = errorSelector(UPSERT_CONCEPT_ACTION); 25 | export const viewConceptLoadingSelector = loadingSelector( 26 | RETRIEVE_CONCEPT_ACTION 27 | ); 28 | export const viewConceptErrorsSelector = errorSelector(RETRIEVE_CONCEPT_ACTION); 29 | export const viewConceptsLoadingSelector = loadingSelector( 30 | RETRIEVE_CONCEPTS_ACTION 31 | ); 32 | export const viewConceptsErrorsSelector = errorSelector( 33 | RETRIEVE_CONCEPTS_ACTION 34 | ); 35 | export const viewActiveConceptsLoadingSelector = loadingSelector( 36 | RETRIEVE_ACTIVE_CONCEPTS_ACTION 37 | ); 38 | export const viewActiveConceptsErrorsSelector = errorSelector( 39 | RETRIEVE_ACTIVE_CONCEPTS_ACTION 40 | ); 41 | export const upsertAllMappingsErrorSelector = errorListSelector( 42 | UPSERT_MAPPING_ACTION 43 | ); 44 | export function removeConceptsFromDictionaryLoadingSelector(state: AppState) { 45 | const removeReferencesStatus = removeReferencesFromDictionaryLoadingSelector( 46 | state 47 | ); 48 | if (!removeReferencesStatus) return false; 49 | 50 | return (removeReferencesStatus as boolean[]).includes(true); 51 | } 52 | -------------------------------------------------------------------------------- /src/apps/authentication/redux/actions.ts: -------------------------------------------------------------------------------- 1 | import { completeAction, createActionThunk, startAction } from "../../../redux"; 2 | import { getIndexedAction } from "../../../redux/utils"; 3 | import api from "../api"; 4 | import { 5 | GET_PROFILE_ACTION, 6 | GET_USER_DETAILS_ACTION, 7 | GET_USER_ORGS_ACTION, 8 | LOGIN_ACTION, 9 | LOGOUT_ACTION, 10 | SET_NEXT_PAGE_ACTION, 11 | CLEAR_NEXT_PAGE_ACTION 12 | } from "./actionTypes"; 13 | 14 | const loginAction = createActionThunk(LOGIN_ACTION, api.login); 15 | const getProfileAction = createActionThunk(GET_PROFILE_ACTION, api.getProfile); 16 | const getUserOrgsAction = createActionThunk( 17 | GET_USER_ORGS_ACTION, 18 | api.getUserOrgs 19 | ); 20 | const getUserDetailsAction = () => { 21 | return async (dispatch: Function) => { 22 | dispatch(startAction(GET_USER_DETAILS_ACTION)); 23 | 24 | let [userProfile] = await Promise.all([dispatch(getProfileAction())]); 25 | 26 | let [userOrgs] = await Promise.all([ 27 | dispatch(getUserOrgsAction(userProfile.username)) 28 | ]); 29 | 30 | if (!(userProfile || userOrgs)) dispatch({ type: LOGOUT_ACTION }); 31 | 32 | dispatch(completeAction(GET_USER_DETAILS_ACTION)); 33 | }; 34 | }; 35 | 36 | const setNextPageAction = (nextPage: string) => { 37 | return async (dispatch: Function) => { 38 | const action = getIndexedAction(SET_NEXT_PAGE_ACTION); 39 | dispatch({ 40 | type: action.actionType, 41 | index: action.actionIndex, 42 | payload: nextPage 43 | }); 44 | }; 45 | }; 46 | 47 | const clearNextPageAction = () => { 48 | return (dispatch: Function) => { 49 | const { actionType, actionIndex } = getIndexedAction( 50 | CLEAR_NEXT_PAGE_ACTION 51 | ); 52 | dispatch({ type: actionType, index: actionIndex }); 53 | }; 54 | }; 55 | 56 | export { 57 | loginAction, 58 | getUserDetailsAction, 59 | getProfileAction, 60 | setNextPageAction, 61 | clearNextPageAction 62 | }; 63 | -------------------------------------------------------------------------------- /src/apps/sources/__test__/api.test.ts: -------------------------------------------------------------------------------- 1 | import api from "../api"; 2 | import { authenticatedInstance, unAuthenticatedInstance } from "../../../api"; 3 | 4 | jest.mock("../../../api", () => ({ 5 | authenticatedInstance: { 6 | put: jest.fn(), 7 | get: jest.fn(() => ({ source: [] })), 8 | post: jest.fn() 9 | }, 10 | unAuthenticatedInstance: { 11 | get: jest.fn(() => ({ source: [] })) 12 | } 13 | })); 14 | 15 | describe("api", () => { 16 | it("should make aunthenticated get call with given url and params", async () => { 17 | const url: string = "/user/sources/"; 18 | const q: string = ""; 19 | const limit: number = 20; 20 | const page: number = 1; 21 | const verbose: boolean = true; 22 | const response = await api.sources.retrieve.private( 23 | url, 24 | q, 25 | limit, 26 | page, 27 | verbose 28 | ); 29 | 30 | expect(authenticatedInstance.get).toHaveBeenCalledWith(url, { 31 | params: { 32 | limit: 20, 33 | page: 1, 34 | q: "", 35 | verbose: true, 36 | timestamp: expect.anything() 37 | } 38 | }); 39 | expect(response).toStrictEqual({ source: [] }); 40 | }); 41 | 42 | it("should make unauthenticated get call with given url and params", async () => { 43 | const url: string = "/sources/"; 44 | const q: string = ""; 45 | const limit: number = 20; 46 | const page: number = 1; 47 | const verbose: boolean = true; 48 | const response = await api.sources.retrieve.public( 49 | url, 50 | q, 51 | limit, 52 | page, 53 | verbose 54 | ); 55 | 56 | expect(unAuthenticatedInstance.get).toHaveBeenCalledWith(url, { 57 | params: { 58 | limit: 20, 59 | page: 1, 60 | q: "", 61 | verbose: true, 62 | timestamp: expect.anything() 63 | } 64 | }); 65 | expect(response).toStrictEqual({ source: [] }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/apps/organisations/redux/selectors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | errorSelector, 3 | loadingSelector, 4 | progressSelector, 5 | indexedAction 6 | } from "../../../redux"; 7 | import { 8 | CREATE_ORGANISATION_ACTION, 9 | EDIT_ORGANISATION_ACTION, 10 | GET_ORG_ACTION, 11 | GET_USER_ORGS_ACTION, 12 | RETRIEVE_ORGS_ACTION, 13 | DELETE_ORGANISATION_ACTION, 14 | CREATE_ORG_MEMBER_ACTION, 15 | DELETE_ORG_MEMBER_ACTION 16 | } from "./actionTypes"; 17 | import { PERSONAL_ORGS_ACTION_INDEX } from "../redux/constants"; 18 | 19 | export const createOrganisationLoadingSelector = loadingSelector( 20 | CREATE_ORGANISATION_ACTION 21 | ); 22 | export const createOrganisationProgressSelector = progressSelector( 23 | CREATE_ORGANISATION_ACTION 24 | ); 25 | export const createOrganisationErrorSelector = errorSelector( 26 | CREATE_ORGANISATION_ACTION 27 | ); 28 | 29 | export const deleteOrganisationErrorSelector = errorSelector( 30 | DELETE_ORGANISATION_ACTION 31 | ); 32 | 33 | export const editOrganisationErrorSelector = errorSelector( 34 | EDIT_ORGANISATION_ACTION 35 | ); 36 | export const editOrganisationLoadingSelector = loadingSelector( 37 | EDIT_ORGANISATION_ACTION 38 | ); 39 | 40 | export const retrieveOrgsLoadingSelector = loadingSelector( 41 | GET_USER_ORGS_ACTION 42 | ); 43 | 44 | export const retrieveOrgLoadingSelector = loadingSelector(GET_ORG_ACTION); 45 | 46 | export const retrievePublicOrganisationsLoadingSelector = loadingSelector( 47 | RETRIEVE_ORGS_ACTION 48 | ); 49 | export const retrievePersonalOrganisationsLoadingSelector = loadingSelector( 50 | indexedAction(RETRIEVE_ORGS_ACTION, PERSONAL_ORGS_ACTION_INDEX) 51 | ); 52 | 53 | export const addOrgMemberErrorSelector = errorSelector( 54 | CREATE_ORG_MEMBER_ACTION 55 | ); 56 | 57 | export const addOrgMemberLoadingSelector = loadingSelector( 58 | CREATE_ORG_MEMBER_ACTION 59 | ); 60 | export const deleteOrgMemberErrorSelector = errorSelector( 61 | DELETE_ORG_MEMBER_ACTION 62 | ); 63 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # Stage-1 Build process 4 | # Use the official node:14 runtime image for the build environment and tag the build as build-deps 5 | FROM node:14-alpine as build-deps 6 | 7 | # Create a working directory for the build project 8 | RUN mkdir -p /usr/src/app 9 | 10 | # Navigate to the created directory 11 | WORKDIR /usr/src/app 12 | 13 | # Create an enviroment variable for the node_modules 14 | ENV PATH /usr/src/app/node_modules/.bin:$PATH 15 | 16 | # Copy the code to the docker image 17 | ADD . /usr/src/app/ 18 | 19 | # Set environment to production 20 | ENV NODE_ENV production 21 | 22 | # Install the project dependencies 23 | RUN npm ci 24 | 25 | # Create an optimized build version of the project 26 | RUN npm run build 27 | 28 | # Stage-2 Production Environment 29 | # Use the nginx 1.21-alpine runtime image for the production environment 30 | FROM nginx:1.21-alpine 31 | 32 | # Make port 80 available to the world outside the container 33 | EXPOSE 80 34 | 35 | # Copy the CI build number 36 | ARG OCL_BUILD 37 | ENV OCL_BUILD=$OCL_BUILD 38 | 39 | # Clear the default nginx folder 40 | RUN rm -rf /usr/share/nginx/html 41 | RUN mkdir -p /usr/share/nginx/html 42 | 43 | # Copy the tagged files from the build to the production environmnet of the nginx server 44 | COPY --from=build-deps /usr/src/app/build /usr/share/nginx/html 45 | 46 | # Copy nginx configuration 47 | COPY --from=build-deps /usr/src/app/docker/default.conf /etc/nginx/conf.d/ 48 | 49 | # Copy shell script to container 50 | COPY --from=build-deps /usr/src/app/docker/startup.sh . 51 | 52 | # Make our shell script executable 53 | RUN chmod +x startup.sh 54 | 55 | # Set the environment variables actually used in the image 56 | ENV OCL_API_HOST https://api.staging.openconceptlab.org/ 57 | ENV TRADITIONAL_OCL_HOST https://app.staging.openconceptlab.org/ 58 | ENV OCL_SIGNUP_URL https://app.staging.openconceptlab.org/#/accounts/signup 59 | 60 | # Start the server 61 | CMD sh startup.sh 62 | -------------------------------------------------------------------------------- /cypress/support/step_definitions/dictionary/edit/edit.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import { 4 | Given, 5 | Then, 6 | When 7 | } from "cypress-cucumber-preprocessor/steps"; 8 | import { getDictionaryId, getUser } from "../../../utils"; 9 | 10 | Given(/a (public|private) dictionary exists/, type => { 11 | const dictionaryId = getDictionaryId(); 12 | const user = getUser(); 13 | cy.createDictionary(dictionaryId, user, type === "public"); 14 | cy.createSource(dictionaryId, user, type === "public"); 15 | }); 16 | 17 | Given("the user is on the edit dictionary page", () => 18 | cy.visit(`/users/${getUser()}/collections/${getDictionaryId()}/edit/`) 19 | ); 20 | 21 | When(/the user selects "(.+)" visibility/, public_access => { 22 | switch (public_access) { 23 | case "Public": 24 | cy.get("#public_access").type("{downarrow}{uparrow}{enter}"); 25 | break; 26 | case "Private": 27 | cy.get("#public_access").type("{downarrow}{downarrow}{enter}"); 28 | break; 29 | default: 30 | throw `Unknown visibility type ${public_access}. I only know how to handle "Public" or "Private".`; 31 | } 32 | }); 33 | 34 | When("the user submits the form", () => { 35 | cy.get("form").submit(); 36 | cy.url().should("not.contain", `/edit/`); 37 | }); 38 | 39 | Then("the dictionary should be publicly visible", () => 40 | cy 41 | .getDictionary(getDictionaryId()) 42 | .its("public_access") 43 | .should("eq", "View") 44 | ); 45 | 46 | Then("the dictionary should not be publicly visible", () => 47 | cy 48 | .getDictionary(getDictionaryId()) 49 | .its("public_access") 50 | .should("eq", "None") 51 | ); 52 | 53 | Then("the source should be publicly visible", () => 54 | cy 55 | .getSource(getDictionaryId()) 56 | .its("public_access") 57 | .should("eq", "View") 58 | ); 59 | 60 | Then("the source should not be publicly visible", () => 61 | cy 62 | .getSource(getDictionaryId()) 63 | .its("public_access") 64 | .should("eq", "None") 65 | ); 66 | -------------------------------------------------------------------------------- /src/apps/authentication/components/UserOrganisationDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Paper, Typography } from "@mui/material"; 3 | import { makeStyles } from "@mui/styles"; 4 | import { APIOrg } from "../types"; 5 | import List from "@mui/material/List"; 6 | import { Link } from "react-router-dom"; 7 | interface Props { 8 | orgs?: APIOrg[]; 9 | } 10 | 11 | const useStyles = makeStyles({ 12 | container: { 13 | minWidth: "0" 14 | }, 15 | orgList: { 16 | maxHeight: 280, 17 | overflow: "scroll", 18 | color: "black" 19 | }, 20 | orgItem: { 21 | paddingBottom: "12px" 22 | } 23 | }); 24 | 25 | const UserOrganisationDetails: React.FC = ({ orgs }) => { 26 | const classes = useStyles(); 27 | 28 | const getUserOrganisationsList = () => { 29 | const OrganisationsList: Array = orgs 30 | ? [...orgs] 31 | .sort((a, b) => 32 | a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()) 33 | ) 34 | .map(org => ( 35 |
  • 36 | {org.name} 37 |
  • 38 | )) 39 | : []; 40 | return OrganisationsList; 41 | }; 42 | 43 | const getUserOrgsEmptyMessage = () => { 44 | if (orgs && orgs.length === 0) { 45 | return ( 46 | 47 | You're not part of any Organisation 48 | 49 | ); 50 | } 51 | return null; 52 | }; 53 | 54 | return ( 55 | 56 |
    57 | 58 | Your Organisations 59 | 60 | {getUserOrgsEmptyMessage()} 61 | 62 |
      {getUserOrganisationsList()}
    63 |
    64 |
    65 |
    66 | ); 67 | }; 68 | 69 | export default UserOrganisationDetails; 70 | -------------------------------------------------------------------------------- /src/apps/sources/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseConceptContainer, 3 | EditableConceptContainerFields, 4 | Version 5 | } from "../../utils"; 6 | 7 | interface BaseSource extends BaseConceptContainer { 8 | extras?: { source?: string }; 9 | } 10 | 11 | export interface Source extends BaseSource { 12 | supported_locales: string[]; 13 | website?: string; 14 | source_type: string; 15 | owner_url?: string; 16 | owner?: string; 17 | } 18 | interface BaseAPISource extends BaseConceptContainer { 19 | external_id: string; 20 | id: string; 21 | full_name: string; 22 | website?: string; 23 | custom_validation_schema: string; 24 | } 25 | 26 | export interface NewAPISource extends BaseAPISource { 27 | // api expects a comma separated string for this in create/ edit data 28 | supported_locales: string; 29 | owner_url: string; 30 | source_type?: string; 31 | } 32 | 33 | export interface APISource extends BaseAPISource { 34 | source_type: string; 35 | url: string; 36 | active_concepts: number; 37 | concepts_url: string; 38 | extras?: {}; 39 | supported_locales: string[]; 40 | owner: string; 41 | owner_type: string; 42 | owner_url: string; 43 | } 44 | 45 | export interface SourceVersion extends Version {} 46 | 47 | export interface SourceState { 48 | sources: { items: APISource[]; responseMeta?: {} }[]; 49 | source?: APISource; 50 | newSource?: APISource; 51 | versions: APISourceVersion[]; 52 | showOnlyVerified: boolean; 53 | } 54 | export interface APISourceVersion extends SourceVersion { 55 | version_url: string; 56 | url: string; 57 | } 58 | 59 | export interface EditableSourceFields extends EditableConceptContainerFields { 60 | public_access?: string; 61 | source_type?: string; 62 | } 63 | 64 | const apiSourceToSource = (apiSource?: APISource): Source | undefined => { 65 | if (!apiSource) return apiSource; 66 | 67 | const { url, supported_locales, ...theRest } = apiSource; 68 | 69 | return { 70 | supported_locales: supported_locales || [], 71 | ...theRest 72 | }; 73 | }; 74 | 75 | export { apiSourceToSource }; 76 | -------------------------------------------------------------------------------- /src/apps/dictionaries/components/DictionaryConceptsSummary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { APIDictionary } from "../../dictionaries"; 3 | import { Button, ButtonGroup, Paper, Typography } from "@mui/material"; 4 | import { Link } from "react-router-dom"; 5 | import { makeStyles } from "@mui/styles"; 6 | 7 | interface Props { 8 | dictionary: APIDictionary; 9 | } 10 | 11 | const useStyles = makeStyles({ 12 | conceptCountBreakDown: { 13 | marginLeft: "3vw" 14 | } 15 | }); 16 | 17 | const DictionaryConceptsSummary: React.FC = ({ dictionary }) => { 18 | const classes = useStyles(); 19 | 20 | const { 21 | concepts_url: conceptsUrl, 22 | preferred_source: preferredSource, 23 | concept_counts: { 24 | total: totalConceptCount = 0, 25 | from_preferred_source: preferredSourceConceptCount = 0, 26 | custom: customConceptCount = 0 27 | } = {}, 28 | } = dictionary; 29 | 30 | return ( 31 | 32 |
    33 | 34 | Concepts(HEAD Version) 35 | 36 | 37 | Total Concepts: {totalConceptCount} 38 | 39 | 45 | 46 | From {preferredSource}: {preferredSourceConceptCount} 47 | 48 |
    49 | 50 | Custom Concepts: {customConceptCount} 51 | 52 |
    53 | 54 | 57 | 58 |
    59 |
    60 | ); 61 | }; 62 | 63 | export default DictionaryConceptsSummary; 64 | -------------------------------------------------------------------------------- /src/apps/authentication/__test__/test_data.ts: -------------------------------------------------------------------------------- 1 | import { APIOrg, APIProfile, AuthState } from "../types"; 2 | import { AppState, StatusState } from "../../../redux"; 3 | import { DictionaryState } from "../../dictionaries"; 4 | import { ConceptsState } from "../../concepts"; 5 | import { SourceState } from "../../sources"; 6 | import { APIOrganisation, OrganisationState } from "../../organisations"; 7 | 8 | export const testProfile: APIProfile = { 9 | username: "TestUser", 10 | name: "TestUser", 11 | email: "TestUser@test.com", 12 | company: "Test Company", 13 | location: "Test Location", 14 | created_on: "1900-01-01T00:00:00.000" 15 | }; 16 | 17 | export const testToken = "TestToken"; 18 | 19 | export const testAPIOrgList: APIOrg[] = [ 20 | { 21 | id: "test1", 22 | name: "Test Organisation", 23 | url: "" 24 | }, 25 | { 26 | id: "test2", 27 | name: "Another Organisation", 28 | url: "" 29 | } 30 | ]; 31 | 32 | const authState: AuthState = { 33 | isLoggedIn: true, 34 | orgs: testAPIOrgList, 35 | profile: testProfile, 36 | token: testToken 37 | }; 38 | 39 | const statusState: StatusState = { key: [] }; 40 | 41 | const dictionariesState: DictionaryState = { 42 | dictionaries: [{ items: [] }], 43 | versions: [], 44 | addReferencesResults: [], 45 | showOnlyVerified: false 46 | }; 47 | 48 | const conceptState: ConceptsState = { 49 | concepts: { items: [] }, 50 | activeConcepts: { items: [] }, 51 | mappings: [] 52 | }; 53 | 54 | export const sourceState: SourceState = { 55 | sources: [{ items: [] }], 56 | versions: [], 57 | showOnlyVerified: false 58 | }; 59 | 60 | export const organisationState: OrganisationState = { 61 | organisation: {} as APIOrganisation, 62 | organisations: [], 63 | showAddMemberDialog: false, 64 | showDeleteMemberDialog: undefined, 65 | showOnlyVerified: false 66 | }; 67 | 68 | export const initialState: AppState = { 69 | auth: authState, 70 | status: statusState, 71 | dictionaries: dictionariesState, 72 | concepts: conceptState, 73 | sources: sourceState, 74 | organisations: organisationState 75 | }; 76 | -------------------------------------------------------------------------------- /src/apps/authentication/pages/ViewUserProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Grid, Paper, Typography } from "@mui/material"; 3 | import { connect } from "react-redux"; 4 | import { 5 | APIOrg, 6 | APIProfile, 7 | getUserDetailsAction, 8 | profileSelector 9 | } from "../../authentication"; 10 | import { AppState } from "../../../redux"; 11 | import Header from "../../../components/Header"; 12 | import { orgsSelector } from "../redux/reducer"; 13 | import { 14 | UserForm, 15 | UserOrganisationDetails, 16 | UserTokenDetails 17 | } from "../components"; 18 | 19 | interface Props { 20 | userProfile?: APIProfile; 21 | userOrganisations?: APIOrg[]; 22 | userToken?: string; 23 | } 24 | 25 | export const ViewUserProfilePage: React.FC = ({ 26 | userProfile, 27 | userOrganisations, 28 | userToken 29 | }: Props) => { 30 | return ( 31 |
    32 | 33 | 34 |
    35 | 36 | Details 37 | 38 | 39 |
    40 |
    41 |
    42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
    51 | ); 52 | }; 53 | 54 | export const mapStateToProps = (state: AppState) => ({ 55 | userProfile: profileSelector(state), 56 | userOrganisations: orgsSelector(state), 57 | userToken: state.auth.token 58 | }); 59 | export const mapDispatchToProps = { 60 | retrieveUserDetails: getUserDetailsAction 61 | }; 62 | 63 | export default connect( 64 | mapStateToProps, 65 | mapDispatchToProps 66 | )(ViewUserProfilePage); 67 | -------------------------------------------------------------------------------- /src/apps/dictionaries/pages/tests/e2e/ViewDictionaryPage.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { login, logout } from "../../../../authentication/tests/e2e/testUtils"; 4 | import { createDictionary, TestDictionary } from "./testUtils"; 5 | 6 | describe("View Dictionary", () => { 7 | let dictionary: TestDictionary, dictionaryUrl: string; 8 | 9 | beforeEach(() => { 10 | login(); 11 | [dictionary, dictionaryUrl] = createDictionary(); 12 | }); 13 | 14 | afterEach(() => { 15 | logout(); 16 | }); 17 | 18 | it("Happy flow: Should allow a user to view a dictionary", () => { 19 | cy.visit(dictionaryUrl); 20 | 21 | cy.findByText("General Details").should("exist"); 22 | cy.findByDisplayValue(dictionary.name) 23 | .should("exist") 24 | .should("be.disabled"); 25 | cy.findByDisplayValue(dictionary.shortCode) 26 | .should("exist") 27 | .should("be.disabled"); 28 | cy.findByDisplayValue(dictionary.description) 29 | .should("exist") 30 | .should("be.disabled"); 31 | // todo add disabled checks for the selects 32 | cy.findByDisplayValue(dictionary.preferredSource).should("exist"); 33 | // cy.findByDisplayValue(dictionary.ownerDisplayValue).should('exist'); 34 | // cy.findByDisplayValue(dictionary.visibility).should('exist'); 35 | // cy.findByDisplayValue(dictionary.preferredLanguage).should('exist'); 36 | cy.queryByText("Submit").should("not.exist"); 37 | 38 | cy.findByText("Concepts(HEAD Version)").should("exist"); 39 | cy.findByText("Total Concepts: 0").should("exist"); 40 | cy.get('[data-testid="preferredConceptCount"]').should( 41 | "have.text", 42 | `From ${dictionary.preferredSource}: 0` 43 | ); 44 | cy.get('[data-testid="customConceptCount"]').should( 45 | "have.text", 46 | "Custom Concepts: 0" 47 | ); 48 | cy.findByText("View Concepts").should("exist"); 49 | 50 | cy.findByText("Versions").should("exist"); 51 | cy.findByText("No versions created").should("exist"); 52 | cy.findByText("Create new version").should("exist"); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/apps/sources/redux/reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer } from "@reduxjs/toolkit"; 2 | import { Action } from "../../../redux"; 3 | import { SourceState } from "../types"; 4 | import { 5 | CREATE_SOURCE_ACTION, 6 | EDIT_SOURCE_ACTION, 7 | RETRIEVE_SOURCE_ACTION, 8 | RETRIEVE_SOURCES_ACTION, 9 | RETRIEVE_SOURCE_VERSIONS_ACTION, 10 | EDIT_SOURCE_VERSION_ACTION, 11 | CREATE_SOURCE_VERSION_ACTION, 12 | TOGGLE_SHOW_VERIFIED_ACTION 13 | } from "./actionTypes"; 14 | import { LOGOUT_ACTION } from "../../authentication/redux/actionTypes"; 15 | 16 | const initialState: SourceState = { 17 | sources: [], 18 | versions: [], 19 | showOnlyVerified: false 20 | }; 21 | 22 | export const reducer = createReducer(initialState, { 23 | [TOGGLE_SHOW_VERIFIED_ACTION]: (state, action) => ({ 24 | ...state, 25 | showOnlyVerified: !state.showOnlyVerified 26 | }), 27 | [CREATE_SOURCE_ACTION]: (state, action) => ({ 28 | ...state, 29 | newSource: action.payload 30 | }), 31 | [RETRIEVE_SOURCES_ACTION]: ( 32 | state, 33 | { actionIndex, payload, responseMeta }: Action 34 | ) => { 35 | state.sources[actionIndex] = { items: payload, responseMeta }; 36 | }, 37 | [RETRIEVE_SOURCE_ACTION]: (state, action) => ({ 38 | ...state, 39 | source: action.payload 40 | }), 41 | [EDIT_SOURCE_ACTION]: (state, action) => ({ 42 | ...state, 43 | editedSource: action.payload 44 | }), 45 | [RETRIEVE_SOURCE_VERSIONS_ACTION]: (state, action) => ({ 46 | ...state, 47 | versions: action.payload 48 | }), 49 | [EDIT_SOURCE_VERSION_ACTION]: (state, { actionIndex, payload }) => { 50 | const versionIndex = state.versions.findIndex( 51 | version => version.id === payload.id 52 | ); 53 | if (versionIndex !== -1) state.versions[versionIndex] = payload; 54 | else state.versions.push(payload); 55 | }, 56 | [CREATE_SOURCE_VERSION_ACTION]: (state, { actionIndex, payload, meta }) => { 57 | state.versions = [payload, ...state.versions]; 58 | }, 59 | [LOGOUT_ACTION]: () => { 60 | return initialState; 61 | } 62 | }); 63 | export { reducer as default }; 64 | -------------------------------------------------------------------------------- /src/apps/dictionaries/pages/tests/e2e/EditDictionaryPage.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { login, logout } from "../../../../authentication/tests/e2e/testUtils"; 4 | import { createDictionary, TestDictionary } from "./testUtils"; 5 | 6 | export interface UpdatedDictionary { 7 | name: string; 8 | description: string; 9 | preferredSource: string; 10 | visibility: string; 11 | preferredLanguage: string; 12 | otherLanguages: string[]; 13 | } 14 | 15 | function updatedDictionary(): UpdatedDictionary { 16 | return { 17 | name: " Updated", 18 | description: " updated", 19 | preferredSource: "PIH", 20 | visibility: "Private", 21 | preferredLanguage: "Avaric (av)", 22 | otherLanguages: ["Ewe (ee)"] 23 | }; 24 | } 25 | 26 | function select(labelText: string, item: string) { 27 | cy.findByLabelText(labelText).click(); 28 | cy.findByText(item).click(); 29 | } 30 | 31 | describe("Edit Dictionary", () => { 32 | let savedDictionary: TestDictionary, dictionaryUrl: string; 33 | 34 | beforeEach(() => { 35 | login(); 36 | [savedDictionary, dictionaryUrl] = createDictionary(); 37 | }); 38 | 39 | afterEach(() => { 40 | logout(); 41 | }); 42 | 43 | it("Happy flow: Should edit a dictionary", () => { 44 | // todo improve this test to check actual values 45 | cy.visit(dictionaryUrl); 46 | cy.findByTitle("Edit this Dictionary").click(); 47 | 48 | const dictionary = updatedDictionary(); 49 | 50 | cy.findByLabelText("Dictionary Name").type(dictionary.name); 51 | cy.findByLabelText("Description").type(dictionary.description); 52 | select("Preferred Source", dictionary.preferredSource); 53 | select("Visibility", dictionary.visibility); 54 | select("Preferred Language", dictionary.preferredLanguage); 55 | dictionary.otherLanguages.forEach(language => { 56 | select("Other Languages", language); 57 | cy.get("body").type("{esc}"); 58 | }); 59 | 60 | cy.findByText("Submit").click(); 61 | // redirects to view page 62 | 63 | cy.findByText("General Details").should("exist"); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | //This file contains project wide scss. Seriously? Project wide? You ask. This project uses css in js, so on most occasions, if you find yourself looking at this file, someone (prolly me) did something wrong 2 | //Why is it here then? Sometimes, rarely though, we need project wide overrides that are difficult to achieve in js or we want to target weird generated classes that we can't appropriately target is js. Kapish?' 3 | 4 | body, 5 | * { 6 | margin: 0; 7 | font-family: "Nunito", sans-serif !important; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 14 | monospace; 15 | } 16 | 17 | .fieldsetParent { 18 | padding: 4vh 2vw; 19 | 20 | fieldset { 21 | border: 0.5px solid black; 22 | } 23 | } 24 | 25 | .fab { 26 | margin: 0; 27 | top: auto; 28 | right: 20px; 29 | bottom: 20px; 30 | left: auto; 31 | position: fixed !important; 32 | } 33 | 34 | .buttonLink { 35 | text-decoration: none; 36 | color: inherit; 37 | } 38 | 39 | .retired { 40 | text-decoration-line: line-through; 41 | } 42 | 43 | #viewConceptPage { 44 | #conceptForm { 45 | .MuiInput-formControl { 46 | .MuiInput-input.Mui-disabled { 47 | color: black; 48 | } 49 | 50 | svg { 51 | visibility: hidden; 52 | } 53 | } 54 | } 55 | } 56 | 57 | %form { 58 | .MuiInput-formControl { 59 | .MuiInput-input.Mui-disabled { 60 | color: black; 61 | -moz-user-select: text; 62 | } 63 | 64 | svg { 65 | visibility: hidden; 66 | } 67 | } 68 | } 69 | 70 | #viewDictionaryPage { 71 | #dictionary-form { 72 | @extend %form; 73 | } 74 | } 75 | 76 | #viewSourcePage { 77 | #source-form { 78 | @extend %form; 79 | } 80 | } 81 | 82 | #viewUserProfilePage { 83 | #user-form { 84 | @extend %form; 85 | } 86 | } 87 | 88 | .link { 89 | text-decoration: none; 90 | color: inherit; 91 | width: 100%; 92 | } 93 | 94 | #supported_locales.MuiSelect-selectMenu { 95 | white-space: inherit; 96 | } 97 | -------------------------------------------------------------------------------- /cypress/integration/dictionary/addBulkConceptsToDictionary.feature: -------------------------------------------------------------------------------- 1 | Feature: Add bulk concepts to an existing dictionary 2 | Background: 3 | Given the user is logged in 4 | 5 | @dictionary 6 | Scenario: The user should be able to add bulk concepts from their preferred source 7 | Given a dictionary exists 8 | And the user is on the view dictionary concepts page 9 | When the user clicks the "Add concepts" button 10 | And the user selects "Import existing concept" 11 | And the user selects "Add bulk concepts" 12 | Then the user should be on the "Add concepts in bulk from CIEL" page 13 | 14 | @dictionary 15 | @ciel 16 | Scenario: The user should be able to add a single bulk concept from CIEL 17 | Given a dictionary exists 18 | And the user is on the "Add concepts in bulk from CIEL" page 19 | When the user enters concept Id "1000" 20 | And the user clicks the "ADD CONCEPTS" button 21 | Then the user navigates to the "Progess notification" page 22 | And the concept Id "1000" should be in the dictionary 23 | 24 | @dictionary 25 | @ciel 26 | Scenario: The user should be able to add multiple bulk concepts from CIEL 27 | Given a dictionary exists 28 | And the user is on the "Add concepts in bulk from CIEL" page 29 | When the user enters concept Id "1001" 30 | And the user enters concept Id "1002" 31 | And the user clicks the "ADD CONCEPTS" button 32 | Then the user navigates to the "Progess notification" page 33 | And the concept Id "1001" should be in the dictionary 34 | And the concept Id "1002" should be in the dictionary 35 | 36 | @dictionary 37 | @ciel 38 | Scenario: The system should be able to skip an already added bulk concepts from CIEL 39 | Given a dictionary exists 40 | And CIEL concept "1000" is already in the dictionary 41 | And the user is on the "Add concepts in bulk from CIEL" page 42 | When the user enters concept Id "1000" 43 | And the user clicks the "ADD CONCEPTS" button 44 | Then the user navigates to the "Progess notification" page 45 | And concept Id "1000" should be skipped 46 | -------------------------------------------------------------------------------- /src/apps/notifications/__test__/components/NotificationCard.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, fireEvent } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import NotificationCard from "../../components/NotificationCard"; 5 | import { BrowserRouter as Router } from "react-router-dom"; 6 | import { NotificationItem, NotificationItemRow } from "../../types"; 7 | 8 | type notificationCardProps = React.ComponentProps; 9 | 10 | const notificationItemRow: NotificationItemRow = { 11 | expression: "", 12 | added: true, 13 | message: "1 notification added" 14 | }; 15 | const notificationItem: NotificationItem = { 16 | result: [notificationItemRow], 17 | progress: "" 18 | }; 19 | 20 | const openNotificationDetails = jest.fn(); 21 | const baseProps: notificationCardProps = { 22 | headerMessage: "This is header message", 23 | subHeaderMessage: "This is sub-header message", 24 | importMetaData: { 25 | dictionary: "/users/root/collections/testBulk2/", 26 | dateTime: "Mon Sep 28 2020 09:24:36 GMT+0530" 27 | } 28 | }; 29 | 30 | function renderUI(props: Partial = {}) { 31 | return render( 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | describe("NotificationCard", () => { 39 | it("NotificationCard snapshot test", () => { 40 | const { container } = renderUI(); 41 | expect(container).toMatchSnapshot(); 42 | }); 43 | 44 | it("should not show view summary button if list is not succeslist", () => { 45 | const { queryByText } = renderUI(); 46 | const viewSummaryLink = queryByText("View Summary"); 47 | expect(viewSummaryLink).toBeNull(); 48 | }); 49 | 50 | it("should call openNotificationDetails on click of view summary button", () => { 51 | const { getByText } = renderUI({ 52 | notification: notificationItem, 53 | openNotificationDetails: openNotificationDetails 54 | }); 55 | const viewSummaryLink = getByText("View Summary"); 56 | fireEvent.click(viewSummaryLink); 57 | expect(openNotificationDetails).toHaveBeenCalledTimes(1); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /cypress/support/step_definitions/organisation/details/details.ts: -------------------------------------------------------------------------------- 1 | import { Given, Then, When } from "cypress-cucumber-preprocessor/steps"; 2 | import { getOrganisationId } from "../../../utils"; 3 | 4 | Given("an organization exists", () => { 5 | cy.createOrganisation(getOrganisationId()); 6 | }); 7 | 8 | Given("a source exists in the organisation", () => { 9 | cy.createOrgSource(undefined, getOrganisationId()); 10 | }); 11 | 12 | Given("a dictionary exists in the organisation", () => { 13 | cy.createOrgDictionary(undefined, getOrganisationId()); 14 | }); 15 | 16 | Given("the user is on the organisation details page", () => 17 | cy.visit(`/orgs/${getOrganisationId()}/`) 18 | ); 19 | 20 | Then("the organisation name should be displayed", () => { 21 | cy.findByText("Details").should("be.visible"); 22 | cy.findByText("Test Organisation", { selector: "span" }).should("be.visible"); 23 | }); 24 | 25 | Then("the organisation sources should be displayed", () => { 26 | cy.findByText("Sources", { selector: "legend" }).should("be.visible"); 27 | }); 28 | 29 | Then("the organisation members should be displayed", () => { 30 | cy.findByText("Members", { selector: "legend" }).should("be.visible"); 31 | }); 32 | 33 | Then("the organisation dictionaries should be displayed", () => { 34 | cy.findByText("Dictionaries", { selector: "legend" }).should("be.visible"); 35 | }); 36 | 37 | Then("the user should see the organisation source", () => { 38 | cy.contains("li", "Test Org Source"); 39 | }); 40 | 41 | When("the user clicks on the source", () => 42 | cy.contains("li", "Test Org Source").get("li > a").click() 43 | ); 44 | 45 | Then("the user should be on the org source page", () => 46 | cy.url().should("contain",`/orgs/${getOrganisationId()}/sources/`) 47 | ); 48 | 49 | Then("the user should see the organisation dictionary", () => { 50 | cy.contains("li", "Test Org Dictionary"); 51 | }); 52 | 53 | When("the user clicks on the dictionary", () => 54 | cy.contains("li", "Test Org Dictionary").get("li > a").click() 55 | ); 56 | 57 | Then("the user should be on the org dictionary page", () => 58 | cy.url().should("contain",`/orgs/${getOrganisationId()}/collections/`) 59 | ); 60 | -------------------------------------------------------------------------------- /src/apps/sources/components/SourceConceptsSummary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { APISource } from "../../sources"; 3 | import { 4 | Button, 5 | ButtonGroup, 6 | Paper, 7 | Typography, 8 | List, 9 | ListItem, 10 | ListItemIcon, 11 | ListItemText 12 | } from "@mui/material"; 13 | import { Link } from "react-router-dom"; 14 | import { StarBorder, DeleteForever } from "@mui/icons-material"; 15 | 16 | interface Props { 17 | source?: APISource; 18 | totalConceptCount: number; 19 | activeConceptCount: number; 20 | } 21 | 22 | const SourceConceptsSummary: React.FC = ({ 23 | source, 24 | totalConceptCount, 25 | activeConceptCount 26 | }) => { 27 | const total_concepts: number = totalConceptCount; 28 | const active_concepts: number = activeConceptCount; 29 | const retire_concepts: number = total_concepts - active_concepts; 30 | 31 | return ( 32 | 33 |
    34 | 35 | Concepts(HEAD Version) 36 | 37 | 38 | {`Total Concepts: ${total_concepts}`} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 62 | 63 |
    64 |
    65 | ); 66 | }; 67 | 68 | export default SourceConceptsSummary; 69 | -------------------------------------------------------------------------------- /src/apps/authentication/__test__/components/UserForm.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import UserForm from "../../components/UserForm"; 3 | import { render } from "../../../../test-utils.test"; 4 | import { BrowserRouter as Router } from "react-router-dom"; 5 | import { testProfile } from "../test_data"; 6 | 7 | type userFormProps = React.ComponentProps; 8 | 9 | const baseProps: userFormProps = { 10 | loading: true, 11 | savedValues: testProfile 12 | }; 13 | 14 | function renderUI(props: Partial = {}) { 15 | return render( 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | describe("UserForm", () => { 23 | it("should match snapshot", () => { 24 | const { container } = renderUI(baseProps); 25 | 26 | expect(container).toMatchSnapshot(); 27 | }); 28 | it("should show correct User Name", () => { 29 | const { getByLabelText } = renderUI(); 30 | const header = getByLabelText("User Name"); 31 | 32 | expect(header.textContent).toBe("TestUser"); 33 | }); 34 | it("should show correct Full name", () => { 35 | const { getByLabelText } = renderUI(); 36 | const header = getByLabelText("Full Name"); 37 | 38 | expect(header.textContent).toBe("TestUser"); 39 | }); 40 | it("should show correct Email ID", () => { 41 | const { getByLabelText } = renderUI(); 42 | const header = getByLabelText("Email ID"); 43 | 44 | expect(header.textContent).toBe("TestUser@test.com"); 45 | }); 46 | it("should show correct Company", () => { 47 | const { getByLabelText } = renderUI(); 48 | const header = getByLabelText("Company"); 49 | 50 | expect(header.textContent).toBe("Test Company"); 51 | }); 52 | it("should show correct Location", () => { 53 | const { getByLabelText } = renderUI(); 54 | const header = getByLabelText("Location"); 55 | 56 | expect(header.textContent).toBe("Test Location"); 57 | }); 58 | it("should show correct Joined Date", () => { 59 | const { getByLabelText } = renderUI(); 60 | const header = getByLabelText("Joined Date"); 61 | 62 | // @ts-ignore 63 | expect(header.value).toBe("01 Jan 1900"); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/apps/sources/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EditableSourceFields, 3 | NewAPISource, 4 | SourceVersion, 5 | APISourceVersion 6 | } from "./types"; 7 | import { authenticatedInstance, unAuthenticatedInstance } from "../../api"; 8 | import { AxiosResponse } from "axios"; 9 | import { buildPartialSearchQuery } from "../../utils"; 10 | import { default as containerAPI } from "../containers/api"; 11 | 12 | const api = { 13 | ...containerAPI, 14 | create: (ownerUrl: string, data: NewAPISource) => 15 | authenticatedInstance.post(`${ownerUrl}sources/`, data), 16 | update: (sourceUrl: string, data: EditableSourceFields) => 17 | authenticatedInstance.put(sourceUrl, data), 18 | sources: { 19 | retrieve: { 20 | private: ( 21 | sourcesUrl: string, 22 | q: string = "", 23 | limit = 20, 24 | page = 1, 25 | verbose = true 26 | ) => 27 | authenticatedInstance.get(sourcesUrl, { 28 | params: { 29 | limit, 30 | page, 31 | verbose: true, 32 | q: buildPartialSearchQuery(q), 33 | timestamp: new Date().getTime() // work around seemingly unhelpful caching 34 | } 35 | }), 36 | public: ( 37 | sourcesUrl: string, 38 | q: string = "", 39 | limit = 20, 40 | page = 1, 41 | verbose = true 42 | ) => 43 | unAuthenticatedInstance.get(sourcesUrl, { 44 | params: { 45 | limit, 46 | page, 47 | verbose: true, 48 | q: buildPartialSearchQuery(q), 49 | timestamp: new Date().getTime() // work around seemingly unhelpful caching 50 | } 51 | }) 52 | } 53 | }, 54 | versions: { 55 | ...containerAPI.versions, 56 | create: ( 57 | sourceUrl: string, 58 | data: SourceVersion 59 | ): Promise> => 60 | authenticatedInstance.post(`${sourceUrl}versions/`, data), 61 | update: ( 62 | sourceUrl: string, 63 | data: SourceVersion 64 | ): Promise> => 65 | authenticatedInstance.put(`${sourceUrl}${data.id}/`, data) 66 | } 67 | }; 68 | 69 | export default api; 70 | -------------------------------------------------------------------------------- /src/apps/organisations/components/OrgCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Button, 4 | Card, 5 | CardContent, 6 | Grid, 7 | Theme, 8 | Typography 9 | } from "@mui/material"; 10 | import { createStyles, makeStyles } from "@mui/styles"; 11 | import { Link as RouterLink, useLocation, useHistory } from "react-router-dom"; 12 | interface Props { 13 | name: string; 14 | url: string; 15 | index: number; 16 | id: string; 17 | } 18 | 19 | const useStyles = makeStyles((theme: Theme) => 20 | createStyles({ 21 | containerName: { 22 | overflowX: "auto" 23 | }, 24 | button: { 25 | margin: theme.spacing(1) 26 | }, 27 | card: { 28 | cursor: "pointer" 29 | } 30 | }) 31 | ); 32 | const OrganisationCard: React.FC = ({ name, url, id, index }) => { 33 | const classes = useStyles(); 34 | const location = useLocation(); 35 | const { push: goTo } = useHistory(); 36 | 37 | return ( 38 | 39 | goTo(url)} className={classes.card}> 40 | 41 | 48 | {id} 49 | 50 | 56 | {name} 57 | 58 | 64 | {url} 65 | 66 | 67 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default OrganisationCard; 83 | -------------------------------------------------------------------------------- /cypress/integration/dictionary/addConceptsToDictionary.feature: -------------------------------------------------------------------------------- 1 | Feature: Add concepts to an existing dictionary 2 | Background: 3 | Given the user is logged in 4 | And a dictionary exists 5 | 6 | @dictionary 7 | Scenario: The user should be able to add concepts from their preferred source 8 | Given the user is on the view dictionary concepts page 9 | When the user clicks the "Add concepts" button 10 | And the user selects "Import existing concept" 11 | And the user selects "Pick concepts" 12 | Then the user should be on the "Import existing concept" page 13 | And the current source should be "CIEL" 14 | 15 | @dictionary 16 | @ciel 17 | Scenario: The user should be able to add a single concept from their preferred source 18 | Given the user is on the "Import existing concept" page 19 | When the user clicks on the row for "Serum" 20 | And the user clicks on the "Add selected to dictionary" button 21 | Then the user should be on the "Import existing concept" page 22 | And the "Serum" concept should be added to the dictionary 23 | 24 | @dictionary 25 | @ciel 26 | Scenario: The user should be able to add multiple concepts from their preferred source 27 | Given the user is on the "Import existing concept" page 28 | When the user clicks on the row for "Serum" 29 | And the user clicks on the row for "Whole blood sample" 30 | And the user clicks on the row for "Plasma" 31 | And the user clicks on the "Add selected to dictionary" button 32 | Then the user should be on the "Import existing concept" page 33 | And the "Serum" concept should be added to the dictionary 34 | And the "Whole blood sample" concept should be added to the dictionary 35 | And the "Plasma" concept should be added to the dictionary 36 | 37 | @dictionary 38 | @ciel 39 | Scenario: The user should be able to add a single concept from their preferred source while viewing it 40 | Given the user is on the "Import existing concept" page 41 | When the user clicks on the link for "Serum" 42 | And the user is sent to the view concept page 43 | And the user clicks on the "Add Serum to dictionary" button 44 | Then the "Serum" concept should be added to the dictionary 45 | -------------------------------------------------------------------------------- /src/apps/organisations/components/OrgDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Typography, List, Paper, ListItem, Grid } from "@mui/material"; 3 | import { makeStyles } from "@mui/styles"; 4 | 5 | import { APIOrganisation } from "../types"; 6 | interface Props { 7 | organisation: APIOrganisation; 8 | } 9 | 10 | const useStyles = makeStyles(() => ({ 11 | listItem: { 12 | display: "flex" 13 | }, 14 | name: { 15 | marginRight: "1rem" 16 | } 17 | })); 18 | 19 | const OrganisationDetails: React.FC = ({ organisation }: Props) => { 20 | const { website, location, company, public_access, name } = 21 | organisation || {}; 22 | const classes = useStyles(); 23 | return ( 24 | 25 | 26 |
    27 | 28 | Details 29 | 30 | 31 | 32 | 33 | Name 34 | {name} 35 | 36 | {company && ( 37 | 38 | Company 39 | {company} 40 | 41 | )} 42 | {website && ( 43 | 44 | Website{" "} 45 | {website} 46 | 47 | )} 48 | {location && ( 49 | 50 | Location{" "} 51 | {location} 52 | 53 | )} 54 | 55 | Public Access{" "} 56 | {public_access} 57 | 58 | 59 |
    60 |
    61 |
    62 | ); 63 | }; 64 | 65 | export default OrganisationDetails; 66 | -------------------------------------------------------------------------------- /src/apps/organisations/types.ts: -------------------------------------------------------------------------------- 1 | export interface BaseAPIOrganisation { 2 | id: string; 3 | name: string; 4 | url: string; 5 | } 6 | export interface OrgSource { 7 | short_code: string; 8 | name: string; 9 | url: string; 10 | owner: string; 11 | owner_type: string; 12 | owner_url: string; 13 | } 14 | export interface OrgCollection { 15 | id: string; 16 | name: string; 17 | url: string; 18 | owner: string; 19 | owner_type: string; 20 | owner_url: string; 21 | } 22 | export interface APIOrganisation extends BaseAPIOrganisation { 23 | type: string; 24 | uuid: string; 25 | company: string; 26 | website: string; 27 | location: string; 28 | public_access: string; 29 | extras: Extras | null; 30 | members_url: string; 31 | sources_url: string; 32 | collections_url: string; 33 | members: number; 34 | public_collections: number; 35 | public_sources: number; 36 | created_on: string; 37 | created_by: string; 38 | updated_on: string; 39 | updated_by: string; 40 | } 41 | export interface Extras { 42 | source?: string; 43 | } 44 | 45 | export interface Organisation { 46 | id: string; 47 | name: string; 48 | company?: string; 49 | website?: string; 50 | location?: string; 51 | extras?: Extras | null; 52 | public_access?: string; 53 | } 54 | 55 | export interface EditableOrganisationFields { 56 | name: string; 57 | company?: string; 58 | website?: string; 59 | location?: string; 60 | extras?: Extras | null; 61 | public_access?: string; 62 | } 63 | export interface OrganisationState { 64 | organisation: APIOrganisation; 65 | organisations: { items: APIOrganisation[]; responseMeta?: any }[]; 66 | meta?: any; 67 | newOrganisation?: APIOrganisation; 68 | editedOrganisation?: APIOrganisation; 69 | orgSources?: OrgSource[]; 70 | orgCollections?: OrgCollection[]; 71 | orgMembers?: OrgMember[]; 72 | showAddMemberDialog: boolean; 73 | showDeleteMemberDialog?: string; 74 | showOnlyVerified: boolean; 75 | } 76 | export interface OrgMember { 77 | username: string; 78 | name?: string; 79 | url?: string; 80 | } 81 | 82 | export const apiOrgToOrganisation = ( 83 | apiOrganisation?: APIOrganisation 84 | ): APIOrganisation | undefined => { 85 | if (!apiOrganisation) return apiOrganisation; 86 | }; 87 | -------------------------------------------------------------------------------- /src/apps/organisations/__test__/components/organisationDetails.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import OrganisationDetails from "../../components/OrgDetails"; 3 | import { render } from "@testing-library/react"; 4 | import "@testing-library/jest-dom"; 5 | import { BrowserRouter as Router } from "react-router-dom"; 6 | 7 | const organisation = { 8 | type: "Organization", 9 | uuid: "1", 10 | id: "T1", 11 | public_access: "View", 12 | name: "Test organisation 1", 13 | company: "Test company", 14 | website: "https://test.com", 15 | location: "Belgium", 16 | members: 0, 17 | members_url: "", 18 | created_on: "2021-01-17T03:16:55.561172Z", 19 | updated_on: "2021-01-17T03:16:55.561197Z", 20 | url: "/orgs/T1/", 21 | extras: null, 22 | created_by: "ocladmin", 23 | updated_by: "ocladmin", 24 | sources_url: "/orgs/T1/sources/", 25 | public_sources: 1, 26 | collections_url: "/orgs/T1/collections/", 27 | public_collections: 0 28 | }; 29 | 30 | type OrgDetailProps = React.ComponentProps; 31 | 32 | function renderUI(props: OrgDetailProps) { 33 | return render( 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | describe("OrganisationDetail", () => { 41 | it("should match snapshot", () => { 42 | const { container } = renderUI({ 43 | organisation 44 | }); 45 | 46 | expect(container).toMatchSnapshot(); 47 | }); 48 | 49 | it("should display name, company, website, location and access of the organisation", () => { 50 | const { getByText } = renderUI({ organisation }); 51 | 52 | expect(getByText("Details")).toBeInTheDocument(); 53 | expect(getByText("Name")).toBeInTheDocument(); 54 | expect(getByText("Test organisation 1")).toBeInTheDocument(); 55 | 56 | expect(getByText("Website")).toBeInTheDocument(); 57 | expect(getByText("https://test.com")).toBeInTheDocument(); 58 | 59 | expect(getByText("Public Access")).toBeInTheDocument(); 60 | expect(getByText("View")).toBeInTheDocument(); 61 | 62 | expect(getByText("Company")).toBeInTheDocument(); 63 | expect(getByText("Test company")).toBeInTheDocument(); 64 | 65 | expect(getByText("Location")).toBeInTheDocument(); 66 | expect(getByText("Belgium")).toBeInTheDocument(); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/apps/concepts/__test__/components/ConceptsActionMenu.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "../../../../test-utils.test"; 3 | import "@testing-library/jest-dom"; 4 | import ConceptsActionMenu from "../../components/ConceptsActionMenu"; 5 | import { 6 | APIConcept, 7 | ConceptName, 8 | ConceptDescription, 9 | APIMapping 10 | } from "../../types"; 11 | 12 | type conceptsActionMenuProps = React.ComponentProps; 13 | 14 | const testConceptName: ConceptName = { 15 | name: "test concept", 16 | locale: "en", 17 | external_id: "1234", 18 | locale_preferred: true, 19 | name_type: "Fully Specified" 20 | }; 21 | 22 | const testDescription: ConceptDescription = { 23 | description: "concept description", 24 | locale: "en", 25 | external_id: "3456", 26 | locale_preferred: true 27 | }; 28 | 29 | const testMapping: APIMapping = { 30 | map_type: "test", 31 | external_id: "79787", 32 | from_concept_url: "from_concept_url", 33 | url: "concept_url", 34 | retired: false, 35 | to_concept_code: "concept_code" 36 | }; 37 | const testConcept: APIConcept = { 38 | id: "123", 39 | external_id: "234234", 40 | concept_class: "Diagnosis", 41 | datatype: "Numeric", 42 | names: [testConceptName], 43 | descriptions: [testDescription], 44 | url: "abcd", 45 | version_url: "version_url", 46 | extras: {}, 47 | display_name: "display_name", 48 | mappings: [testMapping], 49 | retired: false, 50 | source: "source", 51 | source_url: "source_url" 52 | }; 53 | const baseProps: conceptsActionMenuProps = { 54 | index: 1, 55 | row: testConcept, 56 | buttons: { edit: true }, 57 | toggleMenu: function() {}, 58 | menu: { index: 1, anchor: document.createElement("div") }, 59 | canModifyConcept: () => { 60 | return true; 61 | }, 62 | removeConceptsFromDictionary: function() {}, 63 | addConceptsToDictionary: function() {}, 64 | linkedSource: "", 65 | linkedDictionary: "" 66 | }; 67 | 68 | function renderUI(props: Partial = {}) { 69 | return render(); 70 | } 71 | 72 | describe("ConceptsActionMenu", () => { 73 | it("should match snapshot", () => { 74 | const { container } = renderUI(); 75 | 76 | expect(container).toMatchSnapshot(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/apps/organisations/api.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedInstance, unAuthenticatedInstance } from "../../api"; 2 | import { Organisation, EditableOrganisationFields, OrgMember } from "./types"; 3 | import { buildPartialSearchQuery, CUSTOM_VALIDATION_SCHEMA } from "../../utils"; 4 | 5 | const api = { 6 | organisations: { 7 | retrieve: { 8 | public: (organisationUrl: string, q: string = "", limit = 20, page = 1) => 9 | unAuthenticatedInstance.get(organisationUrl, { 10 | params: { 11 | limit, 12 | page, 13 | verbose: true, 14 | q: buildPartialSearchQuery(q), 15 | customValidationSchema: CUSTOM_VALIDATION_SCHEMA, 16 | sortAsc: "name", 17 | timestamp: new Date().getTime() 18 | } 19 | }), 20 | private: (username: string, q: string = "", limit = 20, page = 1) => 21 | authenticatedInstance.get(`/users/${username}/orgs/`, { 22 | params: { 23 | limit, 24 | page, 25 | verbose: true, 26 | q: buildPartialSearchQuery(q), 27 | customValidationSchema: CUSTOM_VALIDATION_SCHEMA, 28 | sortAsc: "name", 29 | timestamp: new Date().getTime() // work around seemingly unhelpful caching 30 | } 31 | }) 32 | } 33 | }, 34 | create: (data: Organisation) => authenticatedInstance.post(`/orgs/`, data), 35 | organisation: { 36 | retrieve: (url: string) => authenticatedInstance.get(url), 37 | update: (orgUrl: string, data: EditableOrganisationFields) => 38 | authenticatedInstance.put(orgUrl, data), 39 | delete: (orgUrl: string) => authenticatedInstance.delete(orgUrl), 40 | retrieveSources: (orgUrl: string) => 41 | authenticatedInstance.get(`${orgUrl}sources/`), 42 | retrieveCollections: (orgUrl: string) => 43 | authenticatedInstance.get(`${orgUrl}collections/`), 44 | retrieveMembers: (orgUrl: string) => 45 | authenticatedInstance.get(`${orgUrl}members/`), 46 | addMember: (orgUrl: string, member: OrgMember) => 47 | authenticatedInstance.put(`${orgUrl}members/${member.username}/`), 48 | deleteMember: (orgUrl: string, user: string) => 49 | authenticatedInstance.delete(`${orgUrl}members/${user}/`) 50 | } 51 | }; 52 | 53 | export default api; 54 | -------------------------------------------------------------------------------- /src/apps/authentication/components/AuthenticationRequired.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { connect } from "react-redux"; 3 | import { Redirect, useLocation } from "react-router-dom"; 4 | import { 5 | getUserDetailsAction, 6 | getUserDetailsLoadingSelector, 7 | profileSelector, 8 | setNextPageAction 9 | } from "../redux"; 10 | import { APIProfile } from "../types"; 11 | import { ProgressOverlay } from "../../../utils/components"; 12 | import { AppState } from "../../../redux"; 13 | 14 | interface Props { 15 | children: any; 16 | isLoggedIn: boolean; 17 | getProfile: Function; 18 | profile?: APIProfile; 19 | profileLoading: boolean; 20 | setNextPage: (...args: Parameters) => void; 21 | } 22 | 23 | export const AuthenticationRequired: React.FC = ({ 24 | children: Component, 25 | isLoggedIn, 26 | getProfile, 27 | profile, 28 | profileLoading, 29 | setNextPage 30 | }) => { 31 | const getProfileCalled = useRef(false); 32 | const [isLoading, setIsLoading] = useState(false); 33 | 34 | useEffect(() => { 35 | if (isLoggedIn) { 36 | getProfileCalled.current = true; 37 | setIsLoading(true); 38 | getProfile(); 39 | } 40 | }, [isLoggedIn, getProfile]); 41 | 42 | useEffect(() => { 43 | if (!profileLoading && getProfileCalled.current) { 44 | setIsLoading(false); 45 | } 46 | }, [profileLoading]); 47 | 48 | const location = useLocation(); 49 | 50 | if (!isLoggedIn) { 51 | const currentPage = location.pathname + location.search + location.hash; 52 | setNextPage(currentPage); 53 | return ; 54 | } 55 | 56 | return ( 57 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | const mapStateToProps = (state: AppState) => ({ 68 | isLoggedIn: state.auth.isLoggedIn, 69 | profile: profileSelector(state), 70 | profileLoading: getUserDetailsLoadingSelector(state) 71 | }); 72 | 73 | const mapDispatchToProps = { 74 | getProfile: getUserDetailsAction, 75 | setNextPage: setNextPageAction 76 | }; 77 | 78 | export default connect( 79 | mapStateToProps, 80 | mapDispatchToProps 81 | )(AuthenticationRequired); 82 | -------------------------------------------------------------------------------- /src/apps/organisations/components/DeleteMemberDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { 3 | Button, 4 | ButtonGroup, 5 | Dialog, 6 | DialogActions, 7 | DialogContent, 8 | DialogContentText, 9 | DialogTitle, 10 | Typography 11 | } from "@mui/material"; 12 | import { Form, Formik, FormikProps, FormikValues } from "formik"; 13 | 14 | interface Props { 15 | open: boolean; 16 | handleClose: () => void; 17 | handleSubmit?: () => void; 18 | user?: string; 19 | orgName: string; 20 | error?: {}; 21 | } 22 | 23 | const DeleteMemberDialog: React.FC = ({ 24 | open, 25 | handleClose, 26 | handleSubmit, 27 | user, 28 | orgName, 29 | error 30 | }) => { 31 | const formikRef = useRef>(null); 32 | 33 | return ( 34 | 35 | Remove {user} 36 | 37 | 38 | Are you sure you want to remove {user} from {orgName}? 39 | 40 | { 44 | if (handleSubmit) { 45 | handleSubmit(); 46 | } 47 | }} 48 | > 49 | {({ isSubmitting }) => ( 50 |
    51 | {!error ? ( 52 |
    53 | ) : ( 54 | 60 | {error} 61 | 62 | )} 63 | 64 | 70 | 71 | 74 | 75 | 76 |
    77 | )} 78 |
    79 |
    80 |
    81 | ); 82 | }; 83 | 84 | export default DeleteMemberDialog; 85 | -------------------------------------------------------------------------------- /src/redux/reducer.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from "lodash"; 2 | import { 3 | errors, 4 | loading, 5 | progress, 6 | COMPLETE, 7 | FAILURE, 8 | PROGRESS, 9 | RESET, 10 | START, 11 | meta 12 | } from "./utils"; 13 | import { Action, StatusState } from "./types"; 14 | import { LOGOUT_ACTION } from "../apps/authentication/redux/actionTypes"; 15 | 16 | const initialState: StatusState = {}; 17 | 18 | // todo improve these action types args 19 | const loadingAndErroredReducer = ( 20 | state: StatusState = initialState, 21 | action: Action 22 | ) => { 23 | const { type, payload, actionIndex, meta: actionMeta } = action; 24 | 25 | if (type === LOGOUT_ACTION) { 26 | return Object.assign({}, initialState); 27 | } 28 | 29 | const matches = /(.*)_(START|PROGRESS|FAILURE|COMPLETE|RESET)/.exec(type); 30 | 31 | if (!matches) return state; 32 | 33 | const [, actionName, requestState] = matches; 34 | const loadingName = loading(actionName); 35 | const progressName = progress(actionName); 36 | const errorName = errors(actionName); 37 | const metaName = meta(actionName); 38 | 39 | const newState = cloneDeep(state); 40 | 41 | switch (requestState) { 42 | case RESET: { 43 | newState[loadingName] = []; 44 | newState[progressName] = []; 45 | newState[errorName] = []; 46 | newState[metaName] = []; 47 | 48 | return newState; 49 | } 50 | case START: { 51 | if (!newState[loadingName]) newState[loadingName] = []; 52 | if (!newState[progressName]) newState[progressName] = []; 53 | if (!newState[errorName]) newState[errorName] = []; 54 | if (!newState[metaName]) newState[metaName] = []; 55 | 56 | newState[loadingName][actionIndex] = true; 57 | newState[progressName][actionIndex] = undefined; 58 | newState[errorName][actionIndex] = undefined; 59 | newState[metaName][actionIndex] = actionMeta; 60 | 61 | return newState; 62 | } 63 | case COMPLETE: { 64 | newState[loadingName][actionIndex] = false; 65 | return newState; 66 | } 67 | case PROGRESS: { 68 | newState[progressName][actionIndex] = payload; 69 | return newState; 70 | } 71 | case FAILURE: { 72 | newState[errorName][actionIndex] = payload; 73 | return newState; 74 | } 75 | default: 76 | return state; 77 | } 78 | }; 79 | 80 | export default loadingAndErroredReducer; 81 | -------------------------------------------------------------------------------- /src/apps/notifications/components/EnhancedNotificationSummaryTableHead.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TableCell, TableHead, TableRow, TableSortLabel } from "@mui/material"; 3 | import { makeStyles } from "@mui/styles"; 4 | interface EnhancedNotificationSummaryTableHeadProps { 5 | onRequestSort: (event: React.MouseEvent, property: string) => void; 6 | order: "asc" | "desc"; 7 | orderBy: string; 8 | } 9 | 10 | const useStyles = makeStyles({ 11 | tableHeadCell: { 12 | minWidth: "150px" 13 | }, 14 | visuallyHidden: { 15 | border: 0, 16 | clip: "rect(0 0 0 0)", 17 | height: 1, 18 | margin: -1, 19 | overflow: "hidden", 20 | padding: 0, 21 | position: "absolute", 22 | top: 20, 23 | width: 1 24 | } 25 | }); 26 | 27 | export function EnhancedNotificationSummaryTableHead( 28 | props: EnhancedNotificationSummaryTableHeadProps 29 | ) { 30 | const { order, orderBy, onRequestSort } = props; 31 | 32 | const classes = useStyles(); 33 | 34 | const headCells = [ 35 | { id: "conceptId", label: "Concept ID" }, 36 | { id: "conceptType", label: "Concept Type" }, 37 | { id: "status", label: "Status" }, 38 | { id: "reasons", label: "Reasons" } 39 | ]; 40 | 41 | const createSortHandler = (property: string) => ( 42 | event: React.MouseEvent 43 | ) => { 44 | onRequestSort(event, property); 45 | }; 46 | 47 | return ( 48 | 49 | 50 | S.No 51 | {headCells.map(headCell => ( 52 | 57 | 62 | {headCell.label} 63 | {orderBy === headCell.id ? ( 64 | 65 | {order === "desc" ? "sorted descending" : "sorted ascending"} 66 | 67 | ) : null} 68 | 69 | 70 | ))} 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 25 | 34 | OpenMRS Dictionary Manager 35 | 36 | 37 | 38 |
    39 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/apps/containers/components/__test__/ContainerSearch.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import { BrowserRouter as Router } from "react-router-dom"; 5 | import { fireEvent } from "@testing-library/dom"; 6 | import ContainerSearch from "../ContainerSearch"; 7 | import { ThemeProvider } from "@mui/material/styles"; 8 | import { theme } from "../../../../App"; 9 | 10 | type searchProps = React.ComponentProps; 11 | 12 | const baseProps: searchProps = { 13 | title: "", 14 | onSearch: function onPageChange() {}, 15 | initialQ: "" 16 | }; 17 | 18 | function renderUI(props: Partial = {}) { 19 | return render( 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | describe("ContainerSearch", () => { 28 | it("should search for given card when input is given", () => { 29 | const spyOnSearch = jest.fn(); 30 | const { container } = renderUI({ 31 | onSearch: spyOnSearch 32 | }); 33 | 34 | const searchInputBox: HTMLElement | null = container.querySelector( 35 | "[data-testid='searchInput']" 36 | ); 37 | const searchSubmitButton: HTMLElement | null = container.querySelector( 38 | "[data-testid='searchButton']" 39 | ); 40 | 41 | expect(searchInputBox).not.toBeNull(); 42 | expect(searchSubmitButton).not.toBeNull(); 43 | if (searchInputBox !== null && searchSubmitButton !== null) { 44 | const searchInputElement = searchInputBox.children[0]; 45 | fireEvent.change(searchInputElement, { target: { value: "Test Card" } }); 46 | fireEvent.click(searchSubmitButton); 47 | expect(spyOnSearch).toHaveBeenCalledWith("Test Card"); 48 | } 49 | }); 50 | 51 | it('should have placeholder as "Search Cards" when title is given as "Cards"', () => { 52 | const { container, debug } = renderUI({ 53 | title: "Cards" 54 | }); 55 | 56 | const searchInputBox: HTMLElement | null = container.querySelector( 57 | "[data-testid='searchInput']" 58 | ); 59 | 60 | expect(searchInputBox).not.toBeNull(); 61 | if (searchInputBox !== null) { 62 | const searchInputElement = searchInputBox.children[0]; 63 | expect(searchInputElement.getAttribute("placeholder")).toBe( 64 | "Search Cards" 65 | ); 66 | } 67 | }); 68 | }); 69 | --------------------------------------------------------------------------------