Last active
March 2, 2021 13:40
-
-
Save RuslanSevrukov/2ce6254f3c9ac31e60ea2ebc92891b14 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
import PropTypes from "prop-types"; | |
import cx from "classnames"; | |
import { observer } from "mobx-react-lite"; | |
import { get as mobxGet } from "mobx"; | |
import filter from "lodash/fp/filter"; | |
import get from "lodash/fp/get"; | |
import getOr from "lodash/fp/getOr"; | |
import groupBy from "lodash/fp/groupBy"; | |
import mapValues from "lodash/fp/mapValues"; | |
import max from "lodash/fp/max"; | |
import flatten from "lodash/fp/flatten"; | |
import pipe from "lodash/fp/pipe"; | |
import sum from "lodash/fp/sum"; | |
import sumBy from "lodash/fp/sumBy"; | |
import toInteger from "lodash/fp/toInteger"; | |
import values from "lodash/fp/values"; | |
import MatrixCell from "./MatrixCell"; | |
import MatrixRow from "./MatrixRow"; | |
import ExportMenu from "../../ExportMenu"; | |
import LoadingChart from "../LoadingChart"; | |
import ErrorMessage from "../../../ErrorMessage"; | |
import { | |
matrixViolationTypeToLabel, | |
violationCountLabel, | |
} from "../../../../utils/transforms/labels"; | |
import { translate } from "../../../../views/tenants/utils/i18nSettings"; | |
import { useContainerHeight } from "../../../../hooks/useContainerHeight"; | |
import { useRootStore } from "../../../../StoreProvider"; | |
import { | |
VIOLATION_TYPE, | |
REPORTED_VIOLATIONS, | |
} from "../../../../constants/filterTypes"; | |
import "./Matrix.scss"; | |
const TITLE = "Admissions by violation history (in year prior to their last reported violation)"; | |
const VIOLATION_COUNTS = ["1", "2", "3", "4", "5", "6", "7", "8"]; | |
const getInteger = (field) => pipe(get(field), toInteger); | |
const sumByInteger = (field) => sumBy(getInteger(field)); | |
const sumRow = pipe(values, sum); | |
const Matrix = ({ timeDescription }) => { | |
const { dataStore, filters, filtersStore } = useRootStore(); | |
const { filterOptions } = filtersStore; | |
const store = dataStore.matrixStore; | |
const { containerHeight, containerRef } = useContainerHeight(); | |
const violationTypes = translate("violationTypes"); | |
if (store.isLoading) { | |
return <LoadingChart containerHeight={containerHeight} />; | |
} | |
if (store.isError) { | |
return <ErrorMessage />; | |
} | |
const updateFilters = (updatedFilters) => { | |
filtersStore.setFilters(updatedFilters); | |
}; | |
const isFiltered = mobxGet(filters, VIOLATION_TYPE) || mobxGet(filters, REPORTED_VIOLATIONS); | |
const filteredData = pipe( | |
filter((data) => violationTypes.includes(data.violation_type)) | |
)(store.filteredData); | |
const dataMatrix = pipe( | |
groupBy("violation_type"), | |
mapValues( | |
pipe( | |
groupBy("reported_violations"), | |
mapValues(sumByInteger("total_revocations")) | |
) | |
) | |
)(filteredData); | |
if (!dataMatrix) { | |
return null; | |
} | |
const maxRevocations = pipe( | |
() => | |
violationTypes.map((rowLabel) => | |
VIOLATION_COUNTS.map((columnLabel) => | |
getOr(0, [rowLabel, columnLabel], dataMatrix) | |
) | |
), | |
flatten, | |
max | |
)(); | |
const violationsSum = sumByInteger("total_revocations")(filteredData); | |
const reportedViolationsSum = pipe( | |
(count) => | |
filter((data) => data.reported_violations === count, filteredData), | |
sumByInteger("total_revocations") | |
); | |
const isSelected = (violationType, reportedViolations) => | |
mobxGet(filters, VIOLATION_TYPE) === violationType && | |
mobxGet(filters, REPORTED_VIOLATIONS) === reportedViolations; | |
const toggleFilter = (violationType, reportedViolations) => { | |
if (isSelected(violationType, reportedViolations)) { | |
updateFilters({ | |
violationType: filterOptions[VIOLATION_TYPE].defaultValue, | |
reportedViolations: filterOptions[REPORTED_VIOLATIONS].defaultValue, | |
}); | |
} else { | |
updateFilters({ violationType, reportedViolations }); | |
} | |
}; | |
const exportableMatrixData = violationTypes.map((rowLabel) => ({ | |
label: matrixViolationTypeToLabel[rowLabel], | |
data: VIOLATION_COUNTS.map((columnLabel) => | |
getOr(0, [rowLabel, columnLabel], dataMatrix) | |
), | |
})); | |
return ( | |
<div ref={containerRef} className="Matrix"> | |
<h4 className="Matrix__title"> | |
{TITLE} | |
<ExportMenu | |
chartId={`${translate("revocation")}Matrix`} | |
regularElement | |
datasets={exportableMatrixData} | |
labels={VIOLATION_COUNTS.map(violationCountLabel)} | |
metricTitle={TITLE} | |
timeWindowDescription={timeDescription} | |
fixLabelsInColumns | |
dataExportLabel="Violations" | |
/> | |
</h4> | |
<h6 className="Matrix__dates">{timeDescription}</h6> | |
<div className="Matrix__x-label"> | |
# of {translate("violationReports")} | |
</div> | |
<div id="revocationMatrix" className="Matrix__chart-container"> | |
<div className="Matrix__y-label" data-html2canvas-ignore> | |
Most severe violation reported | |
</div> | |
<div | |
className={cx("Matrix__matrix", { | |
"Matrix__matrix--is-filtered": isFiltered, | |
})} | |
> | |
<div className="Matrix__violation-counts"> | |
<span className="Matrix__empty-cell" /> | |
{VIOLATION_COUNTS.map((count, i) => ( | |
<span key={i} className="Matrix__violation-column"> | |
{violationCountLabel(count)} | |
</span> | |
))} | |
<span | |
className={cx( | |
"Matrix__violation-sum-column", | |
"Matrix__top-right-total" | |
)} | |
> | |
Total | |
</span> | |
</div> | |
{violationTypes.map((violationType, i) => ( | |
<MatrixRow | |
key={i} | |
violationType={violationType} | |
sum={sumRow(dataMatrix[violationType])} | |
onClick={() => toggleFilter(violationType, "All")} | |
> | |
{VIOLATION_COUNTS.map((violationCount, j) => ( | |
<MatrixCell | |
key={j} | |
count={getOr(0, [violationType, violationCount], dataMatrix)} | |
maxCount={maxRevocations} | |
violationType={violationType} | |
reportedViolations={violationCount} | |
onClick={() => toggleFilter(violationType, violationCount)} | |
/> | |
))} | |
</MatrixRow> | |
))} | |
<div className="Matrix__violation-sum-row"> | |
<span className="Matrix__empty-cell" /> | |
{VIOLATION_COUNTS.map((count, i) => ( | |
<span | |
key={i} | |
className={cx( | |
"Matrix__violation-column", | |
"Matrix__violation-sum" | |
)} | |
> | |
{reportedViolationsSum(count)} | |
</span> | |
))} | |
<span | |
className={cx( | |
"Matrix__violation-sum-column", | |
"Matrix__violation-sum", | |
"Matrix__bottom-right-total" | |
)} | |
> | |
{violationsSum} | |
</span> | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
}; | |
Matrix.propTypes = { | |
timeDescription: PropTypes.string.isRequired, | |
}; | |
export default observer(Matrix); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
import PropTypes from "prop-types"; | |
import cx from "classnames"; | |
import { get } from "mobx"; | |
import { useRootStore } from "../../../../StoreProvider"; | |
import { | |
VIOLATION_TYPE, | |
REPORTED_VIOLATIONS, | |
} from "../../../../constants/filterTypes"; | |
import { COLORS } from "../../../../assets/scripts/constants/colors"; | |
const minRadius = 25; | |
const maxRadius = 50; | |
const MatrixCell = ({ | |
count, | |
maxCount, | |
violationType, | |
reportedViolations, | |
onClick, | |
}) => { | |
const { filters } = useRootStore(); | |
const ratio = maxCount > 0 ? count / maxCount : 0; | |
const radius = Math.max(minRadius, Math.ceil(ratio * maxRadius) + 15); | |
const containerStyle = { | |
position: "relative", | |
zIndex: 1, | |
background: "white", | |
display: "inline-block", | |
width: radius, | |
height: radius, | |
lineHeight: `${radius}px`, | |
}; | |
const cellStyle = { | |
// lantern-dark-blue with opacity | |
background: | |
ratio === 0 ? COLORS.white : `rgba(0, 44, 66, ${Math.max(ratio, 0.05)})`, | |
width: "100%", | |
height: "100%", | |
borderRadius: Math.ceil(radius / 2), | |
color: ratio >= 0.5 ? COLORS.white : COLORS["lantern-dark-blue"], | |
}; | |
const isCellSelected = | |
(get(filters, VIOLATION_TYPE) === "All" || | |
get(filters, VIOLATION_TYPE) === violationType) && | |
(get(filters, REPORTED_VIOLATIONS) === "All" || | |
get(filters, REPORTED_VIOLATIONS) === reportedViolations); | |
return ( | |
<div className="Matrix__cell"> | |
<div style={containerStyle}> | |
<button | |
type="button" | |
className={cx("Matrix__total-revocations", { | |
"is-selected": isCellSelected, | |
})} | |
onClick={onClick} | |
style={cellStyle} | |
> | |
{count} | |
</button> | |
</div> | |
</div> | |
); | |
}; | |
MatrixCell.propTypes = { | |
count: PropTypes.number.isRequired, | |
maxCount: PropTypes.number.isRequired, | |
violationType: PropTypes.string.isRequired, | |
reportedViolations: PropTypes.string.isRequired, | |
onClick: PropTypes.func.isRequired, | |
}; | |
export default MatrixCell; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { makeAutoObservable, when } from "mobx"; | |
import filter from "lodash/fp/filter"; | |
import identity from "lodash/fp/identity"; | |
import map from "lodash/fp/map"; | |
import pipe from "lodash/fp/pipe"; | |
import sortBy from "lodash/fp/sortBy"; | |
import uniq from "lodash/fp/uniq"; | |
import { getAvailableStateCodes, doesUserHaveAccess } from "./utils/user"; | |
import { LANTERN_TENANTS } from "../views/tenants/utils/lanternTenants"; | |
export const CURRENT_TENANT_IN_SESSION = "adminUserCurrentTenantInSession"; | |
/* | |
* Returns the current state that should be viewed. This is retrieved from | |
* the sessionStorage cache if already set. Otherwise, picks the first available state in ABC order. | |
*/ | |
function getTenantIdFromUser(user) { | |
const fromStorage = sessionStorage.getItem(CURRENT_TENANT_IN_SESSION); | |
if (user) { | |
const availableStateCodes = getAvailableStateCodes(user); | |
if (fromStorage && doesUserHaveAccess(user, fromStorage)) { | |
return fromStorage; | |
} | |
return availableStateCodes[0]; | |
} | |
return fromStorage; | |
} | |
export default class TenantStore { | |
rootStore; | |
currentTenantId = null; | |
districts = []; | |
districtsIsLoading = true; | |
constructor({ rootStore }) { | |
makeAutoObservable(this); | |
this.rootStore = rootStore; | |
when( | |
() => !this.rootStore.userStore.userIsLoading, | |
() => this.setCurrentTenantId(getTenantIdFromUser(this.rootStore.user)) | |
); | |
} | |
setCurrentTenantId(tenantId) { | |
this.currentTenantId = tenantId; | |
sessionStorage.setItem(CURRENT_TENANT_IN_SESSION, tenantId); | |
this.districtsIsLoading = true; | |
} | |
setDistricts(apiData) { | |
if (apiData) { | |
const data = apiData.slice(); | |
this.districts = pipe( | |
map("district"), | |
filter((d) => d.toLowerCase() !== "all"), | |
uniq, | |
sortBy(identity) | |
)(data); | |
this.districtsIsLoading = false; | |
} | |
} | |
get isLanternTenant() { | |
return LANTERN_TENANTS.includes(this.currentTenantId); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import createAuth0Client, { Auth0ClientOptions } from "@auth0/auth0-spa-js"; | |
import { makeAutoObservable, runInAction, autorun, flow } from "mobx"; | |
import qs from "qs"; | |
import * as Sentry from "@sentry/react"; | |
import { ERROR_MESSAGES } from "../constants/errorMessages"; | |
import type RootStore from "./RootStore"; | |
import { | |
getUserStateCode, | |
getStateNameForCode, | |
getAvailableStateCodes, | |
} from "./utils/user"; | |
import { callRestrictedAccessApi } from "../api/metrics/metricsClient"; | |
import isDemoMode from "../utils/authentication/demoMode"; | |
import { getDemoUser } from "../utils/authentication/viewAuthentication"; | |
type ConstructorProps = { | |
authSettings?: Auth0ClientOptions; | |
rootStore?: RootStore; | |
}; | |
type RestrictedAccessEmail = { | |
// eslint-disable-next-line camelcase | |
restricted_user_email: string; | |
// eslint-disable-next-line camelcase | |
allowed_level_1_supervision_location_ids: string; | |
}; | |
/** | |
* Reactive wrapper around Auth0 client. | |
* Call `authorize` to retrieve credentials or start login flow. | |
* | |
* @example | |
* | |
* ```js | |
* const store = new UserStore({ authSettings: { domain, client_id, redirect_uri } }); | |
* if (!store.isAuthorized) { | |
* await store.authorize(); | |
* // this may trigger a redirect to the Auth0 login domain; | |
* // if we're still here and user has successfully logged in, | |
* // store.isAuthorized should now be true. | |
* } | |
* ``` | |
*/ | |
export default class UserStore { | |
authError?: Error; | |
readonly authSettings?: Auth0ClientOptions; | |
isAuthorized: boolean; | |
userIsLoading: boolean; | |
// TODO TS create user type | |
user: any; | |
getTokenSilently?: () => void; | |
logout?: () => void; | |
restrictedDistrict?: string; | |
restrictedDistrictIsLoading: boolean; | |
readonly rootStore?: RootStore; | |
constructor({ authSettings, rootStore }: ConstructorProps) { | |
makeAutoObservable(this, { | |
rootStore: false, | |
authSettings: false, | |
}); | |
this.authSettings = authSettings; | |
this.rootStore = rootStore; | |
this.isAuthorized = false; | |
this.userIsLoading = true; | |
this.restrictedDistrictIsLoading = true; | |
autorun(() => { | |
if (!this.userIsLoading && this.rootStore?.currentTenantId) { | |
this.fetchRestrictedDistrictData(this.rootStore?.currentTenantId); | |
} | |
}); | |
} | |
/** | |
* If user already has a valid Auth0 credential, this method will retrieve it | |
* and update class properties accordingly. If not, user will be redirected | |
* to the Auth0 login domain for fresh authentication. | |
* Returns an Error if Auth0 configuration is not present. | |
*/ | |
async authorize(): Promise<void> { | |
if (isDemoMode()) { | |
this.isAuthorized = true; | |
this.userIsLoading = false; | |
this.user = getDemoUser(); | |
this.getTokenSilently = () => ""; | |
return; | |
} | |
if (!this.authSettings) { | |
this.authError = new Error(ERROR_MESSAGES.auth0Configuration); | |
return; | |
} | |
try { | |
const auth0 = await createAuth0Client(this.authSettings); | |
const urlQuery = qs.parse(window.location.search, { | |
ignoreQueryPrefix: true, | |
}); | |
if (urlQuery.code && urlQuery.state) { | |
const { appState } = await auth0.handleRedirectCallback(); | |
// auth0 params are single-use, must be removed from history or they can cause errors | |
let replacementUrl; | |
if (appState && appState.targetUrl) { | |
replacementUrl = appState.targetUrl; | |
} else { | |
// strip away all query params just to be safe | |
replacementUrl = `${window.location.origin}${window.location.pathname}`; | |
} | |
window.history.replaceState({}, document.title, replacementUrl); | |
} | |
if (await auth0.isAuthenticated()) { | |
const user = await auth0.getUser(); | |
runInAction(() => { | |
this.userIsLoading = false; | |
if (user && user.email_verified) { | |
this.user = user; | |
this.getTokenSilently = (...p: any) => auth0.getTokenSilently(...p); | |
this.logout = (...p: any) => auth0.logout(...p); | |
this.isAuthorized = true; | |
} else { | |
this.isAuthorized = false; | |
} | |
}); | |
} else { | |
auth0.loginWithRedirect({ | |
appState: { targetUrl: window.location.href }, | |
}); | |
} | |
} catch (error) { | |
this.authError = error; | |
} | |
} | |
/** | |
* Returns the list of states which are accessible to users to view data for. | |
* | |
*/ | |
get availableStateCodes(): Array<string> { | |
return getAvailableStateCodes(this.user); | |
} | |
/** | |
* Returns the human-readable state name for the authorized state code for the given usere. | |
*/ | |
get stateName(): string { | |
return getStateNameForCode(this.stateCode); | |
} | |
/** | |
* Returns the state code of the authorized state for the given user. | |
* For Recidiviz users or users in demo mode, this will be 'recidiviz'. | |
*/ | |
get stateCode(): string { | |
return getUserStateCode(this.user); | |
} | |
fetchRestrictedDistrictData = flow(function* ( | |
this: UserStore, | |
tenantId: string | |
) { | |
if (!this.rootStore?.tenantStore.isLanternTenant) { | |
this.restrictedDistrictIsLoading = false; | |
return; | |
} | |
const file = "supervision_location_restricted_access_emails"; | |
const endpoint = `${tenantId}/restrictedAccess`; | |
try { | |
this.restrictedDistrict = undefined; | |
const responseData = yield callRestrictedAccessApi( | |
endpoint, | |
this.user.email, | |
this.getTokenSilently | |
); | |
this.setRestrictedDistrict(responseData[file]); | |
this.restrictedDistrictIsLoading = false; | |
} catch (error) { | |
Sentry.captureException(error, { | |
tags: { | |
tenantId, | |
endpoint, | |
availableStateCodes: this.availableStateCodes.join(","), | |
}, | |
}); | |
this.authError = new Error(ERROR_MESSAGES.unauthorized); | |
this.restrictedDistrictIsLoading = false; | |
} | |
}); | |
setRestrictedDistrict(restrictedEmail: RestrictedAccessEmail): void { | |
this.restrictedDistrict = | |
restrictedEmail && | |
restrictedEmail.allowed_level_1_supervision_location_ids; | |
this.verifyRestrictedDistrict(); | |
} | |
verifyRestrictedDistrict(): void { | |
if ( | |
this.restrictedDistrict && | |
!this.rootStore?.tenantStore.districtsIsLoading && | |
!this.rootStore?.tenantStore.districts.includes(this.restrictedDistrict) | |
) { | |
const authError = new Error(ERROR_MESSAGES.unauthorized); | |
Sentry.captureException(authError, { | |
tags: { | |
restrictedDistrict: this.restrictedDistrict, | |
}, | |
}); | |
this.authError = authError; | |
this.restrictedDistrictIsLoading = false; | |
this.restrictedDistrict = undefined; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment