diff --git a/src/hooks/useFenceConnections.ts b/src/hooks/useFenceConnections.ts new file mode 100644 index 000000000..161f2bf9f --- /dev/null +++ b/src/hooks/useFenceConnections.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + concatAllFencesAcls, + fetchAllFencesConnectionsIfNeeded, +} from 'store/actionCreators/fenceConnections'; +import { Api } from 'store/apiTypes'; +import { DispatchFenceConnections, FenceConnections } from 'store/fenceConnectionsTypes'; +import { FencesAllConcatenatedAcls } from 'store/fenceTypes'; +import { RootState } from 'store/rootState'; +import { + selectFenceConnections, + selectIsFetchingAllFenceConnections, +} from 'store/selectors/fenceConnections'; + +type Output = { + isFetchingAllFenceConnections: boolean; + fenceConnections: FenceConnections; + fencesAllAcls: FencesAllConcatenatedAcls; +}; + +const useFenceConnections = (api: Api, fences: string[]): Output => { + const fenceConnections = useSelector((state: RootState) => selectFenceConnections(state)); + const isFetchingAllFenceConnections = useSelector((state: RootState) => + selectIsFetchingAllFenceConnections(state), + ); + const dispatch: DispatchFenceConnections = useDispatch(); + + useEffect(() => { + dispatch(fetchAllFencesConnectionsIfNeeded(api, fences)); + }, [fences, dispatch, api]); + + return { + isFetchingAllFenceConnections, + fenceConnections, + fencesAllAcls: concatAllFencesAcls(fenceConnections), + }; +}; +export default useFenceConnections; diff --git a/src/hooks/useFenceStudies.ts b/src/hooks/useFenceStudies.ts new file mode 100644 index 000000000..ed6ddac9c --- /dev/null +++ b/src/hooks/useFenceStudies.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { computeAclsByFence } from 'store/actionCreators/fenceConnections'; +import { + computeAllFencesAuthStudies, + fetchAllFenceStudiesIfNeeded, +} from 'store/actionCreators/fenceStudies'; +import { Api } from 'store/apiTypes'; +import { DispatchFenceStudies, FenceStudies, FenceStudy } from 'store/fenceStudiesTypes'; +import { RootState } from 'store/rootState'; +import { selectFenceConnections } from 'store/selectors/fenceConnections'; +import { selectFenceStudies, selectIsFetchingAllFenceStudies } from 'store/selectors/fenceStudies'; + +const { keys } = Object; + +type Output = { + isFetchingAllFenceStudies: boolean; + fenceStudies: FenceStudies; + fenceAuthStudies: FenceStudy[]; +}; + +const useFenceStudies = (api: Api): Output => { + const dispatch: DispatchFenceStudies = useDispatch(); + + const isFetchingAllFenceStudies = useSelector((state: RootState) => + selectIsFetchingAllFenceStudies(state), + ); + const fenceStudies = useSelector((state: RootState) => selectFenceStudies(state)); + const fenceConnections = useSelector((state: RootState) => selectFenceConnections(state)); + + useEffect(() => { + const fences = keys(fenceConnections); + const shouldFetch = fences.length > 0; + if (shouldFetch) { + const aclsByFence = computeAclsByFence(fenceConnections); + dispatch(fetchAllFenceStudiesIfNeeded(api, fences, aclsByFence)); + } + }, [fenceConnections, dispatch, api]); + + return { + isFetchingAllFenceStudies, + fenceStudies, + fenceAuthStudies: computeAllFencesAuthStudies(fenceStudies), + }; +}; +export default useFenceStudies; diff --git a/src/services/fenceStudies.js b/src/services/fenceStudies.js new file mode 100644 index 000000000..e34a06dbe --- /dev/null +++ b/src/services/fenceStudies.js @@ -0,0 +1,99 @@ +import keys from 'lodash/keys'; + +import { graphql } from 'services/arranger'; + +export const getAuthStudiesIdAndCount = async (api, fence, userAcl) => + graphql(api)({ + query: ` + query AuthorizedStudyIdsAndCount($sqon: JSON) { + file { + aggregations(filters: $sqon, aggregations_filter_themselves: true){ + participants__study__external_id{ + buckets{ + key + doc_count} + } + } + } + } + `, + variables: { + sqon: { + op: 'and', + content: [ + { op: 'in', content: { field: 'acl', value: userAcl } }, + { op: 'in', content: { field: 'repository', value: fence } }, + ], + }, + }, + }).then( + ({ + data: { + file: { + aggregations: { + participants__study__external_id: { buckets }, + }, + }, + }, + }) => + buckets.reduce((obj, { key, doc_count }) => { + obj[key] = { authorizedFiles: doc_count }; + return obj; + }, {}), + ); + +export const getStudiesCountByNameAndAcl = async (api, studies, userAcl) => { + const studyIds = keys(studies); + + const sqons = studyIds.reduce((obj, studyId) => { + obj[`${studyId}_sqon`] = { + op: 'in', + content: { field: 'participants.study.external_id', value: [studyId] }, + }; + return obj; + }, {}); + + return graphql(api)({ + query: ` + query StudyCountByNamesAndAcl(${studyIds.map( + (studyId) => `$${studyId}_sqon: JSON`, + )}) { + file { + ${studyIds + .map( + (studyId) => ` + ${studyId}: aggregations(filters: $${studyId}_sqon, aggregations_filter_themselves: true) { + acl { + buckets { + key + } + } + participants__study__short_name{ + buckets{ + key + doc_count + } + } + } + `, + ) + .join('')} + + } + } + `, + variables: sqons, + }).then(({ data: { file } }) => + studyIds.map((id) => { + let study = {}; + const agg = file[id]; + study['acl'] = agg['acl']['buckets'].map((b) => b.key).filter((a) => userAcl.includes(a)); + study['studyShortName'] = agg['participants__study__short_name']['buckets'][0]['key']; + study['totalFiles'] = agg['participants__study__short_name']['buckets'][0]['doc_count']; + study['id'] = id; + study['authorizedFiles'] = studies[id]['authorizedFiles']; + + return study; + }), + ); +}; diff --git a/src/store/actionCreators/fenceConnections.ts b/src/store/actionCreators/fenceConnections.ts new file mode 100644 index 000000000..f819cfdc8 --- /dev/null +++ b/src/store/actionCreators/fenceConnections.ts @@ -0,0 +1,83 @@ +import isEmpty from 'lodash/isEmpty'; +import { ThunkAction } from 'redux-thunk'; + +import { getFenceUser } from 'services/fence'; + +import { Api } from '../apiTypes'; +import { + Connection, + FenceConnections, + FenceConnectionsActions, + FenceConnectionsActionTypes, +} from '../fenceConnectionsTypes'; +import { FenceName } from '../fenceTypes'; +import { RootState } from '../rootState'; +import { selectFenceConnections } from '../selectors/fenceConnections'; + +const { entries, keys } = Object; + +const addFenceConnection = ( + fenceName: FenceName, + connection: Connection, +): FenceConnectionsActionTypes => ({ + type: FenceConnectionsActions.addFenceConnection, + fenceName, + connection, +}); + +const toggleIsFetchingAllFenceConnections = (isLoading: boolean): FenceConnectionsActionTypes => ({ + type: FenceConnectionsActions.toggleIsFetchingAllFenceConnections, + isLoading, +}); + +const shouldFetchConnections = (fenceName: FenceName, state: RootState): boolean => + isEmpty(selectFenceConnections(state)[fenceName]); + +const fetchFencesConnections = ( + api: Api, + fenceName: FenceName, +): ThunkAction => async (dispatch) => { + try { + const connection = await getFenceUser(api, fenceName); + dispatch(addFenceConnection(fenceName, connection)); + } catch (error) { + console.error(`Error fetching fence connection for '${fenceName}': ${error}`); + } +}; + +export const fetchFencesConnectionsIfNeeded = ( + api: Api, + fenceName: FenceName, +): ThunkAction => async ( + dispatch, + getState, +) => { + if (shouldFetchConnections(fenceName, getState())) { + return dispatch(fetchFencesConnections(api, fenceName)); + } +}; + +export const fetchAllFencesConnectionsIfNeeded = ( + api: Api, + fencesName: FenceName[], +): ThunkAction => async (dispatch) => { + dispatch(toggleIsFetchingAllFenceConnections(true)); + for (const fenceName of fencesName) { + await dispatch(fetchFencesConnectionsIfNeeded(api, fenceName)); + } + dispatch(toggleIsFetchingAllFenceConnections(false)); +}; + +export const concatAllFencesAcls = (fenceConnections: FenceConnections) => { + const fenceNames = keys(fenceConnections); + return fenceNames.map((fence: FenceName) => keys(fenceConnections[fence].projects)).flat(); +}; + +export const computeAclsByFence = (fenceConnections: FenceConnections) => + entries(fenceConnections).reduce( + (acc, [fenceName, connection]) => ({ + ...acc, + [fenceName]: keys(connection.projects || {}), + }), + {}, + ); diff --git a/src/store/actionCreators/fenceStudies.ts b/src/store/actionCreators/fenceStudies.ts new file mode 100644 index 000000000..2fd43f1df --- /dev/null +++ b/src/store/actionCreators/fenceStudies.ts @@ -0,0 +1,81 @@ +import flatMap from 'lodash/flatMap'; +import isEmpty from 'lodash/isEmpty'; +import { ThunkAction } from 'redux-thunk'; + +import { getAuthStudiesIdAndCount, getStudiesCountByNameAndAcl } from 'services/fenceStudies'; + +import { Api } from '../apiTypes'; +import { FenceStudies, FenceStudiesActions, FenceStudiesActionTypes } from '../fenceStudiesTypes'; +import { AclsByFence, FenceName, UserAcls } from '../fenceTypes'; +import { RootState } from '../rootState'; +import { selectFenceStudies } from '../selectors/fenceStudies'; + +const addWildCardToAcls = (acls: UserAcls) => [...(acls || []), '*']; + +const toggleIsFetchingAllFenceStudies = (isLoading: boolean): FenceStudiesActionTypes => ({ + type: FenceStudiesActions.toggleIsFetchingAllFenceStudies, + isLoading, +}); + +const addFenceStudies = (fenceAuthorizedStudies: FenceStudies): FenceStudiesActionTypes => ({ + type: FenceStudiesActions.addFenceStudies, + fenceAuthorizedStudies: fenceAuthorizedStudies, +}); + +const shouldFetchFenceStudies = (fenceName: FenceName, state: RootState) => { + const studiesForFence = selectFenceStudies(state)[fenceName]; + return isEmpty(studiesForFence) || isEmpty(studiesForFence.authorizedStudies); +}; + +const fetchFenceStudies = ( + api: Api, + fenceName: FenceName, + userAcls: UserAcls, +): ThunkAction => async (dispatch) => { + try { + const aclsWithWildCard = addWildCardToAcls(userAcls); + const studies = await getAuthStudiesIdAndCount(api, fenceName, aclsWithWildCard); + const authorizedStudies = isEmpty(studies) + ? [] + : await getStudiesCountByNameAndAcl(api, studies, aclsWithWildCard); + dispatch( + addFenceStudies({ + [fenceName]: { + authorizedStudies: authorizedStudies || [], + }, + }), + ); + } catch (error) { + console.error(`Error fetching fence studies for '${fenceName}': ${error}`); + } +}; + +const fetchFenceStudiesIfNeeded = ( + api: Api, + fenceName: FenceName, + aclsByFence: AclsByFence, +): ThunkAction => async (dispatch, getState) => { + if (shouldFetchFenceStudies(fenceName, getState())) { + const userAcls = aclsByFence[fenceName]; + return dispatch(fetchFenceStudies(api, fenceName, userAcls)); + } +}; + +export const fetchAllFenceStudiesIfNeeded = ( + api: Api, + fencesName: FenceName[], + aclsByFence: AclsByFence, +): ThunkAction => async (dispatch) => { + dispatch(toggleIsFetchingAllFenceStudies(true)); + for (const fenceName of fencesName) { + await dispatch(fetchFenceStudiesIfNeeded(api, fenceName, aclsByFence)); + } + dispatch(toggleIsFetchingAllFenceStudies(false)); +}; + +export const computeAllFencesAuthStudies = (fenceStudies: FenceStudies) => { + if (isEmpty(fenceStudies)) { + return []; + } + return flatMap(Object.values(fenceStudies), (studies) => studies.authorizedStudies); +}; diff --git a/src/store/fenceConnectionsTypes.ts b/src/store/fenceConnectionsTypes.ts new file mode 100644 index 000000000..6b9c2fa6a --- /dev/null +++ b/src/store/fenceConnectionsTypes.ts @@ -0,0 +1,89 @@ +import { ThunkDispatch } from 'redux-thunk'; + +import { FenceName } from './fenceTypes'; +import { RootState } from './rootState'; + +export type Projects = { [index: string]: any }; + +export type Connection = { + authz: { [index: string]: any }; + azp: string; + certificates_uploaded: any[]; + display_name: string; + email: string; + groups: any[]; + is_admin: boolean; + message: string; + name: string; + phone_number: null; + preferred_username: null; + primary_google_service_account: null; + project_access: { [index: string]: any }; + projects: Projects; + resources: any[]; + resources_granted: any[]; + role: string; + sub: number; + user_id: number; + username: string; +}; + +export type FenceConnections = { [fenceName: string]: Connection }; + +export enum FenceConnectionsActions { + requestFetchFenceConnections = 'requestFetchFenceConnections', + fetchFenceConnections = 'fetchFenceConnections', + failureFetchingFenceConnections = 'failureFetchingFenceConnections', + toggleIsFetchingAllFenceConnections = 'toggleIsFetchingAllFenceConnections', + addFenceConnection = 'addFenceConnection', + requestFetchFenceStudies = 'requestFetchFenceStudies', + fetchFenceStudies = 'fetchFenceStudies', + failureFetchingFenceStudies = 'failureFetchingFenceStudies', +} + +export type RequestFetchFenceStudiesAction = { + type: FenceConnectionsActions.requestFetchFenceStudies; +}; + +export type FetchFenceStudiesAction = { + type: FenceConnectionsActions.fetchFenceStudies; +}; + +export type FailureFetchingFenceStudiesAction = { + type: FenceConnectionsActions.failureFetchingFenceStudies; +}; + +export type ToggleIsFetchingAllFenceConnectionsAction = { + type: FenceConnectionsActions.toggleIsFetchingAllFenceConnections; + isLoading: boolean; +}; + +export type AddFenceConnectionsAction = { + type: FenceConnectionsActions.addFenceConnection; + fenceName: FenceName; + connection: Connection; +}; + +export type RequestFetchFenceConnectionsAction = { + type: FenceConnectionsActions.requestFetchFenceConnections; +}; + +export type FetchFenceConnectionsAction = { + type: FenceConnectionsActions.fetchFenceConnections; +}; + +export type FenceConnectionsState = { + fenceConnections: { [fenceName: string]: Connection }; + isFetchingAllFenceConnections: boolean; +}; + +export type FenceConnectionsActionTypes = + | RequestFetchFenceConnectionsAction + | FetchFenceConnectionsAction + | AddFenceConnectionsAction + | ToggleIsFetchingAllFenceConnectionsAction + | RequestFetchFenceStudiesAction + | FetchFenceStudiesAction + | FailureFetchingFenceStudiesAction; + +export type DispatchFenceConnections = ThunkDispatch; diff --git a/src/store/fenceStudiesTypes.ts b/src/store/fenceStudiesTypes.ts new file mode 100644 index 000000000..18afe6b7d --- /dev/null +++ b/src/store/fenceStudiesTypes.ts @@ -0,0 +1,43 @@ +import { ThunkDispatch } from 'redux-thunk'; + +import { UserAcls } from './fenceTypes'; +import { RootState } from './rootState'; + +export type FenceStudy = { + acl: UserAcls; + studyShortName: string; + totalFiles: number; + id: string; + authorizedFiles: number; +}; + +export type FenceStudies = { + [fenceName: string]: { + authorizedStudies: FenceStudy[]; + }; +}; + +export enum FenceStudiesActions { + toggleIsFetchingAllFenceStudies = 'toggleIsFetchingAllFenceStudies', + fetchFenceStudies = 'fetchFenceStudies', + addFenceStudies = 'addFenceStudies', +} + +export type ToggleIsFetchingAllFenceStudiesAction = { + type: FenceStudiesActions.toggleIsFetchingAllFenceStudies; + isLoading: boolean; +}; + +export type AddFenceStudiesAction = { + type: FenceStudiesActions.addFenceStudies; + fenceAuthorizedStudies: FenceStudies; +}; + +export type FenceStudiesState = { + fenceStudies: FenceStudies; + isFetchingAllFenceStudies: boolean; +}; + +export type FenceStudiesActionTypes = ToggleIsFetchingAllFenceStudiesAction | AddFenceStudiesAction; + +export type DispatchFenceStudies = ThunkDispatch; diff --git a/src/store/fenceTypes.ts b/src/store/fenceTypes.ts new file mode 100644 index 000000000..e6d13d0e2 --- /dev/null +++ b/src/store/fenceTypes.ts @@ -0,0 +1,9 @@ +export type FenceName = string; + +export type AclsByFence = { + [fenceName: string]: string[]; +}; + +export type UserAcls = string[]; + +export type FencesAllConcatenatedAcls = string[]; diff --git a/src/store/reducers/fenceConnections.ts b/src/store/reducers/fenceConnections.ts new file mode 100644 index 000000000..dd485ba66 --- /dev/null +++ b/src/store/reducers/fenceConnections.ts @@ -0,0 +1,32 @@ +import { + FenceConnectionsActions, + FenceConnectionsActionTypes, + FenceConnectionsState, +} from '../fenceConnectionsTypes'; + +const initialState: FenceConnectionsState = { + fenceConnections: {}, + isFetchingAllFenceConnections: false, +}; + +export default ( + state = initialState, + action: FenceConnectionsActionTypes, +): FenceConnectionsState => { + switch (action.type) { + case FenceConnectionsActions.toggleIsFetchingAllFenceConnections: { + return { ...state, isFetchingAllFenceConnections: action.isLoading }; + } + case FenceConnectionsActions.addFenceConnection: { + return { + ...state, + fenceConnections: { + ...state.fenceConnections, + [action.fenceName]: { ...action.connection }, + }, + }; + } + default: + return state; + } +}; diff --git a/src/store/reducers/fenceStudies.ts b/src/store/reducers/fenceStudies.ts new file mode 100644 index 000000000..da458a89b --- /dev/null +++ b/src/store/reducers/fenceStudies.ts @@ -0,0 +1,29 @@ +import { + FenceStudiesActions, + FenceStudiesActionTypes, + FenceStudiesState, +} from '../fenceStudiesTypes'; + +const initialState: FenceStudiesState = { + fenceStudies: {}, + isFetchingAllFenceStudies: false, +}; + +export default (state = initialState, action: FenceStudiesActionTypes): FenceStudiesState => { + switch (action.type) { + case FenceStudiesActions.toggleIsFetchingAllFenceStudies: { + return { ...state, isFetchingAllFenceStudies: action.isLoading }; + } + case FenceStudiesActions.addFenceStudies: { + return { + ...state, + fenceStudies: { + ...state.fenceStudies, + ...action.fenceAuthorizedStudies, + }, + }; + } + default: + return state; + } +}; diff --git a/src/store/reducers/index.js b/src/store/reducers/index.js index f1c85c0d9..70ddcfbe2 100644 --- a/src/store/reducers/index.js +++ b/src/store/reducers/index.js @@ -2,6 +2,8 @@ import { combineReducers } from 'redux'; import currentVirtualStudy from './currentVirtualStudy'; import enableFeatures from './enableFeatures'; +import fenceConnections from './fenceConnections'; +import fenceStudies from './fenceStudies'; import fileSearchFilters from './fileSearchFilters'; import genomicSuggester from './genomicSuggester'; import modal from './modal'; @@ -26,4 +28,6 @@ export default combineReducers({ genomicSuggester, workBench, savedQueries, + fenceConnections, + fenceStudies, }); diff --git a/src/store/rootState.d.ts b/src/store/rootState.d.ts index b87cc342e..21280a3f7 100644 --- a/src/store/rootState.d.ts +++ b/src/store/rootState.d.ts @@ -1,5 +1,7 @@ import { ModalStateType } from './reducers/modal'; import { CurrentVirtualStudyTypes } from './currentVirtualStudyTypes'; +import { FenceConnectionsState } from './fenceConnectionsTypes'; +import { FenceStudiesState } from './fenceStudiesTypes'; import { FileSearchFiltersState } from './fileSearchFiltersTypes'; import { GenomicSuggesterState } from './genomicSuggesterTypes'; import { ReportState } from './reportTypes'; @@ -24,4 +26,6 @@ export interface RootState { genomicSuggester: GenomicSuggesterState; workBench: WorkBenchState; savedQueries: SavedQueriesState; + fenceConnections: FenceConnectionsState; + fenceStudies: FenceStudiesState; } diff --git a/src/store/selectors/fenceConnections.ts b/src/store/selectors/fenceConnections.ts new file mode 100644 index 000000000..ff5249428 --- /dev/null +++ b/src/store/selectors/fenceConnections.ts @@ -0,0 +1,5 @@ +import { RootState } from '../rootState'; + +export const selectFenceConnections = (state: RootState) => state.fenceConnections.fenceConnections; +export const selectIsFetchingAllFenceConnections = (state: RootState) => + state.fenceConnections.isFetchingAllFenceConnections; diff --git a/src/store/selectors/fenceStudies.ts b/src/store/selectors/fenceStudies.ts new file mode 100644 index 000000000..59474d239 --- /dev/null +++ b/src/store/selectors/fenceStudies.ts @@ -0,0 +1,5 @@ +import { RootState } from '../rootState'; + +export const selectFenceStudies = (state: RootState) => state.fenceStudies.fenceStudies; +export const selectIsFetchingAllFenceStudies = (state: RootState) => + state.fenceStudies.isFetchingAllFenceStudies;