diff --git a/packages/libs/web-common/src/App/Categories/CategoryIcon.jsx b/packages/libs/web-common/src/App/Categories/CategoryIcon.tsx similarity index 76% rename from packages/libs/web-common/src/App/Categories/CategoryIcon.jsx rename to packages/libs/web-common/src/App/Categories/CategoryIcon.tsx index d8137b6be1..0a9a6b46af 100755 --- a/packages/libs/web-common/src/App/Categories/CategoryIcon.jsx +++ b/packages/libs/web-common/src/App/Categories/CategoryIcon.tsx @@ -6,13 +6,19 @@ import { Tooltip } from '@veupathdb/coreui'; import { getCategoryColor } from './CategoryUtils'; -class CategoryIcon extends React.Component { +interface CategoryIconProps { + category?: string; +} + +class CategoryIcon extends React.Component { render() { const { category } = this.props; if (!category || category === 'Unknown') return null; const categoryName = capitalize(category); const categoryColor = getCategoryColor(category); - const categoryStyle = { backgroundColor: categoryColor }; + const categoryStyle = categoryColor + ? { backgroundColor: categoryColor } + : undefined; return (
diff --git a/packages/libs/web-common/src/App/Categories/CategoryUtils.js b/packages/libs/web-common/src/App/Categories/CategoryUtils.tsx similarity index 91% rename from packages/libs/web-common/src/App/Categories/CategoryUtils.js rename to packages/libs/web-common/src/App/Categories/CategoryUtils.tsx index fbabd210d4..7718906a5b 100755 --- a/packages/libs/web-common/src/App/Categories/CategoryUtils.js +++ b/packages/libs/web-common/src/App/Categories/CategoryUtils.tsx @@ -1,6 +1,6 @@ import React from 'react'; -export function getCategoryColor(category) { +export function getCategoryColor(category: string | null | undefined): string | null { if (!category) return null; switch (category.toLowerCase()) { // This contains a mix of disease variables from https://webprotege.stanford.edu/#projects/719dd1bd-ffbb-4c15-99cc-050a233977ee/edit/Classes?selection=Class(%3Chttp://purl.obolibrary.org/obo/DOID_4%3E), @@ -37,7 +37,7 @@ export function getCategoryColor(category) { } } -export function getCategoryName(category = '') { +export function getCategoryName(category = ''): JSX.Element { switch (category.toLowerCase()) { case 'malarial': case 'malaria': diff --git a/packages/libs/web-common/src/App/Categories/index.js b/packages/libs/web-common/src/App/Categories/index.ts similarity index 100% rename from packages/libs/web-common/src/App/Categories/index.js rename to packages/libs/web-common/src/App/Categories/index.ts diff --git a/packages/libs/web-common/src/App/Header/Header.jsx b/packages/libs/web-common/src/App/Header/Header.tsx similarity index 71% rename from packages/libs/web-common/src/App/Header/Header.jsx rename to packages/libs/web-common/src/App/Header/Header.tsx index d35b7c6b12..9c90987167 100755 --- a/packages/libs/web-common/src/App/Header/Header.jsx +++ b/packages/libs/web-common/src/App/Header/Header.tsx @@ -11,8 +11,38 @@ import './Header.scss'; import HeaderNav from './HeaderNav'; +interface HeaderOwnProps { + getSiteData: (state: any) => any; + makeHeaderMenuItems: (state: any, props: any) => any; + heroImageUrl: string; + heroImagePosition: string; + titleWithoutDB: string; + subTitle: string; + tagline: string; + logoUrl: string; +} + +interface HeaderStateProps { + user: any; + config: any; + siteConfig: any; + preferences: any; + siteData: any; + dataRestriction: any; + headerMenuItems: any; +} + +interface HeaderDispatchProps { + actions: typeof UserActions & + typeof UserSessionActions & { + requestStudies: typeof requestStudies; + }; +} + +type HeaderProps = HeaderOwnProps & HeaderStateProps & HeaderDispatchProps; + const enhance = connect( - (state, props) => { + (state: any, props: HeaderOwnProps) => { const { getSiteData, makeHeaderMenuItems } = props; const headerMenuItems = makeHeaderMenuItems(state, props); const siteData = getSiteData(state); @@ -29,12 +59,12 @@ const enhance = connect( }; }, { ...UserActions, ...UserSessionActions, requestStudies }, - (stateProps, actions, ownProps) => { + (stateProps: any, actions: any, ownProps: any) => { return { ...stateProps, ...ownProps, actions }; } ); -class Header extends React.Component { +class Header extends React.Component { componentDidMount() { this.props.actions.requestStudies(); } diff --git a/packages/libs/web-common/src/App/Header/HeaderNav.jsx b/packages/libs/web-common/src/App/Header/HeaderNav.tsx similarity index 88% rename from packages/libs/web-common/src/App/Header/HeaderNav.jsx rename to packages/libs/web-common/src/App/Header/HeaderNav.tsx index 7b0558b551..b34eebcdb3 100755 --- a/packages/libs/web-common/src/App/Header/HeaderNav.jsx +++ b/packages/libs/web-common/src/App/Header/HeaderNav.tsx @@ -13,8 +13,48 @@ import { SiteSearchInput } from '../../components'; import './HeaderNav.scss'; -class HeaderNav extends React.Component { - constructor(props) { +interface MenuItem { + type?: string; + url?: string; + name?: string; + text?: string; +} + +interface HeaderMenuItems { + mainMenu: any[]; + iconMenu: MenuItem[]; +} + +interface HeaderNavProps { + headerMenuItems: HeaderMenuItems; + config?: { + buildNumber?: string; + releaseDate?: string; + }; + siteConfig: { + webAppUrl: string; + rootUrl: string; + }; + siteData?: any; + user?: { + isGuest: boolean; + }; + actions: any; + titleWithoutDB: string; + subTitle: string; + logoUrl: string; + heroImageUrl: string; + tagline: string; +} + +interface HeaderNavState { + stickyHeaderVisible: boolean; +} + +class HeaderNav extends React.Component { + private scrollListener: any; + + constructor(props: HeaderNavProps) { super(props); this.state = { stickyHeaderVisible: false }; @@ -107,7 +147,7 @@ class HeaderNav extends React.Component { ); } - renderBranding({ config = {}, titleWithoutDB, subTitle, logoUrl }) { + renderBranding({ config = {}, titleWithoutDB, subTitle, logoUrl }: any) { const { buildNumber, releaseDate } = config; return ( @@ -129,7 +169,7 @@ class HeaderNav extends React.Component { ); } - getIconByType(type = '') { + getIconByType(type: string = '') { if (typeof type !== 'string' || !type.length) return 'globe'; switch (type.toLowerCase()) { case 'facebook': @@ -152,7 +192,7 @@ class HeaderNav extends React.Component { } } - renderIconMenuItem({ type, url = '', name, text }) { + renderIconMenuItem({ type, url = '', name, text }: MenuItem) { const icon = this.getIconByType(type); return ( diff --git a/packages/libs/web-common/src/App/Header/index.js b/packages/libs/web-common/src/App/Header/index.ts similarity index 100% rename from packages/libs/web-common/src/App/Header/index.js rename to packages/libs/web-common/src/App/Header/index.ts diff --git a/packages/libs/web-common/src/App/Hero/Hero.jsx b/packages/libs/web-common/src/App/Hero/Hero.tsx similarity index 73% rename from packages/libs/web-common/src/App/Hero/Hero.jsx rename to packages/libs/web-common/src/App/Hero/Hero.tsx index 3081d0a1c0..7959e69d1d 100755 --- a/packages/libs/web-common/src/App/Hero/Hero.jsx +++ b/packages/libs/web-common/src/App/Hero/Hero.tsx @@ -2,11 +2,13 @@ import React from 'react'; import './Hero.scss'; -class Hero extends React.Component { - constructor(props) { - super(props); - } +interface HeroProps { + image: string; + position?: string; + children?: React.ReactNode; +} +class Hero extends React.Component { render() { const { image, position, children } = this.props; return ( diff --git a/packages/libs/web-common/src/App/Hero/index.js b/packages/libs/web-common/src/App/Hero/index.ts similarity index 100% rename from packages/libs/web-common/src/App/Hero/index.js rename to packages/libs/web-common/src/App/Hero/index.ts diff --git a/packages/libs/web-common/src/App/Home/Home.jsx b/packages/libs/web-common/src/App/Home/Home.tsx similarity index 81% rename from packages/libs/web-common/src/App/Home/Home.jsx rename to packages/libs/web-common/src/App/Home/Home.tsx index d88406ec4b..4d66077569 100755 --- a/packages/libs/web-common/src/App/Home/Home.jsx +++ b/packages/libs/web-common/src/App/Home/Home.tsx @@ -8,6 +8,25 @@ import { News } from '../../App/NewsSidebar'; import './HomePage.scss'; +interface HomePageProps { + newsSidebar: { + news?: { + records: any[]; + }; + error?: boolean; + }; + twitterUrl: string; + webAppUrl: string; + projectId: string; + siteData: { + studies: { + entities: any[]; + }; + }; + attemptAction?: (action: any) => void; + homeContent: any[]; +} + export default function HomePage({ newsSidebar, twitterUrl, @@ -16,7 +35,7 @@ export default function HomePage({ siteData, attemptAction, homeContent, -}) { +}: HomePageProps) { const { wdkService } = useContext(WdkDependenciesContext); const analysisClient = useMemo( () => diff --git a/packages/libs/web-common/src/App/Home/index.js b/packages/libs/web-common/src/App/Home/index.ts similarity index 100% rename from packages/libs/web-common/src/App/Home/index.js rename to packages/libs/web-common/src/App/Home/index.ts diff --git a/packages/libs/web-common/src/App/ImageCard/ImageCard.jsx b/packages/libs/web-common/src/App/ImageCard/ImageCard.tsx similarity index 78% rename from packages/libs/web-common/src/App/ImageCard/ImageCard.jsx rename to packages/libs/web-common/src/App/ImageCard/ImageCard.tsx index 328175860c..7b41df384f 100755 --- a/packages/libs/web-common/src/App/ImageCard/ImageCard.jsx +++ b/packages/libs/web-common/src/App/ImageCard/ImageCard.tsx @@ -4,11 +4,21 @@ import './ImageCard.scss'; import { IconAlt as Icon } from '@veupathdb/wdk-client/lib/Components'; -class ImageCard extends React.Component { - constructor(props) { - super(props); - } +interface ImageCardProps { + card: { + appImage?: string; + image?: string; + appUrl?: string; + url?: string; + title: string; + description: string; + linkText: string; + linkTarget?: string; + }; + prefix?: string; +} +class ImageCard extends React.Component { render() { const { card, prefix = '' } = this.props; const { diff --git a/packages/libs/web-common/src/App/ImageCard/index.js b/packages/libs/web-common/src/App/ImageCard/index.ts similarity index 100% rename from packages/libs/web-common/src/App/ImageCard/index.js rename to packages/libs/web-common/src/App/ImageCard/index.ts diff --git a/packages/libs/web-common/src/App/Modal/Modal.jsx b/packages/libs/web-common/src/App/Modal/Modal.tsx similarity index 81% rename from packages/libs/web-common/src/App/Modal/Modal.jsx rename to packages/libs/web-common/src/App/Modal/Modal.tsx index b932023845..68176c565b 100644 --- a/packages/libs/web-common/src/App/Modal/Modal.jsx +++ b/packages/libs/web-common/src/App/Modal/Modal.tsx @@ -4,7 +4,12 @@ import { useBodyScrollManager } from '@veupathdb/wdk-client/lib/Components/Overl import './Modal.scss'; -function Modal(props) { +interface ModalProps extends React.HTMLAttributes { + when?: boolean; + wrapperClassName?: string; +} + +function Modal(props: ModalProps) { const { when, wrapperClassName, ...divProps } = props; const active = typeof when === 'undefined' ? true : when; const finalWrapperClassName = diff --git a/packages/libs/web-common/src/App/Modal/index.js b/packages/libs/web-common/src/App/Modal/index.ts similarity index 100% rename from packages/libs/web-common/src/App/Modal/index.js rename to packages/libs/web-common/src/App/Modal/index.ts diff --git a/packages/libs/web-common/src/App/NewsSidebar/News.jsx b/packages/libs/web-common/src/App/NewsSidebar/News.tsx similarity index 90% rename from packages/libs/web-common/src/App/NewsSidebar/News.jsx rename to packages/libs/web-common/src/App/NewsSidebar/News.tsx index 0ce85b8217..46f5602768 100644 --- a/packages/libs/web-common/src/App/NewsSidebar/News.jsx +++ b/packages/libs/web-common/src/App/NewsSidebar/News.tsx @@ -24,7 +24,24 @@ function useNewsUrl() { return `${STATIC_ROUTE_PATH}/${(config || {}).displayName}/news.html`; } -const News = ({ twitterUrls, news, error }) => { +interface NewsRecord { + attributes: { + date: string; + tag: string; + headline: string; + item: string; + }; +} + +interface NewsProps { + twitterUrls: string[]; + news?: { + records: NewsRecord[]; + }; + error?: boolean; +} + +const News: React.FC = ({ twitterUrls, news, error }) => { const newsUrl = useNewsUrl(); return ( diff --git a/packages/libs/web-common/src/App/NewsSidebar/NewsModule.js b/packages/libs/web-common/src/App/NewsSidebar/NewsModule.js deleted file mode 100644 index a6263db123..0000000000 --- a/packages/libs/web-common/src/App/NewsSidebar/NewsModule.js +++ /dev/null @@ -1,36 +0,0 @@ -import { communitySite, projectId } from '../../config'; - -const NEWS_LOADING = 'news/loading'; -const NEWS_RECEIVED = 'news/received'; -const NEWS_ERROR = 'news/error'; - -export function requestNews() { - return [ - { type: NEWS_LOADING }, - fetch(`https://${communitySite}/${projectId}/news.json`, { mode: 'cors' }) - .then((res) => res.json()) - .then( - (news) => ({ type: NEWS_RECEIVED, payload: { news } }), - (error) => ({ type: NEWS_ERROR, payload: { error: error.message } }) - ), - ]; -} - -const defaultState = { - status: 'idle', - news: null, - error: null, -}; - -export function newsReducer(state = defaultState, action) { - switch (action.type) { - case NEWS_LOADING: - return { ...state, status: 'loading' }; - case NEWS_RECEIVED: - return { status: 'idle', error: null, news: action.payload.news }; - case NEWS_ERROR: - return { status: 'idle', error: action.payload.error, news: state.news }; - default: - return state; - } -} diff --git a/packages/libs/web-common/src/App/NewsSidebar/NewsModule.ts b/packages/libs/web-common/src/App/NewsSidebar/NewsModule.ts new file mode 100644 index 0000000000..0682741892 --- /dev/null +++ b/packages/libs/web-common/src/App/NewsSidebar/NewsModule.ts @@ -0,0 +1,75 @@ +import { communitySite, projectId } from '../../config'; + +const NEWS_LOADING = 'news/loading'; +const NEWS_RECEIVED = 'news/received'; +const NEWS_ERROR = 'news/error'; + +export interface NewsItem { + date?: string; + headline?: string; + description?: string; + link?: string; + [key: string]: any; +} + +type NewsLoadingAction = { + type: typeof NEWS_LOADING; +}; + +type NewsReceivedAction = { + type: typeof NEWS_RECEIVED; + payload: { news: NewsItem[] }; +}; + +type NewsErrorAction = { + type: typeof NEWS_ERROR; + payload: { error: string }; +}; + +type NewsAction = NewsLoadingAction | NewsReceivedAction | NewsErrorAction; + +export function requestNews() { + return [ + { type: NEWS_LOADING }, + fetch(`https://${communitySite}/${projectId}/news.json`, { mode: 'cors' }) + .then((res) => res.json()) + .then( + (news: NewsItem[]): NewsReceivedAction => ({ + type: NEWS_RECEIVED, + payload: { news }, + }), + (error): NewsErrorAction => ({ + type: NEWS_ERROR, + payload: { error: error.message }, + }) + ), + ]; +} + +export interface NewsState { + status: 'idle' | 'loading'; + news: NewsItem[] | null; + error: string | null; +} + +const defaultState: NewsState = { + status: 'idle', + news: null, + error: null, +}; + +export function newsReducer( + state: NewsState = defaultState, + action: NewsAction +): NewsState { + switch (action.type) { + case NEWS_LOADING: + return { ...state, status: 'loading' }; + case NEWS_RECEIVED: + return { status: 'idle', error: null, news: action.payload.news }; + case NEWS_ERROR: + return { status: 'idle', error: action.payload.error, news: state.news }; + default: + return state; + } +} diff --git a/packages/libs/web-common/src/App/NewsSidebar/index.js b/packages/libs/web-common/src/App/NewsSidebar/index.ts similarity index 100% rename from packages/libs/web-common/src/App/NewsSidebar/index.js rename to packages/libs/web-common/src/App/NewsSidebar/index.ts diff --git a/packages/libs/web-common/src/App/Searches/SearchCard.jsx b/packages/libs/web-common/src/App/Searches/SearchCard.tsx similarity index 80% rename from packages/libs/web-common/src/App/Searches/SearchCard.jsx rename to packages/libs/web-common/src/App/Searches/SearchCard.tsx index 8984e85b91..404c42b386 100755 --- a/packages/libs/web-common/src/App/Searches/SearchCard.jsx +++ b/packages/libs/web-common/src/App/Searches/SearchCard.tsx @@ -5,7 +5,21 @@ import './SearchCard.scss'; import { IconAlt as Icon } from '@veupathdb/wdk-client/lib/Components'; import { getBodyClassByType } from './SearchUtils'; -class SearchCard extends React.Component { +interface SearchCardProps { + card: { + icon: string; + name: string; + studyName?: string; + recordClassDisplayName: string; + url?: string; + appUrl?: string; + description: string; + disabled?: boolean; + }; + prefix?: string; +} + +class SearchCard extends React.Component { render() { const { card, prefix = '' } = this.props; const { @@ -23,7 +37,7 @@ class SearchCard extends React.Component { const bodyClass = getBodyClassByType(recordClassDisplayName); - function httpHtml(content) { + function httpHtml(content: { description: string }) { const reg = /(http:\/\/|https:\/\/)((\w|=|\?|\.|\/|&|-)+)/g; return content.description.replace(reg, "$1$2"); } diff --git a/packages/libs/web-common/src/App/Searches/SearchCardActionCreators.js b/packages/libs/web-common/src/App/Searches/SearchCardActionCreators.js deleted file mode 100644 index fb0fe0e9e6..0000000000 --- a/packages/libs/web-common/src/App/Searches/SearchCardActionCreators.js +++ /dev/null @@ -1,57 +0,0 @@ -import { keyBy, get } from 'lodash'; - -import { fetchStudies } from '../../App/Studies/StudyActionCreators'; - -export const SEARCHES_LOADING = 'search-cards/loading'; -export const SEARCHES_LOADED = 'search-cards/loaded'; -export const SEARCHES_ERROR = 'search-cards/error'; - -export const loadSearches = - (userEmails) => - ({ wdkService }) => - [ - { type: SEARCHES_LOADING }, - fetchAndFormatSearches(wdkService, userEmails).then( - (searches) => ({ type: SEARCHES_LOADED, payload: { searches } }), - (error) => ({ type: SEARCHES_ERROR, payload: { error: error.message } }) - ), - ]; - -async function fetchAndFormatSearches(wdkService, userEmails) { - const [recordClasses, strategies, [studies]] = await Promise.all([ - wdkService.getRecordClasses(), - userEmails - ? wdkService.getPublicStrategies({ userEmail: userEmails }) - : wdkService.getPublicStrategies(), - fetchStudies(wdkService), - ]); - - const recordClassesByUrlSegment = keyBy(recordClasses, 'urlSegment'); - - return strategies - .filter((strategy) => strategy.isValid) - .map((strategy) => ({ - icon: get( - recordClassesByUrlSegment[strategy.recordClassName], - 'iconName', - 'question' - ), - recordClassDisplayName: get( - recordClassesByUrlSegment[strategy.recordClassName], - 'displayNamePlural', - 'Uknown record type' - ), - name: strategy.name, - studyName: getStudyNameByRecordClassName( - studies, - strategy.recordClassName - ), - appUrl: `/app/workspace/strategies/import/${strategy.signature}`, - description: strategy.description, - })); -} - -function getStudyNameByRecordClassName(studies, recordClassName) { - const study = studies.find((study) => recordClassName.startsWith(study.id)); - return study && study.name; -} diff --git a/packages/libs/web-common/src/App/Searches/SearchCardActionCreators.ts b/packages/libs/web-common/src/App/Searches/SearchCardActionCreators.ts new file mode 100644 index 0000000000..bb11187174 --- /dev/null +++ b/packages/libs/web-common/src/App/Searches/SearchCardActionCreators.ts @@ -0,0 +1,95 @@ +import { keyBy, get } from 'lodash'; + +import { fetchStudies, Study } from '../../App/Studies/StudyActionCreators'; +import { SearchCard } from './SearchCardReducer'; +import WdkService from '@veupathdb/wdk-client/lib/Service/WdkService'; +import { RecordClass } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; +import { StrategySummary } from '@veupathdb/wdk-client/lib/Utils/WdkUser'; + +export const SEARCHES_LOADING = 'search-cards/loading'; +export const SEARCHES_LOADED = 'search-cards/loaded'; +export const SEARCHES_ERROR = 'search-cards/error'; + +export type SearchesLoadingAction = { + type: typeof SEARCHES_LOADING; +}; + +export type SearchesLoadedAction = { + type: typeof SEARCHES_LOADED; + payload: { searches: SearchCard[] }; +}; + +export type SearchesErrorAction = { + type: typeof SEARCHES_ERROR; + payload: { error: string }; +}; + +export type SearchesAction = + | SearchesLoadingAction + | SearchesLoadedAction + | SearchesErrorAction; + +export const loadSearches = + (userEmails?: string[]) => + ({ wdkService }: { wdkService: WdkService }) => + [ + { type: SEARCHES_LOADING }, + fetchAndFormatSearches(wdkService, userEmails).then( + (searches): SearchesLoadedAction => ({ + type: SEARCHES_LOADED, + payload: { searches }, + }), + (error): SearchesErrorAction => ({ + type: SEARCHES_ERROR, + payload: { error: error.message }, + }) + ), + ]; + +async function fetchAndFormatSearches( + wdkService: WdkService, + userEmails?: string[] +): Promise { + const [recordClasses, strategies, [studies]] = await Promise.all([ + wdkService.getRecordClasses(), + userEmails + ? wdkService.getPublicStrategies({ userEmail: userEmails }) + : wdkService.getPublicStrategies(), + fetchStudies(wdkService), + ]); + + const recordClassesByUrlSegment: Record = keyBy( + recordClasses, + 'urlSegment' + ); + + return strategies + .filter((strategy: StrategySummary) => strategy.isValid) + .map((strategy: StrategySummary): SearchCard => ({ + icon: get( + recordClassesByUrlSegment[strategy.recordClassName ?? ''], + 'iconName', + 'question' + ), + recordClassDisplayName: get( + recordClassesByUrlSegment[strategy.recordClassName ?? ''], + 'displayNamePlural', + 'Uknown record type' + ), + name: strategy.name, + studyName: getStudyNameByRecordClassName( + studies, + strategy.recordClassName ?? '' + ), + appUrl: `/app/workspace/strategies/import/${strategy.signature}`, + description: strategy.description ?? '', + })); +} + +function getStudyNameByRecordClassName( + studies: Study[], + recordClassName: string +): string | undefined { + const study = studies.find((study) => recordClassName.startsWith(study.id)); + return study && study.name; +} diff --git a/packages/libs/web-common/src/App/Searches/SearchCardReducer.js b/packages/libs/web-common/src/App/Searches/SearchCardReducer.ts similarity index 55% rename from packages/libs/web-common/src/App/Searches/SearchCardReducer.js rename to packages/libs/web-common/src/App/Searches/SearchCardReducer.ts index 4601d75113..9c908be8eb 100644 --- a/packages/libs/web-common/src/App/Searches/SearchCardReducer.js +++ b/packages/libs/web-common/src/App/Searches/SearchCardReducer.ts @@ -2,12 +2,34 @@ import { SEARCHES_LOADED, SEARCHES_LOADING, SEARCHES_ERROR, + SearchesAction, } from './SearchCardActionCreators'; +export interface SearchCard { + icon: string; + recordClassDisplayName: string; + name: string; + studyName?: string; + appUrl: string; + description: string; +} + +export interface SearchCardState { + loading: boolean; + error: string | null; + entities: SearchCard[] | null; +} + +const initialState: SearchCardState = { + loading: false, + error: null, + entities: null, +}; + export default function reduce( - state = { loading: false, error: null, entities: null }, - action -) { + state: SearchCardState = initialState, + action: SearchesAction +): SearchCardState { switch (action.type) { case SEARCHES_LOADING: return { diff --git a/packages/libs/web-common/src/App/Searches/SearchUtils.js b/packages/libs/web-common/src/App/Searches/SearchUtils.ts similarity index 84% rename from packages/libs/web-common/src/App/Searches/SearchUtils.js rename to packages/libs/web-common/src/App/Searches/SearchUtils.ts index ea2d098869..6c65ca918d 100755 --- a/packages/libs/web-common/src/App/Searches/SearchUtils.js +++ b/packages/libs/web-common/src/App/Searches/SearchUtils.ts @@ -1,5 +1,5 @@ // FIXME Replace w/ stable random color assignment -export function getBodyClassByType(type = '') { +export function getBodyClassByType(type = ''): string { return /participant/i.test(type) ? 'red-fade-bg' : /household/i.test(type) diff --git a/packages/libs/web-common/src/App/Searches/index.js b/packages/libs/web-common/src/App/Searches/index.ts similarity index 100% rename from packages/libs/web-common/src/App/Searches/index.js rename to packages/libs/web-common/src/App/Searches/index.ts diff --git a/packages/libs/web-common/src/App/Showcase/CardList.jsx b/packages/libs/web-common/src/App/Showcase/CardList.tsx similarity index 87% rename from packages/libs/web-common/src/App/Showcase/CardList.jsx rename to packages/libs/web-common/src/App/Showcase/CardList.tsx index e5c7353e5e..ffa639e6dd 100644 --- a/packages/libs/web-common/src/App/Showcase/CardList.jsx +++ b/packages/libs/web-common/src/App/Showcase/CardList.tsx @@ -1,6 +1,5 @@ import { upperFirst } from 'lodash'; import React from 'react'; -import PropTypes from 'prop-types'; import Select from 'react-select'; import { Link, RealTimeSearchBox } from '@veupathdb/wdk-client/lib/Components'; import { projectId } from '../../config'; @@ -24,7 +23,28 @@ const FILTER_CLASS_NAME = `${CLASS_NAME}__FilterInput`; const ALLSTUDIES_LINK_CLASS_NAME = `${CLASS_NAME}__TableViewLink link`; -export default function CardList(props) { +interface Filter { + id: string; + display: React.ReactNode; +} + +interface CardListProps { + additionalClassName?: string; + isLoading?: boolean; + list?: any[]; + renderCard: (item: any, index: number) => React.ReactNode; + filters?: Filter[]; + filtersLabel?: string; + isExpandable?: boolean; + isSearchable?: boolean; + tableViewLink?: string; + tableViewLinkText?: string; + contentNamePlural?: string; + getSearchStringForItem?: (item: any) => string; + matchPredicate?: (searchString: string, filterString: string) => boolean; +} + +export default function CardList(props: CardListProps) { const { additionalClassName, isLoading, @@ -42,9 +62,9 @@ export default function CardList(props) { } = props; // state - const [isExpanded, setIsExpanded] = React.useState(null); - const [filterString, setFilterString] = React.useState(null); - const [categoryFilter, setCategoryFilter] = React.useState(); + const [isExpanded, setIsExpanded] = React.useState(null); + const [filterString, setFilterString] = React.useState(null); + const [categoryFilter, setCategoryFilter] = React.useState(); // save state to session storage React.useEffect(() => { @@ -208,19 +228,6 @@ export default function CardList(props) { ); } -function defaultMatchPredicate(searchString, filterString) { +function defaultMatchPredicate(searchString: string, filterString: string) { return searchString.toLowerCase().includes(filterString.toLowerCase()); } - -CardList.propTypes = { - renderCard: PropTypes.func.isRequired, - additionalClassName: PropTypes.string, - isLoading: PropTypes.bool, - list: PropTypes.array, - isExpandable: PropTypes.bool, - isSearchable: PropTypes.bool, - isExpanded: PropTypes.bool, - filterString: PropTypes.string, - contentNamePlural: PropTypes.string, - getSearchStringForItem: PropTypes.func, -}; diff --git a/packages/libs/web-common/src/App/Showcase/PlaceholderCard.jsx b/packages/libs/web-common/src/App/Showcase/PlaceholderCard.tsx similarity index 83% rename from packages/libs/web-common/src/App/Showcase/PlaceholderCard.jsx rename to packages/libs/web-common/src/App/Showcase/PlaceholderCard.tsx index d2519c920d..331bd2aed6 100644 --- a/packages/libs/web-common/src/App/Showcase/PlaceholderCard.jsx +++ b/packages/libs/web-common/src/App/Showcase/PlaceholderCard.tsx @@ -14,7 +14,11 @@ export default function PlaceholderCard() { ); } -function PlaceholderParagraph(props) { +interface PlaceholderParagraphProps { + lines: number; +} + +function PlaceholderParagraph(props: PlaceholderParagraphProps) { const { lines } = props; return (
diff --git a/packages/libs/web-common/src/App/Showcase/Showcase.jsx b/packages/libs/web-common/src/App/Showcase/Showcase.tsx similarity index 75% rename from packages/libs/web-common/src/App/Showcase/Showcase.jsx rename to packages/libs/web-common/src/App/Showcase/Showcase.tsx index 5ed2061892..e0341d2958 100755 --- a/packages/libs/web-common/src/App/Showcase/Showcase.jsx +++ b/packages/libs/web-common/src/App/Showcase/Showcase.tsx @@ -8,7 +8,39 @@ import { WdkDependenciesContext } from '@veupathdb/wdk-client/lib/Hooks/WdkDepen import { AnalysisClient } from '@veupathdb/eda/lib/core/api/AnalysisClient'; import { edaServiceUrl } from '../../config'; -export default function Showcase(props) { +interface ShowcaseContent { + items?: any[]; + title?: string; + viewAllUrl?: string; + viewAllAppUrl?: string; + filters?: any[]; + filtersLabel?: string; + contentType?: string; + contentNamePlural?: string; + description?: string; + isLoading?: boolean; + isExpandable?: boolean; + isSearchable?: boolean; + tableViewLink?: string; + tableViewLinkText?: string; + cardComponent: React.ComponentType; + getSearchStringForItem?: (item: any) => string; + matchPredicate?: (searchString: string, filterString: string) => boolean; + permissions?: any; + loadItems?: (params: { + analysisClient: AnalysisClient; + wdkService: any; + }) => Promise; +} + +interface ShowcaseProps { + analyses?: any[]; + content: ShowcaseContent; + prefix: string; + attemptAction?: (action: any) => void; +} + +export default function Showcase(props: ShowcaseProps) { const { analyses, content, prefix, attemptAction } = props; const { items, @@ -32,8 +64,10 @@ export default function Showcase(props) { loadItems, } = content; - const [list, setList] = React.useState(loadItems == null ? items : null); - const [error, setError] = React.useState(); + const [list, setList] = React.useState( + loadItems == null ? items || null : null + ); + const [error, setError] = React.useState(); const { wdkService } = React.useContext(WdkDependenciesContext); const analysisClient = useMemo( () => new AnalysisClient({ baseUrl: edaServiceUrl }, wdkService), @@ -50,7 +84,7 @@ export default function Showcase(props) { React.useEffect(() => { if (loadItems == null) { - setList(items); + setList(items || null); } }, [items, loadItems]); diff --git a/packages/libs/web-common/src/App/Showcase/ShowcaseFilter.jsx b/packages/libs/web-common/src/App/Showcase/ShowcaseFilter.tsx similarity index 80% rename from packages/libs/web-common/src/App/Showcase/ShowcaseFilter.jsx rename to packages/libs/web-common/src/App/Showcase/ShowcaseFilter.tsx index ed6717da50..1a81405110 100755 --- a/packages/libs/web-common/src/App/Showcase/ShowcaseFilter.jsx +++ b/packages/libs/web-common/src/App/Showcase/ShowcaseFilter.tsx @@ -2,8 +2,27 @@ import React from 'react'; import { IconAlt as Icon, Mesa } from '@veupathdb/wdk-client/lib/Components'; -class ShowcaseFilter extends React.Component { - constructor(props) { +interface Filter { + id: string; + display: React.ReactNode; + predicate: (item: any) => boolean; +} + +interface ShowcaseFilterProps { + filters: Filter[]; + items?: any[]; + onFilter?: (filteredItems: any[]) => void; +} + +interface ShowcaseFilterState { + activeFilters: string[]; +} + +class ShowcaseFilter extends React.Component< + ShowcaseFilterProps, + ShowcaseFilterState +> { + constructor(props: ShowcaseFilterProps) { super(props); const { filters } = props; this.state = { activeFilters: [] }; @@ -11,13 +30,13 @@ class ShowcaseFilter extends React.Component { this.applyFilterToList = this.applyFilterToList.bind(this); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: ShowcaseFilterProps) { if (this.props.filters !== prevProps.filters) { this.setState({ activeFilters: [] }); } } - toggleFilter(id) { + toggleFilter(id: string) { const { activeFilters } = this.state; let newFilters; if (activeFilters.includes(id)) { diff --git a/packages/libs/web-common/src/App/Showcase/index.js b/packages/libs/web-common/src/App/Showcase/index.ts similarity index 100% rename from packages/libs/web-common/src/App/Showcase/index.js rename to packages/libs/web-common/src/App/Showcase/index.ts diff --git a/packages/libs/web-common/src/App/SiteMenu/SiteMenu.jsx b/packages/libs/web-common/src/App/SiteMenu/SiteMenu.tsx similarity index 53% rename from packages/libs/web-common/src/App/SiteMenu/SiteMenu.jsx rename to packages/libs/web-common/src/App/SiteMenu/SiteMenu.tsx index 19709ee903..f4433befa9 100755 --- a/packages/libs/web-common/src/App/SiteMenu/SiteMenu.jsx +++ b/packages/libs/web-common/src/App/SiteMenu/SiteMenu.tsx @@ -2,9 +2,27 @@ import React from 'react'; import './SiteMenu.scss'; import SiteMenuItem from './SiteMenuItem'; +import { MenuItem } from '../../util/menuItems'; +import { User } from '@veupathdb/wdk-client/lib/Utils/WdkUser'; -class SiteMenu extends React.Component { - constructor(props) { +interface SiteMenuConfig { + webAppUrl: string; + projectId?: string; +} + +interface SiteMenuActions { + showLoginWarning: (message: string, href: string) => void; +} + +interface SiteMenuProps { + items?: MenuItem[]; + config: SiteMenuConfig; + actions: SiteMenuActions; + user: User; +} + +class SiteMenu extends React.Component { + constructor(props: SiteMenuProps) { super(props); } diff --git a/packages/libs/web-common/src/App/SiteMenu/SiteMenuItem.jsx b/packages/libs/web-common/src/App/SiteMenu/SiteMenuItem.tsx similarity index 75% rename from packages/libs/web-common/src/App/SiteMenu/SiteMenuItem.jsx rename to packages/libs/web-common/src/App/SiteMenu/SiteMenuItem.tsx index 7213aad294..2422c2014d 100755 --- a/packages/libs/web-common/src/App/SiteMenu/SiteMenuItem.jsx +++ b/packages/libs/web-common/src/App/SiteMenu/SiteMenuItem.tsx @@ -2,20 +2,57 @@ import React from 'react'; import './SiteMenuItem.scss'; import { IconAlt as Icon, Link } from '@veupathdb/wdk-client/lib/Components'; +import { MenuItem } from '../../util/menuItems'; +import { User } from '@veupathdb/wdk-client/lib/Utils/WdkUser'; -class SiteMenuItem extends React.Component { - constructor(props) { +interface SiteMenuItemConfig { + webAppUrl: string; + projectId?: string; +} + +interface SiteMenuItemActions { + showLoginWarning: (message: string, href: string) => void; +} + +interface ExtendedMenuItem extends Omit { + appUrl?: string; + isVisible?: boolean; + children?: + | MenuItem[] + | ((context: { + webAppUrl: string; + projectId?: string; + isFocused: boolean; + }) => MenuItem[]); +} + +interface SiteMenuItemProps { + item: ExtendedMenuItem; + config: SiteMenuItemConfig; + actions: SiteMenuItemActions; + user: User; +} + +interface SiteMenuItemState { + isFocused: boolean; +} + +class SiteMenuItem extends React.Component< + SiteMenuItemProps, + SiteMenuItemState +> { + constructor(props: SiteMenuItemProps) { super(props); this.state = { isFocused: false }; this.focus = this.focus.bind(this); this.blur = this.blur.bind(this); } - focus(event) { + focus(event: React.MouseEvent) { this.setState({ isFocused: true }); } - blur(event) { + blur(event: React.MouseEvent | React.TouchEvent) { this.setState({ isFocused: false }); } @@ -37,7 +74,7 @@ class SiteMenuItem extends React.Component { const { showLoginWarning } = actions; const isGuest = user.isGuest; - let handleClick = (e) => { + let handleClick = (e: React.MouseEvent) => { if (item.onClick) { item.onClick(e); } diff --git a/packages/libs/web-common/src/App/SiteMenu/index.js b/packages/libs/web-common/src/App/SiteMenu/index.ts similarity index 100% rename from packages/libs/web-common/src/App/SiteMenu/index.js rename to packages/libs/web-common/src/App/SiteMenu/index.ts diff --git a/packages/libs/web-common/src/App/Studies/DownloadLink.jsx b/packages/libs/web-common/src/App/Studies/DownloadLink.tsx similarity index 77% rename from packages/libs/web-common/src/App/Studies/DownloadLink.jsx rename to packages/libs/web-common/src/App/Studies/DownloadLink.tsx index 3be65f0f7d..f89501dfc7 100644 --- a/packages/libs/web-common/src/App/Studies/DownloadLink.jsx +++ b/packages/libs/web-common/src/App/Studies/DownloadLink.tsx @@ -4,11 +4,23 @@ import { compose } from 'lodash/fp'; import { IconAlt as Icon, Mesa } from '@veupathdb/wdk-client/lib/Components'; import { wrappable } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; -import { attemptAction } from '@veupathdb/study-data-access/lib/data-restriction/DataRestrictionActionCreators'; +import { attemptAction as attemptActionCreator } from '@veupathdb/study-data-access/lib/data-restriction/DataRestrictionActionCreators'; import { connect } from 'react-redux'; import { isPrereleaseStudy } from '@veupathdb/study-data-access/lib/data-restriction/DataRestrictionUtils'; +import { UserPermissions } from '@veupathdb/study-data-access/lib/study-access/permission'; -function DownloadLink(props) { +interface DownloadLinkProps { + attemptAction: typeof attemptActionCreator; + studyAccess: string; + studyId: string; + studyUrl: string; + permissions?: UserPermissions; + className?: string; + linkText?: string; + iconFirst?: boolean; +} + +function DownloadLink(props: DownloadLinkProps) { const { attemptAction, studyAccess, @@ -61,5 +73,5 @@ function DownloadLink(props) { // attemptAction gets bound to the store, the store receives the action, which will get executed when user clicks. export default compose( wrappable, - connect(null, { attemptAction }) + connect(null, { attemptAction: attemptActionCreator }) )(DownloadLink); diff --git a/packages/libs/web-common/src/App/Studies/StudyActionCreators.js b/packages/libs/web-common/src/App/Studies/StudyActionCreators.ts similarity index 55% rename from packages/libs/web-common/src/App/Studies/StudyActionCreators.js rename to packages/libs/web-common/src/App/Studies/StudyActionCreators.ts index 6590b3d680..53811c4967 100644 --- a/packages/libs/web-common/src/App/Studies/StudyActionCreators.js +++ b/packages/libs/web-common/src/App/Studies/StudyActionCreators.ts @@ -1,15 +1,70 @@ -import { get, identity, keyBy, mapValues, orderBy, spread } from 'lodash'; +import { get, identity, keyBy, mapValues, spread } from 'lodash'; import { emptyAction } from '@veupathdb/wdk-client/lib/Core/WdkMiddleware'; import { getSearchableString } from '@veupathdb/wdk-client/lib/Views/Records/RecordUtils'; import { showUnreleasedData } from '../../config'; import { isDiyWdkRecordId } from '@veupathdb/user-datasets/lib/Utils/diyDatasets'; +import WdkService from '@veupathdb/wdk-client/lib/Service/WdkService'; +import { + Question, + RecordClass, + RecordInstance, +} from '@veupathdb/wdk-client/lib/Utils/WdkModel'; export const STUDIES_REQUESTED = 'studies/studies-requested'; export const STUDIES_RECEIVED = 'studies/studies-received'; export const STUDIES_ERROR = 'studies/studies-error'; +export interface StudySearch { + icon: string; + name: string; + path: string; + displayName: string; +} + +export interface Study { + name: string; + id: string; + route: string; + categories: string[]; + access: string; + isReleased: boolean; + email?: string; + policyUrl?: string; + requestNeedsApproval?: string; + downloadUrl?: string; + headline?: string; + points?: string[]; + searches: StudySearch[]; + searchString: string; + disabled: boolean; +} + +interface InvalidRecord { + record: RecordInstance; + error: Error; +} + +export type StudiesRequestedAction = { + type: typeof STUDIES_REQUESTED; +}; + +export type StudiesReceivedAction = { + type: typeof STUDIES_RECEIVED; + payload: { studies: Study[] }; +}; + +export type StudiesErrorAction = { + type: typeof STUDIES_ERROR; + payload: { error: string }; +}; + +export type StudiesAction = + | StudiesRequestedAction + | StudiesReceivedAction + | StudiesErrorAction; + /** * Load studies */ @@ -20,16 +75,16 @@ export function requestStudies() { // Action creators // --------------- -function studiesRequested() { +function studiesRequested(): StudiesRequestedAction { return { type: STUDIES_REQUESTED }; } -function studiesReceived([studies, invalidRecords]) { +function studiesReceived([studies, invalidRecords]: [Study[], InvalidRecord[]]) { return [ { type: STUDIES_RECEIVED, payload: { studies } }, invalidRecords.length === 0 ? emptyAction - : ({ wdkService }) => + : ({ wdkService }: { wdkService: WdkService }) => wdkService .submitError( new Error( @@ -43,7 +98,7 @@ function studiesReceived([studies, invalidRecords]) { ]; } -function studiesError(error) { +function studiesError(error: Error): StudiesErrorAction { return { type: STUDIES_ERROR, payload: { error: error.message } }; } @@ -62,12 +117,14 @@ const requiredAttributes = [ // ------------- function loadStudies() { - return function run({ wdkService }) { + return function run({ wdkService }: { wdkService: WdkService }) { return fetchStudies(wdkService).then(studiesReceived, studiesError); }; } -export function fetchStudies(wdkService) { +export function fetchStudies( + wdkService: WdkService +): Promise<[Study[], InvalidRecord[]]> { return Promise.all([ wdkService.getQuestions(), wdkService.getRecordClasses(), @@ -78,22 +135,45 @@ export function fetchStudies(wdkService) { // Helpers // -const parseStudy = mapProps({ +type PropMapper = { + [K in keyof T]: [ + string | ((record: RecordInstance) => any), + ((value: any) => T[K])?, + ]; +}; + +interface ParsedStudy { + name: string; + id: string; + route: string; + categories: string[]; + access: string; + isReleased: boolean; + email?: string; + policyUrl?: string; + requestNeedsApproval?: string; + downloadUrl?: string; + headline?: string; + points?: string[]; + searches: Record; +} + +const parseStudy = mapProps({ name: ['attributes.display_name'], id: ['attributes.dataset_id'], - route: ['attributes.dataset_id', (id) => `/record/dataset/${id}`], + route: ['attributes.dataset_id', (id: string) => `/record/dataset/${id}`], categories: [ - (record) => + (record: RecordInstance) => 'disease' in record.attributes ? (record.attributes.disease || 'Unknown').split(/,\s*/g) - : JSON.parse(record.attributes.study_categories), + : JSON.parse(record.attributes.study_categories as string), ], // TODO Remove .toLowerCase() when attribute display value is updated access: [ 'attributes.study_access', - (access) => access && access.toLowerCase(), + (access: string) => access && access.toLowerCase(), ], - isReleased: ['attributes.is_public', (str) => str === 'true'], + isReleased: ['attributes.is_public', (str: string) => str === 'true'], email: ['attributes.email'], policyUrl: ['attributes.policy_url'], requestNeedsApproval: ['attributes.request_needs_approval'], @@ -103,11 +183,25 @@ const parseStudy = mapProps({ searches: ['attributes.card_questions', JSON.parse], }); -function formatStudies(questions, recordClasses, answer) { - const questionsByName = keyBy(questions, 'fullName'); - const recordClassesByName = keyBy(recordClasses, 'urlSegment'); +function formatStudies( + questions: Question[], + recordClasses: RecordClass[], + answer: { records: RecordInstance[] } +): [Study[], InvalidRecord[]] { + const questionsByName: Record = keyBy( + questions, + 'fullName' + ); + const recordClassesByName: Record = keyBy( + recordClasses, + 'urlSegment' + ); - const records = answer.records.reduce( + const records = answer.records.reduce<{ + valid: Study[]; + invalid: InvalidRecord[]; + appearFirst: Set; + }>( (records, record) => { try { const missingAttributes = requiredAttributes.filter( @@ -121,19 +215,19 @@ function formatStudies(questions, recordClasses, answer) { records.valid.push({ ...parseStudy(record), searchString: getSearchableString([], [], record), - }); + } as Study); // Our presenters use a build number of 0 to convey studies // which should appear first... // (1) in the cards and... // (2) in the "Search a study" menu if (record.attributes.build_number_introduced === '0') { - records.appearFirst.add(record.attributes.dataset_id); + records.appearFirst.add(record.attributes.dataset_id as string); } return records; } catch (error) { - records.invalid.push({ record, error }); + records.invalid.push({ record, error: error as Error }); return records; } }, @@ -153,7 +247,7 @@ function formatStudies(questions, recordClasses, answer) { searches: Object.values(study.searches) .map((questionName) => questionsByName[questionName]) .filter((question) => question != null) - .map((question) => { + .map((question): StudySearch => { const recordClass = recordClassesByName[question.outputRecordClassName]; return { @@ -173,21 +267,29 @@ function formatStudies(questions, recordClasses, answer) { /** * Map props from source object to props in new object. * - * @param {object} propMap Object describing how to map properties. Keys are + * @param propMap Object describing how to map properties. Keys are * key for new object, and values are an array of [ path, valueMapper ], where * valueMapper is a function that takes the value from the source object and * returns a new value. If valueMapper is not specified, then identity is used. */ -function mapProps(propMap) { - return function mapper(source) { - return mapValues(propMap, ([sourcePath, valueMapper = identity]) => { - try { - if (typeof sourcePath === 'function') - return valueMapper(sourcePath(source)); - return valueMapper(get(source, sourcePath)); - } catch (error) { - throw new Error(`Parsing error at ${sourcePath}: ${error.message}`); +function mapProps(propMap: PropMapper) { + return function mapper(source: RecordInstance): T { + return mapValues( + propMap, + ([sourcePath, valueMapper = identity]: [ + string | ((record: RecordInstance) => any), + ((value: any) => any)?, + ]) => { + try { + if (typeof sourcePath === 'function') + return valueMapper(sourcePath(source)); + return valueMapper(get(source, sourcePath)); + } catch (error) { + throw new Error( + `Parsing error at ${sourcePath}: ${(error as Error).message}` + ); + } } - }); + ) as T; }; } diff --git a/packages/libs/web-common/src/App/Studies/StudyCard.jsx b/packages/libs/web-common/src/App/Studies/StudyCard.tsx similarity index 88% rename from packages/libs/web-common/src/App/Studies/StudyCard.jsx rename to packages/libs/web-common/src/App/Studies/StudyCard.tsx index f23fd04eb4..fc4edd8e9f 100755 --- a/packages/libs/web-common/src/App/Studies/StudyCard.jsx +++ b/packages/libs/web-common/src/App/Studies/StudyCard.tsx @@ -9,16 +9,41 @@ import { isPrereleaseStudy } from '@veupathdb/study-data-access/lib/data-restric import './StudyCard.scss'; import { makeEdaRoute } from '../../routes'; import { Tooltip } from '@veupathdb/coreui'; +import { Study } from './StudyActionCreators'; +import { UserPermissions } from '@veupathdb/study-data-access/lib/study-access/permission'; +import { attemptAction } from '@veupathdb/study-data-access/lib/data-restriction/DataRestrictionActionCreators'; -class StudyCard extends React.Component { - constructor(props) { +interface Analysis { + studyId: string; + [key: string]: any; +} + +interface StudyCardData extends Omit { + downloadUrl?: { + url: string; + }; +} + +interface StudyCardProps { + card: StudyCardData; + permissions?: UserPermissions; + analyses?: Analysis[]; + attemptAction?: typeof attemptAction; +} + +interface StudyCardState { + searchType: string | null; +} + +class StudyCard extends React.Component { + constructor(props: StudyCardProps) { super(props); this.state = { searchType: null }; this.displaySearchType = this.displaySearchType.bind(this); this.clearDisplaySearchType = this.clearDisplaySearchType.bind(this); } - displaySearchType(searchType) { + displaySearchType(searchType: string) { this.setState({ searchType }); } @@ -56,7 +81,7 @@ class StudyCard extends React.Component { linkText="Download Data" studyAccess={card.access} studyId={card.id} - studyUrl={card.downloadUrl.url} + studyUrl={card.downloadUrl?.url || ''} attemptAction={attemptAction} /> ); diff --git a/packages/libs/web-common/src/App/Studies/StudyFilterUtils.jsx b/packages/libs/web-common/src/App/Studies/StudyFilterUtils.tsx similarity index 51% rename from packages/libs/web-common/src/App/Studies/StudyFilterUtils.jsx rename to packages/libs/web-common/src/App/Studies/StudyFilterUtils.tsx index 54a1f0b9d9..a1e92e0485 100644 --- a/packages/libs/web-common/src/App/Studies/StudyFilterUtils.jsx +++ b/packages/libs/web-common/src/App/Studies/StudyFilterUtils.tsx @@ -1,10 +1,23 @@ import React from 'react'; import { ucFirst } from '../../App/Utils/Utils'; +import { CategoryIcon } from '../Categories'; /* Filtering, defunct until we need study filtering again (AB, 1/8/18) */ -export function createStudyCategoryPredicate(targetCategory) { - return ({ categories } = {}) => { +interface StudyWithCategories { + categories?: string[]; +} + +interface StudyFilter { + id: string; + display: JSX.Element; + predicate: (study: StudyWithCategories) => boolean; +} + +export function createStudyCategoryPredicate( + targetCategory: string +): (study: StudyWithCategories) => boolean { + return ({ categories }: StudyWithCategories = {}) => { return !categories ? false : categories @@ -13,7 +26,7 @@ export function createStudyCategoryPredicate(targetCategory) { }; } -export function createStudyCategoryFilter(id) { +export function createStudyCategoryFilter(id: string): StudyFilter { const display = (