diff --git a/docker-compose.yml b/docker-compose.yml index b1afd943b..dbcdd6568 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,11 +32,11 @@ services: - VALID_EMAIL_GLOB_EXPRESSION=*.gov.sg - LOGIN_MESSAGE=Your OTP might take awhile to get to you. - USER_MESSAGE=User message test - - ANNOUNCEMENT_MESSAGE=The quick brown fox jumps over a lazy dog - - ANNOUNCEMENT_TITLE=Some title - - ANNOUNCEMENT_SUBTITLE=Subtitle is found here + - ANNOUNCEMENT_MESSAGE=Search by email to find link owners, or by keyword to discover other links! \n PRO TIP! Search your email domain to find out all the links made by your agency. + - ANNOUNCEMENT_TITLE=GoDirectory is here! + - ANNOUNCEMENT_SUBTITLE=Search all go.gov.sg links - ANNOUNCEMENT_URL=https://go.gov.sg/ - - ANNOUNCEMENT_IMAGE=/assets/transition-page/images/browser.svg + - ANNOUNCEMENT_IMAGE=/assets/transition-page/images/directory-browser.svg - AWS_S3_BUCKET=local-bucket - ROTATED_LINKS=whatsapp,passport,spsc,sppr - AWS_ACCESS_KEY_ID=foobar diff --git a/public/assets/transition-page/images/directory-browser.svg b/public/assets/transition-page/images/directory-browser.svg new file mode 100644 index 000000000..0964a0baf --- /dev/null +++ b/public/assets/transition-page/images/directory-browser.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/actions/directory/index.ts b/src/client/actions/directory/index.ts new file mode 100755 index 000000000..b8faa9ffd --- /dev/null +++ b/src/client/actions/directory/index.ts @@ -0,0 +1,126 @@ +import { ThunkAction } from 'redux-thunk' +import { Dispatch } from 'react' +import querystring from 'querystring' +import { History } from 'history' +import * as Sentry from '@sentry/browser' +import { + DirectoryActionType, + ResetDirectoryResultsAction, + SET_DIRECTORY_RESULTS, + SET_INITIAL_STATE, + SetDirectoryResultsAction, +} from './types' +import { GoGovReduxState } from '../../reducers/types' +import { RootActionType, SetErrorMessageAction } from '../root/types' +import rootActions from '../root' +import { SearchResultsSortOrder } from '../../../shared/search' +import { get } from '../../util/requests' +import { DIRECTORY_PAGE } from '../../util/types' +import { UrlTypePublic } from '../../reducers/directory/types' +import { GAEvent } from '../ga' + +function setDirectoryResults(payload: { + count: number + urls: Array + query: string +}): SetDirectoryResultsAction { + return { + type: SET_DIRECTORY_RESULTS, + payload, + } +} + +function resetDirectoryResults(): ResetDirectoryResultsAction { + return { + type: SET_INITIAL_STATE, + } +} + +const getDirectoryResults = ( + query: string, + order: SearchResultsSortOrder, + rowsPerPage: number, + currentPage: number, + state: string, + isFile: string, + isEmail: string, +): ThunkAction< + void, + GoGovReduxState, + void, + DirectoryActionType | RootActionType +> => async ( + dispatch: Dispatch, +) => { + if (!query.trim()) { + return + } + const offset = currentPage * rowsPerPage + const limit = rowsPerPage + const paramsObj = { + query, + order, + limit, + offset, + state, + isFile, + isEmail, + } + const params = querystring.stringify(paramsObj) + const response = await get(`/api/directory/search?${params}`) + const json = await response.json() + if (!response.ok) { + // Report error from endpoints + GAEvent('directory page', query, 'unsuccessful') + Sentry.captureMessage('directory search unsuccessful') + dispatch( + rootActions.setErrorMessage( + json.message || 'Error fetching search results', + ), + ) + return + } + + let filteredQuery = '' + + if (isEmail === 'true') { + // split by space, then split each subset by @ and take the last part (domain), then rejoin back the string + filteredQuery = query + .split(' ') + .map((subset) => subset.split('@').slice(-1)) + .join(' ') + } else { + // Remove all words that have @ inside to prevent potential email address problem + filteredQuery = query.replace('@', ' ') + } + GAEvent('directory page', filteredQuery, 'successful') + dispatch( + setDirectoryResults({ + count: json.count, + urls: json.urls as Array, + query, + }), + ) +} + +const redirectToDirectoryPage = ( + history: History, + query: string, +): ThunkAction< + void, + GoGovReduxState, + void, + DirectoryActionType | RootActionType +> => () => { + history.push({ + pathname: DIRECTORY_PAGE, + search: querystring.stringify({ query }), + }) +} + +export default { + getDirectoryResults, + setDirectoryResults, + redirectToDirectoryPage, + resetDirectoryResults, +} diff --git a/src/client/actions/directory/types.ts b/src/client/actions/directory/types.ts new file mode 100755 index 000000000..7201eea73 --- /dev/null +++ b/src/client/actions/directory/types.ts @@ -0,0 +1,22 @@ +import { UrlTypePublic } from '../../reducers/directory/types' + +export const SET_DIRECTORY_RESULTS = 'SET_DIRECTORY_RESULTS' +export const SET_DIRECTORY_TABLE_CONFIG = 'SET_DIRECTORY_TABLE_CONFIG' +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE' + +export type SetDirectoryResultsAction = { + type: typeof SET_DIRECTORY_RESULTS + payload: { + urls: Array + count: number + query: string + } +} + +export type ResetDirectoryResultsAction = { + type: typeof SET_INITIAL_STATE +} + +export type DirectoryActionType = + | SetDirectoryResultsAction + | ResetDirectoryResultsAction diff --git a/src/client/actions/types.ts b/src/client/actions/types.ts index a3fb17862..48281293a 100644 --- a/src/client/actions/types.ts +++ b/src/client/actions/types.ts @@ -5,6 +5,7 @@ import { RootActionType } from './root/types' import { UserActionType } from './user/types' import { LoginActionType } from './login/types' import { SearchActionType } from './search/types' +import { DirectoryActionType } from './directory/types' export type GetReduxState = () => GoGovReduxState @@ -14,5 +15,6 @@ export type AllActions = | LoginActionType | HomeActionType | SearchActionType + | DirectoryActionType export type AllThunkDispatch = ThunkDispatch diff --git a/src/client/assets/go-main-logo-mini-light.svg b/src/client/assets/go-main-logo-mini-light.svg new file mode 100644 index 000000000..f95038eee --- /dev/null +++ b/src/client/assets/go-main-logo-mini-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/assets/go-main-logo-mini.svg b/src/client/assets/go-main-logo-mini.svg new file mode 100644 index 000000000..abf8b39ee --- /dev/null +++ b/src/client/assets/go-main-logo-mini.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/components/BaseLayout/BaseLayoutHeader.jsx b/src/client/components/BaseLayout/BaseLayoutHeader.jsx index 6a0149350..1452bb150 100644 --- a/src/client/components/BaseLayout/BaseLayoutHeader.jsx +++ b/src/client/components/BaseLayout/BaseLayoutHeader.jsx @@ -14,10 +14,14 @@ import { import i18next from 'i18next' import GoLogo from '~/assets/go-main-logo.svg' import GoLogoLight from '~/assets/go-main-logo-light.svg' +import GoLogoMini from '~/assets/go-main-logo-mini.svg' +import GoLogoMiniLight from '~/assets/go-main-logo-mini-light.svg' import loginActions from '../../actions/login' import Section from '../Section' import logoutIcon from './assets/logout-icon.svg' +import logoutWhiteIcon from './assets/logout-white-icon.svg' import helpIcon from '../../assets/help-icon.svg' +import directoryIcon from './assets/directory-icon.svg' import feedbackIcon from './assets/feedback-icon.svg' import githubIcon from './assets/github-icon.svg' import signinIcon from './assets/signin-icon.svg' @@ -50,7 +54,8 @@ const useStyles = makeStyles((theme) => flexGrow: 0.85, }, appBarSignOutBtn: { - fill: theme.palette.primary.main, + // fill: theme.palette.primary.main, + color: (props) => (props.isLightItems ? 'white' : '#384A51'), order: 10, }, appBarSignInBtn: { @@ -72,17 +77,23 @@ const useStyles = makeStyles((theme) => display: 'flex', width: '100%', height: '100%', + [theme.breakpoints.down('sm')]: { + width: 'auto', + }, }, headerButton: { filter: (props) => (props.isLightItems ? 'brightness(10)' : ''), // this class is not mobile first by default as padding should not be set // when it is not mobile. - [theme.breakpoints.down('xs')]: { + [theme.breakpoints.down('xm')]: { paddingLeft: 0, paddingRight: 0, minWidth: theme.spacing(6), }, }, + logoutIcon: { + width: '24px', + }, sectionPageSticky: { paddingTop: '43px', }, @@ -113,6 +124,14 @@ const BaseLayoutHeader = ({ const classes = useStyles({ isLoggedIn, isLightItems }) const headers = [ + { + text: 'Directory', + link: i18next.t('general.links.directory'), + public: false, + icon: directoryIcon, + mobileOrder: 1, + internalLink: true, + }, { text: 'Contribute', link: i18next.t('general.links.contribute'), @@ -131,7 +150,7 @@ const BaseLayoutHeader = ({ link: i18next.t('general.links.contact'), public: false, icon: feedbackIcon, - mobileOrder: 1, + mobileOrder: 3, }, ] @@ -139,14 +158,22 @@ const BaseLayoutHeader = ({ ) : ( <> @@ -175,6 +202,19 @@ const BaseLayoutHeader = ({ ) + const getGoLogo = () => { + if (isLightItems && isMobileVariant) { + return GoLogoMiniLight + } + if (isLightItems) { + return GoLogoLight + } + if (!isLightItems && isMobileVariant) { + return GoLogoMini + } + return GoLogo + } + return (
GoGovSG Logo diff --git a/src/client/components/BaseLayout/assets/directory-icon.svg b/src/client/components/BaseLayout/assets/directory-icon.svg new file mode 100644 index 000000000..1038fd371 --- /dev/null +++ b/src/client/components/BaseLayout/assets/directory-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/components/BaseLayout/assets/logout-white-icon.svg b/src/client/components/BaseLayout/assets/logout-white-icon.svg new file mode 100644 index 000000000..4cc8ee851 --- /dev/null +++ b/src/client/components/BaseLayout/assets/logout-white-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/components/DirectoryPage/DirectoryHeader/index.tsx b/src/client/components/DirectoryPage/DirectoryHeader/index.tsx new file mode 100755 index 000000000..e4eb2a1f8 --- /dev/null +++ b/src/client/components/DirectoryPage/DirectoryHeader/index.tsx @@ -0,0 +1,159 @@ +import React, { FunctionComponent } from 'react' +import { + Typography, + createStyles, + makeStyles, + useTheme, + useMediaQuery, + Hidden, + Button, +} from '@material-ui/core' +import { ApplyAppMargins } from '../../AppMargins' +import GoDirectoryInput from '../../widgets/GoDirectoryInput' +import { SearchResultsSortOrder } from '../../../../shared/search' +import useAppMargins from '../../AppMargins/appMargins' +import BetaTag from '../../widgets/BetaTag' +import arrow from '../assets/arrow.svg' + +type DirectoryHeaderProps = { + onQueryChange: (query: string) => void + onSortOrderChange: (order: SearchResultsSortOrder) => void + onClearQuery: () => void + query: string + getFile:(queryFile: string) => void + getState:(queryState: string) => void + getEmail:(queryEmail: string) => void + setDisablePagination:(disablePagination: boolean) => void + onApply: () => void + onReset: () => void +} + +type DirectoryHeaderStyleProps = { + appMargins: number +} + +const useStyles = makeStyles((theme) => + createStyles({ + headerWrapper: { + backgroundColor: '#384a51', + position: 'sticky', + top: 0, + }, + headerWrapperShort: { + backgroundColor: '#384a51', + top: 0, + }, + headerContent: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + top: '22px', + margin: '0 auto', + maxWidth: (props: DirectoryHeaderStyleProps) => + theme.spacing(180) - 2 * props.appMargins, + [theme.breakpoints.up('md')]: { + top: '35px', + }, + }, + headerTextWrapper: { + color: '#f9f9f9', + marginBottom: theme.spacing(3), + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + headerText: { + flexShrink: 0, + marginRight: theme.spacing(2), + [theme.breakpoints.up('md')]: { + marginRight: theme.spacing(1), + }, + }, + leftWrapper : { + display: 'flex', + alignItems: 'center', + }, + rightWrapper : { + display: 'inline-flex', + }, + arrow: { + paddingRight: '5px', + }, + }), +) + +const DirectoryHeader: FunctionComponent = ({ + onQueryChange, + onSortOrderChange, + onClearQuery, + query, + getFile, + getState, + getEmail, + onApply, + onReset, + setDisablePagination, +}: DirectoryHeaderProps) => { + const appMargins = useAppMargins() + const classes = useStyles({ appMargins }) + const theme = useTheme() + const isMobileView = useMediaQuery(theme.breakpoints.down('sm')) + // smaller then 730px height will cause the filter modal to be inaccessible + const isIdealHeight = useMediaQuery('(min-height: 730px)') + + return ( +
+ +
+
+
+ + Search directory + + +
+ + + +
+ +
+
+ +
+ { + if (e.key === 'Enter') { + (e.target as any).blur() + e.preventDefault() + } + }} + /> +
+
+
+ ) +} + +export default DirectoryHeader diff --git a/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/DirectoryTablePagination/index.tsx b/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/DirectoryTablePagination/index.tsx new file mode 100755 index 000000000..097e748a9 --- /dev/null +++ b/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/DirectoryTablePagination/index.tsx @@ -0,0 +1,133 @@ +import React, { FunctionComponent } from 'react' +import { + TableCell, + TablePagination, + TableRow, + createStyles, + makeStyles, +} from '@material-ui/core' +import useAppMargins from '../../../../AppMargins/appMargins' +import PaginationActionComponent from '../../../../widgets/PaginationActionComponent' + +type DirectoryTablePaginationProps = { + pageCount: number + rowsPerPage: number + changePageHandler: ( + event: React.MouseEvent | null, + pageNumber: number, + ) => void + changeRowsPerPageHandler: ( + event: React.ChangeEvent, + ) => void + currentPage: number + resultsCount: number + disablePagination: boolean +} + +type DirectoryTablePaginationStyleProps = { + appMargins: number +} + +const useStyles = makeStyles((theme) => + createStyles({ + pagination: { + zIndex: -1, + marginTop: theme.spacing(2), + marginBottom: theme.spacing(6), + [theme.breakpoints.up('md')]: { + marginTop: theme.spacing(6.5), + }, + }, + paginationCell: { + padding: 0, + border: 'none', + width: '100%', + }, + paginationRow: { + height: 'fit-content', + border: 'none', + }, + toolbar: { + paddingLeft: (props: DirectoryTablePaginationStyleProps) => props.appMargins, + paddingRight: (props: DirectoryTablePaginationStyleProps) => + props.appMargins, + }, + spacer: { + flex: 0, + }, + caption: { + fontWeight: 400, + marginRight: '4px', + [theme.breakpoints.down('sm')]: { + display: 'none', + }, + }, + select: { + border: 'solid 1px #d8d8d8', + zIndex: 2, + [theme.breakpoints.down('sm')]: { + display: 'none', + }, + }, + selectIcon: { + zIndex: 2, + [theme.breakpoints.down('sm')]: { + display: 'none', + }, + }, + }), +) + +const DirectoryTablePagination: FunctionComponent = ({ + pageCount, + rowsPerPage, + resultsCount, + currentPage, + disablePagination, + changePageHandler, + changeRowsPerPageHandler, +}: DirectoryTablePaginationProps) => { + const appMargins = useAppMargins() + const classes = useStyles({ appMargins }) + + return ( + + + ( + + )} + labelRowsPerPage="Links per page" + rowsPerPageOptions={[10, 25, 100]} + component="div" + count={resultsCount} + rowsPerPage={rowsPerPage} + page={currentPage} + backIconButtonProps={{ + 'aria-label': 'previous page', + }} + nextIconButtonProps={{ + 'aria-label': 'next page', + }} + onChangePage={changePageHandler} + onChangeRowsPerPage={changeRowsPerPageHandler} + classes={{ + spacer: classes.spacer, + toolbar: classes.toolbar, + caption: classes.caption, + select: classes.select, + selectIcon: classes.selectIcon, + }} + /> + + + ) +} + +export default DirectoryTablePagination diff --git a/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/DirectoryTableRow/index.tsx b/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/DirectoryTableRow/index.tsx new file mode 100755 index 000000000..89e355131 --- /dev/null +++ b/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/DirectoryTableRow/index.tsx @@ -0,0 +1,274 @@ +import React, { FunctionComponent } from 'react' +import { useDispatch } from 'react-redux' +import copy from 'copy-to-clipboard' +import { + TableCell, + TableRow, + Typography, + createStyles, + makeStyles, + useMediaQuery, + useTheme, + Hidden, +} from '@material-ui/core' +import { UrlTypePublic } from '../../../../../reducers/search/types' +import useAppMargins from '../../../../AppMargins/appMargins' +import personIcon from '../../../assets/person-icon.svg' +import { SetSuccessMessageAction } from '../../../../../actions/root/types' +import rootActions from '../../../../../actions/root' +import DirectoryFileIcon from '../../../widgets/DirectoryFileIcon' +import DirectoryUrlIcon from '../../../widgets/DirectoryUrlIcon' + + +type DirectoryTableRowProps = { + url: UrlTypePublic + setUrlInfo: (url: UrlTypePublic) => void + setOpen: (urlInfo:boolean) => void +} + +type DirectoryTableRowStyleProps = { + appMargins: number +} + +const useStyles = makeStyles((theme) => + createStyles({ + shortLinkCell: { + display: 'inline-flex', + width: '100%', + paddingBottom: theme.spacing(0.5), + borderBottom: 'none', + margin: 0, + marginLeft: (props: DirectoryTableRowStyleProps) => props.appMargins, + paddingTop: theme.spacing(4), + flexDirection: 'column', + [theme.breakpoints.up('md')]: { + width: '40%', + paddingTop: theme.spacing(5.5), + paddingRight: () => '10%', + marginLeft: () => 0, + }, + }, + tableRow: { + display: 'flex', + flexWrap: 'wrap', + border: 'none', + borderBottom: '1px solid #d8d8d860', + height: 'fit-content', + '&:hover': { + backgroundColor: '#f9f9f9', + cursor: 'pointer', + }, + }, + shortLinkText: { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + maxWidth: '400px', + [theme.breakpoints.down('sm')]: { + maxWidth: '200px', + }, + }, + emailText: { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + maxWidth: '400px', + [theme.breakpoints.down('sm')]: { + maxWidth: '200px', + }, + textDecoration: 'none', + '&:hover': { + textDecoration: 'underline', + }, + }, + domainTextInactive: { + color: '#BBBBBB', + [theme.breakpoints.up('md')]: { + maxWidth: '400px', + }, + [theme.breakpoints.down('sm')]: { + width: 'calc(100% - 32px)', + }, + }, + domainTextActive: { + [theme.breakpoints.up('md')]: { + maxWidth: '400px', + }, + [theme.breakpoints.down('sm')]: { + width: 'calc(100% - 32px)', + }, + }, + urlInformationCell: { + width: '100%', + marginRight: 0, + }, + contactEmailCell: { + display: 'inline-flex', + margin: 0, + width: '100%', + border: 'none', + paddingLeft: (props: DirectoryTableRowStyleProps) => props.appMargins, + paddingBottom: theme.spacing(4), + [theme.breakpoints.up('md')]: { + paddingTop: theme.spacing(5.5), + paddingBottom: theme.spacing(0.5), + paddingLeft: () => 0, + width: '25%', + flexDirection: 'column', + }, + }, + IconCell: { + display: 'inline-flex', + verticalAlign: 'middle', + [theme.breakpoints.up('md')]: { + paddingRight: theme.spacing(1.5), + marginLeft: (props: DirectoryTableRowStyleProps) => props.appMargins, + }, + }, + mailIconCell: { + [theme.breakpoints.up('md')]: { + paddingTop: theme.spacing(5.0), + paddingLeft: 0, + paddingRight: theme.spacing(1), + marginLeft: 0, + marginRight: 0, + }, + }, + personIcon: { + marginRight: 5, + }, + stateActive: { + color: '#6d9067', + textTransform: 'capitalize', + }, + stateInactive: { + color: '#c85151', + textTransform: 'capitalize', + }, + stateCell: { + [theme.breakpoints.up('md')]: { + paddingTop: theme.spacing(5.5), + minWidth: '100px', + }, + [theme.breakpoints.down('sm')]: { + display: 'inline-flex', + width: '30%', + minWidth: '110px', + paddingLeft: (props: DirectoryTableRowStyleProps) => props.appMargins, + }, + }, + }), +) + +const DirectoryTableRow: FunctionComponent = ({ + url, + setUrlInfo, + setOpen, +}: DirectoryTableRowProps) => { + const appMargins = useAppMargins() + const classes = useStyles({ appMargins }) + const theme = useTheme() + const isMobileView = useMediaQuery(theme.breakpoints.down('sm')) + const dispatch = useDispatch() + + + const onClickEvent = (e:React.MouseEvent) => { + if (!isMobileView && url.state === 'ACTIVE') { + e.stopPropagation() + const redirect = `${window.location.origin}/${url.shortUrl}` + window.open(redirect,'_blank','noopener noreferrer') + } + else if (isMobileView) { + setUrlInfo(url) + setOpen(true) + } + } + + const onClickEmail = (e:React.MouseEvent) => { + if (!isMobileView) { + e.stopPropagation() + copy(url.email) + dispatch( + rootActions.setSuccessMessage('Email has been copied'), + ) + } + } + + return ( + onClickEvent(e)} + key={url.shortUrl} + > + + +
+ {url?.isFile? + + : + + } +
+ {url.state === 'ACTIVE'? + <> + + /{url.shortUrl} + + + : + <> + + /{url.shortUrl} + + + } + +
+
+ + + + + {'• '} + {url.state.toLowerCase()} + + + + + + onClickEmail(e)} + > +
+ Copy email +
+ {String(url.email)} +
+
+
+ ) +} + +export default DirectoryTableRow diff --git a/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/index.tsx b/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/index.tsx new file mode 100755 index 000000000..2e4fb2657 --- /dev/null +++ b/src/client/components/DirectoryPage/DirectoryResults/DirectoryTable/index.tsx @@ -0,0 +1,76 @@ +import React, { FunctionComponent } from 'react' +import { Table, TableBody, createStyles, makeStyles } from '@material-ui/core' +import { UrlTypePublic } from '../../../../reducers/search/types' +import DirectoryTableRow from './DirectoryTableRow' +import DirectoryTablePagination from './DirectoryTablePagination' + +type DirectoryTableProps = { + searchResults: Array + pageCount: number + rowsPerPage: number + changePageHandler: ( + event: React.MouseEvent | null, + pageNumber: number, + ) => void + changeRowsPerPageHandler: ( + event: React.ChangeEvent, + ) => void + currentPage: number + resultsCount: number + disablePagination: boolean + setUrlInfo: (url:UrlTypePublic) => void + setOpen: (urlInfo:boolean) => void +} + +const useStyles = makeStyles((theme) => + createStyles({ + resultsTable: { + boxSizing: 'content-box', + maxWidth: theme.spacing(180), + [theme.breakpoints.up('md')]: { + marginTop: theme.spacing(3), + }, + }, + }), +) + +const DirectoryTable: FunctionComponent = React.memo( + ({ + searchResults, + pageCount, + rowsPerPage, + resultsCount, + currentPage, + disablePagination, + changePageHandler, + changeRowsPerPageHandler, + setUrlInfo, + setOpen, + }: DirectoryTableProps) => { + const classes = useStyles() + return ( + + + {searchResults.map((url: UrlTypePublic) => ( + + ))} + + +
+ ) + }, +) + +export default DirectoryTable diff --git a/src/client/components/DirectoryPage/DirectoryResults/MobilePanel/index.tsx b/src/client/components/DirectoryPage/DirectoryResults/MobilePanel/index.tsx new file mode 100644 index 000000000..00ad478ab --- /dev/null +++ b/src/client/components/DirectoryPage/DirectoryResults/MobilePanel/index.tsx @@ -0,0 +1,171 @@ +import React, { FunctionComponent } from 'react' +import copy from 'copy-to-clipboard' +import { useDispatch } from 'react-redux' +import { + Typography, + makeStyles, + createStyles, + Drawer, + Paper, + Divider, +} from '@material-ui/core' +import { SetSuccessMessageAction } from '../../../../actions/root/types' +import rootActions from '../../../../actions/root' +import useAppMargins from '../../../AppMargins/appMargins' +import { UrlTypePublic } from '../../../../reducers/search/types' +import personIcon from '../../assets/person-icon.svg' +import copyEmailIcon from '../../assets/copy-email-icon.svg' +import RedirectIcon from '../../widgets/RedirectIcon' +import DirectoryFileIcon from '../../widgets/DirectoryFileIcon' +import DirectoryUrlIcon from '../../widgets/DirectoryUrlIcon' + +type MobilePanelProps = { + isOpen: boolean + setOpen: (open: boolean) => void + url: UrlTypePublic +} + +const useStyles = makeStyles((theme) => + createStyles({ + mobilePanel: { + padding: theme.spacing(1.5), + }, + personIcon: { + marginRight: 5, + }, + copyIcon: { + position: 'absolute', + right: '10%' + }, + goToIcon:{ + position: 'absolute', + right: '10%' + }, + row: { + padding: theme.spacing(3), + }, + divider: { + marginTop: theme.spacing(3), + marginBottom: theme.spacing(3), + }, + shortUrlRow: { + display: 'inline-block', + maxWidth: '200px', + width: '100%', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + }, + shortUrlInActive: { + color: "#BBBBBB" + }, + stateIcon: { + verticalAlign: 'middle' + }, + stateActive: { + color: '#6d9067', + textTransform: 'capitalize', + padding: theme.spacing(3), + }, + stateInactive: { + color: '#c85151', + textTransform: 'capitalize', + padding: theme.spacing(3), + }, + }), +) + +const MobilePanel: FunctionComponent = ({ + isOpen, + setOpen, + url, +}: MobilePanelProps) => { + const appMargins = useAppMargins() + const classes = useStyles({ appMargins }) + const dispatch = useDispatch() + + const onClickEvent = () => { + copy(url.email) + dispatch( + rootActions.setSuccessMessage('Email has been copied'), + ) + } + + // inactive and active icons/ colors + const getMobileIcon = () => { + if (url?.state === 'ACTIVE' && url?.isFile) { + return (<> + + /{url?.shortUrl} + ) + } + else if (url?.state === 'ACTIVE' && !url?.isFile) { + return (<> + + /{url?.shortUrl} + ) + } + else if (url?.isFile) { + return (<> + + /{url?.shortUrl} + ) + } + else { + return (<> + + /{url?.shortUrl} + ) + } + } + + return( + setOpen(false)} + onEscapeKeyDown={() => setOpen(false)} + > + + +
+ + + {'• '} + {url?.state.toLowerCase()} + + + + + {url?.email} + onClickEvent()} + /> + + + + ) +} + +export default MobilePanel \ No newline at end of file diff --git a/src/client/components/DirectoryPage/DirectoryResults/index.tsx b/src/client/components/DirectoryPage/DirectoryResults/index.tsx new file mode 100755 index 000000000..fb146a8f4 --- /dev/null +++ b/src/client/components/DirectoryPage/DirectoryResults/index.tsx @@ -0,0 +1,109 @@ +import React, { FunctionComponent, useState } from 'react' +import { + Typography, + createStyles, + makeStyles, + useMediaQuery, + useTheme, +} from '@material-ui/core' +import { ApplyAppMargins } from '../../AppMargins' +import DirectoryTable from './DirectoryTable' +import { UrlTypePublic } from '../../../reducers/search/types' +import useAppMargins from '../../AppMargins/appMargins' +import MobilePanel from './MobilePanel' + +type DirectoryResultsProps = { + searchResults: Array + pageCount: number + rowsPerPage: number + changePageHandler: ( + event: React.MouseEvent | null, + pageNumber: number, + ) => void + changeRowsPerPageHandler: ( + event: React.ChangeEvent, + ) => void + currentPage: number + resultsCount: number + query: string + disablePagination: boolean +} + +const useStyles = makeStyles((theme) => + createStyles({ + resultsHeaderText: { + marginTop: theme.spacing(7.25), + [theme.breakpoints.up('md')]: { + marginTop: theme.spacing(11), + }, + }, + tableWrapper: { + width: '100%', + margin: '0 auto', + minHeight: theme.spacing(40), + [theme.breakpoints.up(1440)]: { + width: theme.spacing(180), + }, + }, + mobilePanel: { + padding: theme.spacing(1.5), + }, + }), +) + +const DirectoryResults: FunctionComponent = ({ + searchResults, + pageCount, + rowsPerPage, + resultsCount, + currentPage, + changePageHandler, + changeRowsPerPageHandler, + query, + disablePagination, +}: DirectoryResultsProps) => { + const appMargins = useAppMargins() + const classes = useStyles({ appMargins }) + const theme = useTheme() + const isMobileView = useMediaQuery(theme.breakpoints.down('sm')) + const [isMobilePanelOpen, setIsMobilePanelOpen] = useState(false) + const [urlInfo, setUrlInfo] = useState() + + return ( +
+ + + {!!resultsCount && + `Showing ${resultsCount} link${ + resultsCount > 1 ? 's' : '' + } for “${query}”`} + {!resultsCount && `No links found for “${query}”`} + + + {!!resultsCount && ( + + )} + + +
+ ) +} + +export default DirectoryResults diff --git a/src/client/components/DirectoryPage/EmptySearchGraphic/index.tsx b/src/client/components/DirectoryPage/EmptySearchGraphic/index.tsx new file mode 100755 index 000000000..4161338bb --- /dev/null +++ b/src/client/components/DirectoryPage/EmptySearchGraphic/index.tsx @@ -0,0 +1,59 @@ +import React, { FunctionComponent } from 'react' +import { + Typography, + createStyles, + makeStyles, + useMediaQuery, + useTheme, +} from '@material-ui/core' +import empyGraphic from '../assets/empty-graphic.svg' + +const useStyles = makeStyles((theme) => + createStyles({ + root: { + display: 'flex', + flexDirection: 'column', + marginTop: theme.spacing(8), + alignItems: 'center', + [theme.breakpoints.up('md')]: { + marginTop: theme.spacing(16), + }, + }, + emptyStateGraphic: { + marginTop: '48px', + marginBottom: '76px', + position: 'relative', + zIndex: -1, + }, + emptyStateBodyText: { + marginTop: '8px', + textAlign: 'center', + padding: '10px', + }, + }), +) + +const EmptyStateGraphic: FunctionComponent = () => { + const classes = useStyles() + const theme = useTheme() + const isMobileView = useMediaQuery(theme.breakpoints.down('sm')) + return ( +
+ + What link are you looking for? + + + Search by keyword or email to see what links other public officers{' '} +
+ have created,and find link owners. The directory is only available{' '} +
+ to users who are signed in. +
+
+ empty search graphic +
+
+ ) +} + +export default EmptyStateGraphic diff --git a/src/client/components/DirectoryPage/assets/arrow.svg b/src/client/components/DirectoryPage/assets/arrow.svg new file mode 100755 index 000000000..cbce0c4aa --- /dev/null +++ b/src/client/components/DirectoryPage/assets/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/components/DirectoryPage/assets/copy-email-icon.svg b/src/client/components/DirectoryPage/assets/copy-email-icon.svg new file mode 100644 index 000000000..f14bbadd6 --- /dev/null +++ b/src/client/components/DirectoryPage/assets/copy-email-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/components/DirectoryPage/assets/empty-graphic.svg b/src/client/components/DirectoryPage/assets/empty-graphic.svg new file mode 100755 index 000000000..e3b2afd27 --- /dev/null +++ b/src/client/components/DirectoryPage/assets/empty-graphic.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/components/DirectoryPage/assets/mail-icon.svg b/src/client/components/DirectoryPage/assets/mail-icon.svg new file mode 100755 index 000000000..93a6f5beb --- /dev/null +++ b/src/client/components/DirectoryPage/assets/mail-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/components/DirectoryPage/assets/person-icon.svg b/src/client/components/DirectoryPage/assets/person-icon.svg new file mode 100755 index 000000000..bfe7e2e9f --- /dev/null +++ b/src/client/components/DirectoryPage/assets/person-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/components/DirectoryPage/index.tsx b/src/client/components/DirectoryPage/index.tsx new file mode 100755 index 000000000..ffd797d80 --- /dev/null +++ b/src/client/components/DirectoryPage/index.tsx @@ -0,0 +1,283 @@ +import React, { FunctionComponent, useEffect, useState } from 'react' +import { + createStyles, + makeStyles, +} from '@material-ui/core' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory, useLocation } from 'react-router-dom' +import querystring from 'querystring' +import debounce from 'lodash/debounce' +import { History } from 'history' +import BaseLayout from '../BaseLayout' +import { GoGovReduxState } from '../../reducers/types' +import useAppMargins from '../AppMargins/appMargins' +import directoryActions from '../../actions/directory' +import { DIRECTORY_PAGE } from '../../util/types' +import { SearchResultsSortOrder } from '../../../shared/search' +import DirectoryHeader from './DirectoryHeader' +import DirectoryResults from './DirectoryResults' +import EmptyStateGraphic from './EmptySearchGraphic' +import { defaultSortOption } from '../../constants/directory' + +type GoSearchParams = { + query: string + sortOrder: SearchResultsSortOrder + rowsPerPage: number + currentPage: number + state: string + isFile: string + isEmail: string +} + +const useStyles = makeStyles(() => + createStyles({ + root: { + height: '100vh', + overflowY: 'auto', + zIndex: 1, + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + flexShrink: 0, + '-ms-flex': '1 1 auto', + }, + }), +) + +type SearchPageProps = {} + +// might carry this over to constants/directory instead +const defaultParams: GoSearchParams = { + query: '', + sortOrder: defaultSortOption, + rowsPerPage: 10, + currentPage: 0, + state: '', + isFile: '', + isEmail: 'false', +} + +const redirectWithParams = (newParams: GoSearchParams, history: History) => { + + const queryObject: any = { query: newParams.query } + for (const [ key, value ] of Object.entries(newParams)) { + // Always ensure that the query is populated. + if (key === 'query') { + if (value && value !== (defaultParams as any)[key]) { + queryObject[key] = value + } + } else { + queryObject[key] = value + } + } + const newPath = { + pathname: DIRECTORY_PAGE, + search: `${querystring.stringify(queryObject)}`, + } + history.push(newPath) +} + +const updateQueryDebounced = debounce(redirectWithParams, 500) + +const SearchPage: FunctionComponent = () => { + const appMargins = useAppMargins() + const classes = useStyles({ appMargins }) + const dispatch = useDispatch() + const history = useHistory() + const location = useLocation() + const [pendingQuery, setPendingQuery] = useState('') + const [queryFile, setQueryFile] = useState(defaultParams.isFile) + const [querystate, setQueryState] = useState(defaultParams.state) + const [queryEmail, setQueryEmail] = useState(defaultParams.isEmail) + const [queryOrder, setQueryOrder] = useState(defaultParams.sortOrder) + const [disablePagination, setDisablePagination] = useState(false) + + const urlParams = querystring.parse(location.search.substring(1)) as Partial< + GoSearchParams + > + const params: GoSearchParams = { + ...defaultParams, + ...urlParams, + } + const { query, sortOrder, state, isFile, isEmail } = params + const rowsPerPage = Number(params.rowsPerPage) + const currentPage = Number(params.currentPage) + + // When the query changes + const getResults = () => + dispatch( + directoryActions.getDirectoryResults( + query, + sortOrder, + rowsPerPage, + currentPage, + state, + isFile, + isEmail, + ), + ) + + const resultsCount = useSelector( + (state: GoGovReduxState) => state.directory.resultsCount, + ) + const queryForResult = useSelector( + (state: GoGovReduxState) => state.directory.queryForResult, + ) + const searchResults = useSelector( + (state: GoGovReduxState) => state.directory.results, + ) + + let pageCount = Math.ceil(resultsCount / rowsPerPage) + + const onQueryChange = (newQuery: string) => { + setPendingQuery(newQuery) + updateQueryDebounced( + { + ...params, + query: newQuery, + state: querystate, + isFile: queryFile, + isEmail: queryEmail, + sortOrder: queryOrder, + currentPage: newQuery === params.query ? params.currentPage : defaultParams.currentPage, + }, + history, + ) + } + + // When applying changes after filtering order, file type and state + const applyChanges = () => { + if (!(queryFile === params.isFile) || !(querystate === params.state) || !(queryOrder === params.sortOrder)) { + redirectWithParams( + { + ...params, + state: querystate, + isFile: queryFile, + sortOrder: queryOrder, + isEmail: queryEmail, + currentPage: defaultParams.currentPage, + }, + history, + ) + } + } + + //reset + const onResetFilter = () => { + // Ensure same differences will not be pushed to history + if (!(queryFile === defaultParams.isFile) || !(querystate === defaultParams.state) || !(queryOrder === defaultParams.sortOrder)) { + redirectWithParams( + { + ...defaultParams, + query: query, + isEmail: queryEmail + }, + history, + ) + } + } + + // Changes when queryEmail changes + useEffect(() => { + setPendingQuery('') + dispatch( + directoryActions.resetDirectoryResults() + ) + redirectWithParams( + { + ...defaultParams, + state: querystate, + isFile: queryFile, + sortOrder: queryOrder, + isEmail: queryEmail, + }, + history, + ) + }, [queryEmail]) + + const onClearQuery = () => { + setPendingQuery('') + } + + // When changing page + const changePageHandler = ( + _: React.MouseEvent | null, + pageNumber: number, + ) => { + redirectWithParams( + { + ...params, + state: querystate, + isFile: queryFile, + sortOrder: queryOrder, + isEmail: queryEmail, + currentPage: pageNumber, + }, + history, + ) + } + + // When changing number of rows in a page + const changeRowsPerPageHandler = ( + event: React.ChangeEvent, + ) => { + redirectWithParams( + { + ...params, + state: querystate, + isFile: queryFile, + sortOrder: queryOrder, + isEmail: queryEmail, + currentPage: 0, + rowsPerPage: parseInt(event.target.value, 10), + }, + history, + ) + } + + const queryToDisplay = (queryForResult || '').trim() + // detect changes in query + useEffect(() => { + if (!query) { + return + } + setPendingQuery(query) + getResults() + }, [query, sortOrder, rowsPerPage, currentPage, state, isFile, isEmail]) + + return ( +
+ + + {queryToDisplay ? ( + + ) : ( + + )} + +
+ ) +} + +export default SearchPage diff --git a/src/client/components/DirectoryPage/widgets/DirectoryFileIcon.tsx b/src/client/components/DirectoryPage/widgets/DirectoryFileIcon.tsx new file mode 100644 index 000000000..20a95a588 --- /dev/null +++ b/src/client/components/DirectoryPage/widgets/DirectoryFileIcon.tsx @@ -0,0 +1,28 @@ +import React, { FunctionComponent } from 'react' + +type DirectoryFileIconProps = { + color?: string + className?: string +} + +const DirectoryFileIcon: FunctionComponent = ({ + className = '', + color = '#384A51' +}) => { + return ( + + + + + ) +} + +export default DirectoryFileIcon diff --git a/src/client/components/DirectoryPage/widgets/DirectoryUrlIcon.tsx b/src/client/components/DirectoryPage/widgets/DirectoryUrlIcon.tsx new file mode 100644 index 000000000..498d29191 --- /dev/null +++ b/src/client/components/DirectoryPage/widgets/DirectoryUrlIcon.tsx @@ -0,0 +1,29 @@ +import React, { FunctionComponent } from 'react' + +type DirectoryUrlIconProps = { + color?: string + className?: string +} + +const DirectoryUrlIcon: FunctionComponent = ({ + className = '', + color = '#384A51' +}) => { + return ( + + + + + ) +} + +export default DirectoryUrlIcon diff --git a/src/client/components/DirectoryPage/widgets/RedirectIcon.tsx b/src/client/components/DirectoryPage/widgets/RedirectIcon.tsx new file mode 100644 index 000000000..e5fb47456 --- /dev/null +++ b/src/client/components/DirectoryPage/widgets/RedirectIcon.tsx @@ -0,0 +1,30 @@ +import React, { FunctionComponent } from 'react' + +type RedirectIconProps = { + color?: string + className?: string +} + +const RedirectIcon: FunctionComponent = ({ + className = '', + color = '#384A51' +}) => { + return ( + + + + + + ) +} + +export default RedirectIcon diff --git a/src/client/components/LoginPage/index.jsx b/src/client/components/LoginPage/index.jsx index 347b127b9..98bad2f54 100644 --- a/src/client/components/LoginPage/index.jsx +++ b/src/client/components/LoginPage/index.jsx @@ -14,7 +14,7 @@ import { Redirect } from 'react-router-dom' import loginActions from '~/actions/login' import rootActions from '~/actions/root' -import { USER_PAGE, loginFormVariants } from '~/util/types' +import { DIRECTORY_PAGE, USER_PAGE, loginFormVariants } from '~/util/types' import GoLogo from '../../assets/go-main-logo.svg' import LoginGraphics from '../../assets/login-page-graphics/login-page-graphics.svg' import { get } from '../../util/requests' @@ -124,9 +124,13 @@ const LoginPage = ({ useEffect(() => { // Google Analytics: Move into login page - GAPageView('EMAIL LOGIN PAGE') - GAEvent('login page', 'email') - }, []) + // Because directory page will redirect to login page first + // We need filter that out + if (location?.state?.previous !== '/directory') { + GAPageView('EMAIL LOGIN PAGE') + GAEvent('login page', 'email') + } + }, [location?.state?.previous]) // Display a login message from the server useEffect(() => { @@ -237,8 +241,16 @@ const LoginPage = ({ ) } - // User is logged in, redirect if available if (location) { + // ensure redirection back to directory and reset the state + if (location?.state?.previous === '/directory') { + // reason why we record directory here is because going into directory page will always go into login page first + // before going into directory page + GAEvent('directory page', 'main') + GAPageView('DIRECTORY PAGE') + return + } + return } return diff --git a/src/client/components/PrivateRoute.jsx b/src/client/components/PrivateRoute.jsx index 4b2c7c086..58b658d0d 100644 --- a/src/client/components/PrivateRoute.jsx +++ b/src/client/components/PrivateRoute.jsx @@ -7,6 +7,7 @@ import loginActions from '~/actions/login' const PrivateRoute = (props) => { const { component: ChildComponent, ...args } = props + const { path } = props const dispatch = useDispatch() const isLoggedIn = useSelector((state) => state.login.isLoggedIn) useEffect(() => { @@ -23,6 +24,7 @@ const PrivateRoute = (props) => { ) diff --git a/src/client/components/RootPage/index.jsx b/src/client/components/RootPage/index.jsx index d33310d08..bc7a53b16 100644 --- a/src/client/components/RootPage/index.jsx +++ b/src/client/components/RootPage/index.jsx @@ -10,6 +10,7 @@ import LoginPage from '~/components/LoginPage' import UserPage from '~/components/UserPage' import NotFoundPage from '~/components/NotFoundPage' import SearchPage from '~/components/SearchPage' +import DirectoryPage from '~/components/DirectoryPage' import MessageSnackbar from '~/components/MessageSnackbar' import ScrollToTop from './ScrollToTop' @@ -21,6 +22,7 @@ import '~/assets/favicon/favicon-16x16.png' import '~/assets/favicon/favicon-32x32.png' import { + DIRECTORY_PAGE, HOME_PAGE, LOGIN_PAGE, NOT_FOUND_PAGE, @@ -40,6 +42,7 @@ const Root = ({ store }) => ( + diff --git a/src/client/components/SearchPage/SearchHeader/index.tsx b/src/client/components/SearchPage/SearchHeader/index.tsx index bfb702c9c..f86fb0672 100644 --- a/src/client/components/SearchPage/SearchHeader/index.tsx +++ b/src/client/components/SearchPage/SearchHeader/index.tsx @@ -92,7 +92,7 @@ const SearchHeader: FunctionComponent = ({ onClearQuery={onClearQuery} onKeyPress={(e) => { if (e.key === 'Enter') { - ;(e.target as any).blur() + (e.target as any).blur() e.preventDefault() } }} diff --git a/src/client/components/UserPage/AnnouncementModal/index.tsx b/src/client/components/UserPage/AnnouncementModal/index.tsx index 6426424b7..31564010c 100644 --- a/src/client/components/UserPage/AnnouncementModal/index.tsx +++ b/src/client/components/UserPage/AnnouncementModal/index.tsx @@ -32,8 +32,17 @@ const useStyles = makeStyles((theme) => backgroundColor: theme.palette.primary.dark, }, announcementImage: { - marginTop: theme.spacing(-4), + width: '600px', + marginLeft: 'auto', + marginRight: 'auto', + paddingLeft: '80px', + paddingRight: '80px', + marginTop: theme.spacing(-6), marginBottom: theme.spacing(4), + [theme.breakpoints.down('sm')]: { + paddingLeft: '0px', + paddingRight: '0px', + }, }, announcementPadding: { paddingBottom: theme.spacing(4), @@ -43,9 +52,19 @@ const useStyles = makeStyles((theme) => justifyContent: 'center', width: '100%', }, + justifyCenterImage: { + display: 'flex', + justifyContent: 'center', + width: '600px', + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + }, messagePadding: { paddingLeft: theme.spacing(4), paddingRight: theme.spacing(4), + whiteSpace: 'pre-line', + textAlign: 'center', }, closeIconButton: { fill: (props) => @@ -174,13 +193,13 @@ export default function AnnouncementModal() { )} {announcement?.image ? ( ) : (
)} {announcement?.subtitle ? ( @@ -197,7 +216,8 @@ export default function AnnouncementModal() { className={`${classes.justifyCenter} ${classes.messagePadding}`} variant="body2" > - {announcement.message} + {/* Enable line break */} + {announcement.message.replace(/\\n/g, '\n')} ) : null}
@@ -210,7 +230,7 @@ export default function AnnouncementModal() { variant="text" className={classes.learnMoreButton} > - Learn More + Try it now ) : null}
diff --git a/src/client/components/UserPage/CreateUrlModal/CreateLinkForm.jsx b/src/client/components/UserPage/CreateUrlModal/CreateLinkForm.jsx index 875290938..ce7539a90 100644 --- a/src/client/components/UserPage/CreateUrlModal/CreateLinkForm.jsx +++ b/src/client/components/UserPage/CreateUrlModal/CreateLinkForm.jsx @@ -289,7 +289,9 @@ function CreateLinkForm({ visible={!!createShortLinkError} type={CollapsibleMessageType.Error} > - {createShortLinkError} + + {createShortLinkError} +
+ + + + + + ) +} diff --git a/src/client/components/widgets/GoDirectoryInput/SortDrawer/index.tsx b/src/client/components/widgets/GoDirectoryInput/SortDrawer/index.tsx new file mode 100755 index 000000000..a4376466c --- /dev/null +++ b/src/client/components/widgets/GoDirectoryInput/SortDrawer/index.tsx @@ -0,0 +1,188 @@ +import React, { FunctionComponent, useState, useEffect } from 'react' +import { + createStyles, + makeStyles, + useMediaQuery, + useTheme, +} from '@material-ui/core' +import useAppMargins from '../../../AppMargins/appMargins' +import BottomDrawer from '../../BottomDrawer' +import SortPanel from '../../SortPanel' +import { SearchResultsSortOrder } from '../../../../../shared/search' +import FilterPanel from './FilterPanel' +import FilterSortPanelFooter from './FilterSortPanelFooter' +import CollapsingPanel from '../../CollapsingPanel' +import { defaultSortOption } from '../../../../constants/directory' + +type SortDrawerProps = { + open: boolean + onClose: () => void + options: Array<{ key: string; label: string }> + onChoose: (orderBy: SearchResultsSortOrder) => void + getFile:(queryFile: string) => void + getState:(queryState: string) => void + onApply: () => void + onReset: () => void +} + +// type SortDrawerStyleProps = {} + +const useStyles = makeStyles((theme) => + createStyles({ + titleText: { + fontSize: '0.8125rem', + fontWeight: 500, + paddingLeft: theme.spacing(4), + [theme.breakpoints.up('md')]: { + color: '#767676', + }, + [theme.breakpoints.down('sm')]: { + paddingTop: theme.spacing(2), + }, + }, + divider: { + marginTop: theme.spacing(1.5), + marginBottom: theme.spacing(0.5), + }, + content: { + display: 'flex', + flexDirection: 'column', + marginTop: theme.spacing(3.5), + marginBottom: theme.spacing(3.5), + zIndex: 3, + }, + sortPanel: { + width: theme.spacing(50), + right: 0, + left: 'auto', + }, + }), +) + +const SortDrawer: FunctionComponent = ({ + open, + onClose, + options, + onChoose, + getFile, + getState, + onApply, + onReset, +}: SortDrawerProps) => { + const appMargins = useAppMargins() + const classes = useStyles({ appMargins }) + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + + const [orderBy, setOrderBy] = useState(defaultSortOption as string) + const [isIncludeFiles, setIsIncludeFiles] = useState(false) + + const [isIncludeLinks, setIsIncludeLinks] = useState(false) + + const [isIncludeActive, setIsIncludeActive] = useState(false) + + const [isIncludeInactive, setIsIncludeInactive] = useState(false) + + const filterConfig = { + isIncludeFiles, + isIncludeLinks, + isIncludeActive, + isIncludeInactive, + setIsIncludeFiles, + setIsIncludeLinks, + setIsIncludeActive, + setIsIncludeInactive, + } + + // Order check + useEffect(() => { + onChoose(orderBy as SearchResultsSortOrder) + }, [orderBy]) + + // File check + useEffect(() => { + if (isIncludeFiles === isIncludeLinks) { + getFile('') + } + else if (isIncludeFiles) { + getFile('true') + } + else { + getFile('false') + } + }, [isIncludeFiles, isIncludeLinks]) + + // State check + useEffect(() => { + if (isIncludeActive === isIncludeInactive) { + getState('') + } + else if (isIncludeActive) { + getState('ACTIVE') + } + else { + getState('INACTIVE') + } + }, [isIncludeActive, isIncludeInactive]) + + + // Close the modal and hit endpoint + const applyChange = () => { + onClose() + onApply() + } + + const reset = () => { + // reset current component's state + setOrderBy(defaultSortOption as string) + setIsIncludeFiles(false) + setIsIncludeLinks(false) + setIsIncludeActive(false) + setIsIncludeInactive(false) + // reset to initial config + onClose() + onReset() + } + + return ( + <> + {isMobile ? + +
+ + + +
+
+ : + +
+ + + +
+
+ } + + + ) +} + +export default SortDrawer diff --git a/src/client/components/widgets/GoDirectoryInput/SortDrawer/widgets/ArrowDownIcon.tsx b/src/client/components/widgets/GoDirectoryInput/SortDrawer/widgets/ArrowDownIcon.tsx new file mode 100755 index 000000000..072c83bf1 --- /dev/null +++ b/src/client/components/widgets/GoDirectoryInput/SortDrawer/widgets/ArrowDownIcon.tsx @@ -0,0 +1,21 @@ +import React, { FunctionComponent } from 'react' + +type ArrowDownIconProps = { + className?: string + width?: string + height?: string +} + +const ArrowDownIcon: FunctionComponent = ({ + className = '', + width = '36', + height = '36', +}) => { + return ( + + + + ) +} + +export default ArrowDownIcon diff --git a/src/client/components/widgets/GoDirectoryInput/SortDrawer/widgets/CheckIcon.tsx b/src/client/components/widgets/GoDirectoryInput/SortDrawer/widgets/CheckIcon.tsx new file mode 100755 index 000000000..18495167c --- /dev/null +++ b/src/client/components/widgets/GoDirectoryInput/SortDrawer/widgets/CheckIcon.tsx @@ -0,0 +1,29 @@ +import React, { FunctionComponent } from 'react' + +type CheckIconProps = { + color?: string + className?: string +} + +const CheckIcon: FunctionComponent = ({ + color = '#000', + className = '', +}) => { + return ( + + + + ) +} + +export default CheckIcon diff --git a/src/client/components/widgets/GoDirectoryInput/index.tsx b/src/client/components/widgets/GoDirectoryInput/index.tsx new file mode 100755 index 000000000..93a93ae46 --- /dev/null +++ b/src/client/components/widgets/GoDirectoryInput/index.tsx @@ -0,0 +1,316 @@ +import React, { FunctionComponent, useEffect, useState } from 'react' +import { + ClickAwayListener, + IconButton, + TextField, + createStyles, + makeStyles, + useMediaQuery, + useTheme, + Divider, + Button, +} from '@material-ui/core' +import CloseIcon from '../CloseIcon' +import SearchSortIcon from '../SearchSortIcon' +import SearchIcon from '../SearchIcon' +import EmailIcon from '../EmailIcon' +import { sortOptions } from '../../../constants/directory' +import { SearchResultsSortOrder } from '../../../../shared/search' +import SortDrawer from './SortDrawer' +import ArrowDownIcon from './SortDrawer/widgets/ArrowDownIcon' +import FilterDrawer from './FilterDrawer' + +type GoSearchInputProps = { + showAdornments?: boolean + onQueryChange?: (query: string) => void + onSortOrderChange?: (order: SearchResultsSortOrder) => void + onClearQuery?: () => void + onKeyPress?: (e: React.KeyboardEvent) => void + query: string + getFile:(queryFile: string) => void + getState:(queryState: string) => void + getEmail:(queryEmail: string) => void + setDisablePagination:(disablePagination:boolean) => void + onApply: () => void + onReset: () => void +} + +const useStyles = makeStyles((theme) => + createStyles({ + root: { + width: '100%', + height: '44px', + [theme.breakpoints.up('md')]: { + height: '70px', + }, + }, + searchTextField: { + width: '100%', + height: '100%', + '& input::-webkit-search-decoration, & input::-webkit-search-cancel-button, & input::-webkit-search-results-button, & input::-webkit-search-results-decoration': { + display: 'none', + }, + }, + searchInput: { + height: '100%', + background: 'white', + boxShadow: '0px 0px 30px rgba(0, 0, 0, 0.25)', + borderRadius: '5px', + border: 0, + paddingRight: 0, + paddingLeft: 0, + [theme.breakpoints.up('md')]: { + paddingRight: theme.spacing(2), + }, + }, + searchInputNested: { + [theme.breakpoints.up('md')]: { + fontSize: '1rem', + }, + }, + searchInputIcon: { + marginTop: '6px', + marginLeft: theme.spacing(2), + marginRight: theme.spacing(1), + [theme.breakpoints.up('md')]: { + marginLeft: theme.spacing(4), + marginRight: theme.spacing(2.5), + }, + }, + searchbar:{ + display: 'contents' + }, + searchOptionsButton: { + padding: theme.spacing(0.75), + marginRight: theme.spacing(1.5), + [theme.breakpoints.up('md')]: { + marginRight: theme.spacing(2.5), + padding: theme.spacing(1.5), + }, + }, + closeButton: { + padding: theme.spacing(0.75), + [theme.breakpoints.up('md')]: { + padding: theme.spacing(1.5), + }, + }, + sortPanel: { + width: theme.spacing(50), + right: 0, + left: 'auto', + }, + filterButton: { + height: '100%', + paddingLeft: '30px', + paddingRight: '30px', + borderRadius: '0px' + }, + filterIcon: { + paddingLeft: theme.spacing(1.5), + verticalAlign: 'middle', + [theme.breakpoints.down('sm')]: { + paddingLeft: theme.spacing(0.0), + }, + }, + buttonWrapper: { + width: 'auto', + display: 'inline-block', + [theme.breakpoints.down('sm')]: { + display: 'inline-flex', + }, + }, + labelWrapper: { + verticalAlign: 'middle', + }, + }), +) + +const noOp = () => {} + +const GoDirectoryInput: FunctionComponent = ({ + showAdornments, + query, + onQueryChange = noOp, + onSortOrderChange = noOp, + onClearQuery = noOp, + onKeyPress = noOp, + getFile = noOp, + getState = noOp, + getEmail = noOp, + onApply = noOp, + onReset = noOp, + setDisablePagination = noOp, +}: GoSearchInputProps) => { + const [isSortPanelOpen, setIsSortPanelOpen] = useState(false) + const [isFilterOpen, setIsFilterOpen] = useState(false) + const [isEmail, setIsEmail] = useState(false) + const classes = useStyles() + const theme = useTheme() + const isMobileView = useMediaQuery(theme.breakpoints.down('sm')) + + // Checks and assign email variable + useEffect(() => { + if(isEmail) { + getEmail('true') + } + else { + getEmail('false') + } + }, [isEmail]) + + // If sort panel is open, set pagination z-index to -1, else reset z-index to 1 + // this prevent pagination from intersecting with sort panel + useEffect(() => { + if (isSortPanelOpen) { + setDisablePagination(true) + } + else { + setDisablePagination(false) + } + + }, [isSortPanelOpen]) + + // Label for the button - requires double conditions + const getSearchLabel = (isEmail: boolean, isMobileView: boolean) => { + if (isMobileView && isEmail) { + return () + } + else if (isMobileView && !isEmail) { + return () + } + else if (!isMobileView && isEmail) { + return 'Email' + } + else { + return 'Keyword' + } + } + + // Icon for search bar + const getSearchIcon = (isEmail: boolean, isMobileView:boolean) => { + if (isMobileView) { + return '' + } + else if (isEmail) { + return () + } + else { + return () + } + } + + return ( + { + if (!isMobileView) { + setIsSortPanelOpen(false) + } + setIsFilterOpen(false) + setDisablePagination(false) + }} + > +
+ onQueryChange(e.target.value)} + onKeyPress={onKeyPress} + variant="outlined" + InputProps={{ + className: classes.searchInput, + startAdornment: ( +
+ + + +
+ {getSearchIcon(isEmail, isMobileView)} +
+
+ + ), + endAdornment: ( + <> + {showAdornments && ( + <> + {query && ( + + + + )} + setIsSortPanelOpen(true)} + > + + + + )} + + ), + }} + // TextField takes in two separate inputProps and InputProps, + // each having its own purpose. + // eslint-disable-next-line react/jsx-no-duplicate-props + inputProps={{ + className: classes.searchInputNested, + onClick: () => setIsSortPanelOpen(false), + }} + /> + + + + setIsSortPanelOpen(false)} + onChoose={onSortOrderChange} + options={sortOptions} + getFile={getFile} + getState={getState} + onApply={onApply} + onReset={onReset} + /> +
+
+ ) +} + +export default GoDirectoryInput diff --git a/src/client/components/widgets/GoDirectoryInput/styles.tsx b/src/client/components/widgets/GoDirectoryInput/styles.tsx new file mode 100755 index 000000000..9436e35af --- /dev/null +++ b/src/client/components/widgets/GoDirectoryInput/styles.tsx @@ -0,0 +1,147 @@ +import { createStyles, makeStyles } from '@material-ui/core' + +const useSearchInputHeight = () => 48 + +export default makeStyles((theme) => + createStyles({ + collapse: { + width: '100%', + position: 'absolute', + left: 0, + top: useSearchInputHeight() + 10, + zIndex: 1000, + [theme.breakpoints.down('sm')]: { + top: 0, + height: '100% !important', // Bypass Material UI uses element style + minHeight: '800px !important', + }, + }, + collapseWrapper: { + [theme.breakpoints.down('sm')]: { + height: '100%', + }, + }, + closeIcon: { + position: 'absolute', + top: 0, + right: 0, + margin: theme.spacing(1), + [theme.breakpoints.down('sm')]: { + margin: theme.spacing(3), + }, + }, + sortButtonGrid: { + height: '67px', + width: '100%', + }, + sectionHeader: { + paddingLeft: theme.spacing(4), + [theme.breakpoints.up('md')]: { + color: '#767676', + }, + }, + sortHeaderGrid: { + marginBottom: theme.spacing(1), + }, + filterHeaderGrid: { + marginTop: theme.spacing(0.5), + marginBottom: theme.spacing(1), + [theme.breakpoints.down('sm')]: { + marginTop: theme.spacing(5.75), + }, + }, + filterSectionGrid: { + marginLeft: theme.spacing(4), + }, + filterSectionHeader: { + marginTop: theme.spacing(2.5), + fontWeight: 500, + marginBottom: theme.spacing(0.5), + }, + filterLabelLeft: { + fontWeight: 400, + width: '100px', + }, + filterLabelRight: { + fontWeight: 400, + }, + divider: { + width: '100%', + }, + dividerGrid: { + marginBottom: theme.spacing(0.5), + [theme.breakpoints.down('sm')]: { + marginBottom: theme.spacing(4), + }, + }, + root: { + boxShadow: '0 2px 10px 0 rgba(0, 0, 0, 0.1)', + border: 'solid 1px #e8e8e8', + height: '100%', + overflow: 'hidden', + }, + leftCheckbox: { + marginLeft: theme.spacing(-1.5), + }, + sortButtonRoot: { + borderRadius: 0, + }, + sortButton: { + height: '100%', + justifyContent: 'start', + }, + sortButtonSelected: { + height: '100%', + justifyContent: 'start', + background: '#f9f9f9', + }, + columnLabel: { + paddingLeft: theme.spacing(3), + fontWeight: 400, + flex: 1, + textAlign: 'left', + }, + checkIcon: { + marginLeft: 'auto', + marginRight: theme.spacing(4), + flexShrink: 0, + flexGrow: 0, + }, + sortButtonLabel: { + display: 'flex', + alignItems: 'flex-start', + }, + applyButton: { + width: '121px', + height: '45px', + [theme.breakpoints.down('sm')]: { + width: '100%', + height: '55px', + marginRight: theme.spacing(4), + }, + }, + footer: { + padding: theme.spacing(2), + marginRight: theme.spacing(4), + [theme.breakpoints.down('sm')]: { + marginTop: theme.spacing(11), + flexDirection: 'column-reverse', + }, + }, + resetButton: { + marginRight: theme.spacing(1.5), + [theme.breakpoints.down('sm')]: { + width: '100%', + height: '55px', + }, + }, + buttonGrid: { + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + }, + collapsingPanel: { + width: '100%', + }, + }), +) diff --git a/src/client/components/widgets/PaginationActionComponent/index.jsx b/src/client/components/widgets/PaginationActionComponent/index.jsx index b96efb883..30241f760 100644 --- a/src/client/components/widgets/PaginationActionComponent/index.jsx +++ b/src/client/components/widgets/PaginationActionComponent/index.jsx @@ -17,6 +17,18 @@ const useStyles = makeStyles(() => height: '100%', zIndex: 1, }, + pageSelectGridDirectory: { + fontWeight: 500, + color: '#767676', + position: 'absolute', + top: 0, + left: 0, + display: 'flex', + justifyContent: 'center', + width: '100%', + height: '100%', + zIndex: -1, + }, gridItemHorizontalPadding: { '&:first-child': { paddingRight: 34, @@ -28,10 +40,24 @@ const useStyles = makeStyles(() => }), ) -export default ({ pageCount, onChangePage, page }) => { +export default ({ + pageCount, + onChangePage, + page, + disablePagination = false, +}) => { const classes = useStyles() return ( - + onChangePage(event, page - 1)} diff --git a/src/client/components/widgets/SearchIcon.tsx b/src/client/components/widgets/SearchIcon.tsx index 658d48a52..da0a6f60a 100644 --- a/src/client/components/widgets/SearchIcon.tsx +++ b/src/client/components/widgets/SearchIcon.tsx @@ -1,11 +1,11 @@ -import React, { FunctionComponent } from 'react' +import React from 'react' type SearchIconProps = { size: number color?: string } -const SearchIcon: FunctionComponent = ({ +const SearchIcon = ({ size, color = '#384A51', }: SearchIconProps) => { diff --git a/src/client/components/widgets/SortPanel/index.tsx b/src/client/components/widgets/SortPanel/index.tsx index b3939ae1c..1d1a6ea82 100644 --- a/src/client/components/widgets/SortPanel/index.tsx +++ b/src/client/components/widgets/SortPanel/index.tsx @@ -21,7 +21,6 @@ export default React.memo( const classes = useStyles() const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('sm')) - return ( {!noHeader && ( diff --git a/src/client/constants/directory.ts b/src/client/constants/directory.ts new file mode 100755 index 000000000..3ff2768cb --- /dev/null +++ b/src/client/constants/directory.ts @@ -0,0 +1,10 @@ +import { SearchResultsSortOrder } from '../../shared/search' + +export const sortOptions = [ + { key: SearchResultsSortOrder.Popularity, label: 'Most popular' }, + { key: SearchResultsSortOrder.Recency, label: 'Most recent' }, +] + +export const defaultSortOption = SearchResultsSortOrder.Recency + +export default { sortOptions, defaultSortOption } diff --git a/src/client/locale/en/translation.ts b/src/client/locale/en/translation.ts index a6cdb4081..abb35ffad 100644 --- a/src/client/locale/en/translation.ts +++ b/src/client/locale/en/translation.ts @@ -23,6 +23,7 @@ const translationEn = { }, links: { contribute: 'https://go.gov.sg/go-opensource', + directory: '/directory', faq: 'https://guide.go.gov.sg/faq.html', privacy: 'https://guide.go.gov.sg/privacy.html', terms: 'https://guide.go.gov.sg/termsofuse.html', diff --git a/src/client/reducers/directory/index.ts b/src/client/reducers/directory/index.ts new file mode 100755 index 000000000..3dd33b441 --- /dev/null +++ b/src/client/reducers/directory/index.ts @@ -0,0 +1,36 @@ +import { DirectoryState } from './types' +import { + DirectoryActionType, + SET_DIRECTORY_RESULTS, + SET_INITIAL_STATE, +} from '../../actions/directory/types' + +export const initialState: DirectoryState = { + results: [], + resultsCount: 0, + queryForResult: null, +} + +const directory: ( + state: DirectoryState, + action: DirectoryActionType, +) => DirectoryState = (state = initialState, action) => { + let nextState: Partial = {} + switch (action.type) { + case SET_DIRECTORY_RESULTS: + nextState = { + resultsCount: action.payload.count, + results: action.payload.urls, + queryForResult: action.payload.query, + } + break + case SET_INITIAL_STATE: + nextState = initialState + break + default: + break + } + return { ...state, ...nextState } +} + +export default directory diff --git a/src/client/reducers/directory/types.ts b/src/client/reducers/directory/types.ts new file mode 100755 index 000000000..f19ea3772 --- /dev/null +++ b/src/client/reducers/directory/types.ts @@ -0,0 +1,14 @@ +import { UrlType } from '../user/types' + +export type UrlTypePublic = Omit + +export enum UrlState { + Active = 'ACTIVE', + Inactive = 'INACTIVE', +} + +export type DirectoryState = { + results: Array + resultsCount: number + queryForResult: string | null +} diff --git a/src/client/reducers/index.js b/src/client/reducers/index.js index 866898f3f..09c0000d0 100644 --- a/src/client/reducers/index.js +++ b/src/client/reducers/index.js @@ -4,6 +4,7 @@ import user from '~/reducers/user' import root from '~/reducers/root' import home from '~/reducers/home' import search from '~/reducers/search' +import directory from '~/reducers/directory' const reducer = combineReducers({ login, @@ -11,5 +12,6 @@ const reducer = combineReducers({ root, home, search, + directory, }) export default reducer diff --git a/src/client/reducers/types.ts b/src/client/reducers/types.ts index 2546bdef1..560a11d18 100644 --- a/src/client/reducers/types.ts +++ b/src/client/reducers/types.ts @@ -3,6 +3,7 @@ import { LoginState } from './login/types' import { UserState } from './user/types' import { RootState } from './root/types' import { SearchState } from './search/types' +import { DirectoryState } from './directory/types' export type GoGovReduxState = { user: UserState @@ -10,4 +11,5 @@ export type GoGovReduxState = { root: RootState login: LoginState search: SearchState + directory: DirectoryState } diff --git a/src/client/reducers/user/types.ts b/src/client/reducers/user/types.ts index 50f914e76..42fc63928 100644 --- a/src/client/reducers/user/types.ts +++ b/src/client/reducers/user/types.ts @@ -37,6 +37,7 @@ export type UrlType = { editedDescription: string contactEmail: string editedContactEmail: string + email: string } export type UserState = { diff --git a/src/client/util/types.ts b/src/client/util/types.ts index 7347ef7f0..d6078ce96 100644 --- a/src/client/util/types.ts +++ b/src/client/util/types.ts @@ -3,6 +3,7 @@ export const LOGIN_PAGE = '/login' export const USER_PAGE = '/user' export const SEARCH_PAGE = '/search' export const NOT_FOUND_PAGE = '/404/:shortUrl' +export const DIRECTORY_PAGE = '/directory' export const snackbarVariants = { ERROR: 0, INFO: 1, SUCCESS: 2 } export const loginFormVariants = { diff --git a/src/server/api/directory.ts b/src/server/api/directory.ts new file mode 100644 index 000000000..ae4a7de9b --- /dev/null +++ b/src/server/api/directory.ts @@ -0,0 +1,34 @@ +import Express from 'express' +import { createValidator } from 'express-joi-validation' +import Joi from '@hapi/joi' +import { container } from '../util/inversify' +import { DependencyIds } from '../constants' +import { DirectoryControllerInterface } from '../controllers/interfaces/DirectoryControllerInterface' +import { SearchResultsSortOrder } from '../../shared/search' + +const urlSearchRequestSchema = Joi.object({ + query: Joi.string().required(), + order: Joi.string() + .required() + .allow(...Object.values(SearchResultsSortOrder)) + .only(), + limit: Joi.number(), + offset: Joi.number(), + state: Joi.string().allow(''), + isFile: Joi.string().allow(''), + isEmail: Joi.string().required(), +}) + +const router = Express.Router() +const validator = createValidator({ passError: true }) +const directoryController = container.get( + DependencyIds.directoryController, +) + +router.get( + '/search', + validator.query(urlSearchRequestSchema), + directoryController.getDirectoryWithConditions, +) + +module.exports = router diff --git a/src/server/api/index.ts b/src/server/api/index.ts index e394534b4..e45a014c5 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -48,6 +48,7 @@ function preprocess( router.use('/user', userGuard, preprocess, require('./user')) router.use('/qrcode', userGuard, require('./qrcode')) router.use('/link-stats', userGuard, require('./link-statistics')) +router.use('/directory', userGuard, require('./directory')) router.use((_, res) => { res.status(404).render(ERROR_404_PATH) diff --git a/src/server/constants.ts b/src/server/constants.ts index 2c02de1e8..e39fdc61d 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -30,7 +30,9 @@ export const DependencyIds = { userController: Symbol.for('userController'), qrCodeService: Symbol.for('qrCodeService'), urlSearchService: Symbol.for('urlSearchService'), + directorySearchService: Symbol.for('directorySearchService'), searchController: Symbol.for('searchController'), + directoryController: Symbol.for('directoryController'), linkStatisticsController: Symbol.for('linkStatisticsController'), linkStatisticsService: Symbol.for('linkStatisticsService'), linkStatisticsRepository: Symbol.for('linkStatisticsRepository'), diff --git a/src/server/controllers/DirectoryController.ts b/src/server/controllers/DirectoryController.ts new file mode 100644 index 000000000..adba78160 --- /dev/null +++ b/src/server/controllers/DirectoryController.ts @@ -0,0 +1,67 @@ +import { inject, injectable } from 'inversify' +import Express from 'express' +import { DirectorySearchServiceInterface } from '../services/interfaces/DirectorySearchServiceInterface' +import { DependencyIds } from '../constants' +import { logger } from '../config' +import jsonMessage from '../util/json' +import { DirectoryControllerInterface } from './interfaces/DirectoryControllerInterface' +import { SearchResultsSortOrder } from '../../shared/search' + +@injectable() +export class DirectoryController implements DirectoryControllerInterface { + private directorySearchService: DirectorySearchServiceInterface + + public constructor( + @inject(DependencyIds.directorySearchService) + directorySearchService: DirectorySearchServiceInterface, + ) { + this.directorySearchService = directorySearchService + } + + public getDirectoryWithConditions: ( + req: Express.Request, + res: Express.Response, + ) => Promise = async (req, res) => { + let { limit = 100, query = '', order = '' } = req.query + limit = Math.min(100, Number(limit)) + query = query.toString().toLowerCase() + order = order.toString() + const { offset = 0, isFile, state, isEmail } = req.query + + const queryConditions = { + query, + order: order as SearchResultsSortOrder, + limit, + offset: Number(offset), + state: state?.toString(), + isFile: undefined as boolean | undefined, + isEmail: isEmail === 'true', + } + + // Reassign isFile to true / false / undefined (take both files and url) + if (isFile === 'true') { + queryConditions.isFile = true + } else if (isFile === 'false') { + queryConditions.isFile = false + } else { + queryConditions.isFile = undefined + } + + try { + const { urls, count } = await this.directorySearchService.plainTextSearch( + queryConditions, + ) + + res.ok({ + urls, + count, + }) + return + } catch (error) { + logger.error(`Error searching urls: ${error}`) + res.serverError(jsonMessage('Error retrieving URLs for search')) + } + } +} + +export default DirectoryController diff --git a/src/server/controllers/interfaces/DirectoryControllerInterface.ts b/src/server/controllers/interfaces/DirectoryControllerInterface.ts new file mode 100644 index 000000000..55ec9a7a6 --- /dev/null +++ b/src/server/controllers/interfaces/DirectoryControllerInterface.ts @@ -0,0 +1,14 @@ +import Express from 'express' + +export interface DirectoryControllerInterface { + /** + * Controller for plain text search on urls. + * @param {Express.Request} req Express request. + * @param {Express.Response} res Express response. + * @returns Empty promise that resolves when the request has been processed. + */ + getDirectoryWithConditions( + req: Express.Request, + res: Express.Response, + ): Promise +} diff --git a/src/server/inversify.config.ts b/src/server/inversify.config.ts index 45627166e..ccdf4f48a 100644 --- a/src/server/inversify.config.ts +++ b/src/server/inversify.config.ts @@ -43,7 +43,9 @@ import { UrlManagementService } from './services/UrlManagementService' import { UserController } from './controllers/UserController' import { QrCodeService } from './services/QrCodeService' import { SearchController } from './controllers/SearchController' +import { DirectoryController } from './controllers/DirectoryController' import { UrlSearchService } from './services/UrlSearchService' +import { DirectorySearchService } from './services/DirectorySearchService' import { LinkStatisticsController } from './controllers/LinkStatisticsController' import { LinkStatisticsService } from './services/LinkStatisticsService' import { LinkStatisticsRepository } from './repositories/LinkStatisticsRepository' @@ -102,6 +104,8 @@ export default () => { bindIfUnbound(DependencyIds.qrCodeService, QrCodeService) bindIfUnbound(DependencyIds.searchController, SearchController) bindIfUnbound(DependencyIds.urlSearchService, UrlSearchService) + bindIfUnbound(DependencyIds.directorySearchService, DirectorySearchService) + bindIfUnbound(DependencyIds.directoryController, DirectoryController) bindIfUnbound(DependencyIds.deviceCheckService, DeviceCheckService) container diff --git a/src/server/models/url.ts b/src/server/models/url.ts index 81931f885..a8ddb40e5 100644 --- a/src/server/models/url.ts +++ b/src/server/models/url.ts @@ -1,5 +1,4 @@ import Sequelize from 'sequelize' - import { ACTIVE, INACTIVE } from './types' import { isBlacklisted, @@ -27,6 +26,7 @@ export interface UrlType extends IdType, UrlBaseType, Sequelize.Model { readonly clicks: number readonly createdAt: string readonly updatedAt: string + readonly email: string } // For sequelize define @@ -34,6 +34,18 @@ type UrlTypeStatic = typeof Sequelize.Model & { new (values?: object, options?: Sequelize.BuildOptions): UrlType } +// Escape characters +export const sanitise = (query: string): string => { + // check legit domain + if (emailValidator.match(query)) { + // remove wildcards characters and escape characters + const inputRaw = query.replace(/(%|\\)/g, '') + return `%${inputRaw}` + } + + return '' +} + export const Url = sequelize.define( 'url', { diff --git a/src/server/repositories/UrlRepository.ts b/src/server/repositories/UrlRepository.ts index 01c70b6fe..c56b4fc2f 100644 --- a/src/server/repositories/UrlRepository.ts +++ b/src/server/repositories/UrlRepository.ts @@ -2,7 +2,7 @@ import { inject, injectable } from 'inversify' import { QueryTypes } from 'sequelize' -import { Url, UrlType } from '../models/url' +import { Url, UrlType, sanitise } from '../models/url' import { NotFoundError } from '../util/error' import { redirectClient } from '../redis' import { @@ -15,11 +15,18 @@ import { sequelize } from '../util/sequelize' import { DependencyIds } from '../constants' import { FileVisibility, S3Interface } from '../services/aws' import { UrlRepositoryInterface } from './interfaces/UrlRepositoryInterface' -import { StorableFile, StorableUrl, UrlsPaginated } from './types' +import { + StorableFile, + StorableUrl, + UrlDirectory, + UrlDirectoryPaginated, + UrlsPaginated, +} from './types' import { StorableUrlState } from './enums' import { Mapper } from '../mappers/Mapper' import { SearchResultsSortOrder } from '../../shared/search' import { urlSearchConditions, urlSearchVector } from '../models/search' +import { DirectoryQueryConditions } from '../services/interfaces/DirectorySearchServiceInterface' const { Public, Private } = FileVisibility @@ -167,6 +174,165 @@ export class UrlRepository implements UrlRepositoryInterface { } } + public rawDirectorySearch: ( + conditions: DirectoryQueryConditions, + ) => Promise = async (conditions) => { + const { query, order, limit, offset, state, isFile, isEmail } = conditions + + const { tableName } = Url + + const urlVector = urlSearchVector + + const rankingAlgorithm = this.getRankingAlgorithm(order, tableName) + + const urlsModel = await (isEmail + ? this.getRelevantUrlsFromEmail( + query, + rankingAlgorithm, + limit, + offset, + state, + isFile, + ) + : this.getRelevantUrlsFromText( + urlVector, + rankingAlgorithm, + limit, + offset, + query, + state, + isFile, + )) + + return urlsModel + } + + private async getRelevantUrlsFromEmail( + query: string, + rankingAlgorithm: string, + limit: number, + offset: number, + state: string | undefined, + isFile: boolean | undefined, + ): Promise { + const emails = query.toString().split(' ') + // split email/domains by space into tokens, also reduces injections + const likeQuery = emails.map(sanitise) + + const queryFile = this.getQueryFileEmail(isFile) + const queryState = this.getQueryStateEmail(state) + + // TODO: optimize the search query, possibly with reverse-email search + const rawQuery = ` + SELECT "users"."email", "urls"."shortUrl", "urls"."state", "urls"."isFile" + FROM urls AS "urls" + JOIN users + ON "urls"."userId" = "users"."id" + AND "users"."email" LIKE ANY (ARRAY[:likeQuery]) + AND "urls"."isFile" IN (:queryFile) + AND "urls"."state" In (:queryState) + ORDER BY (${rankingAlgorithm}) DESC` + + // Search only once to get both urls and count + const urlsModel = (await sequelize.query(rawQuery, { + replacements: { + likeQuery, + queryFile, + queryState, + }, + type: QueryTypes.SELECT, + model: Url, + raw: true, + mapToModel: true, + })) as Array + + const count = urlsModel.length + const ending = Math.min(count, offset + limit) + const slicedUrlsModel = urlsModel.slice(offset, ending) + + return { count, urls: slicedUrlsModel } + } + + private async getRelevantUrlsFromText( + urlVector: string, + rankingAlgorithm: string, + limit: number, + offset: number, + query: string, + state: string | undefined, + isFile: boolean | undefined, + ): Promise { + const queryFile = this.getQueryFileText(isFile) + const queryState = this.getQueryStateText(state) + const rawQuery = ` + SELECT "urls"."shortUrl", "users"."email", "urls"."state", "urls"."isFile" + FROM urls AS "urls" + JOIN users + ON "urls"."userId" = "users"."id" + JOIN plainto_tsquery('english', $query) query + ON query @@ (${urlVector}) + ${queryFile} + ${queryState} + ORDER BY (${rankingAlgorithm}) DESC` + + // Search only once to get both urls and count + const urlsModel = (await sequelize.query(rawQuery, { + bind: { + query, + }, + raw: true, + type: QueryTypes.SELECT, + model: Url, + mapToModel: true, + })) as Array + + const count = urlsModel.length + const ending = Math.min(count, offset + limit) + const slicedUrlsModel = urlsModel.slice(offset, ending) + + return { count, urls: slicedUrlsModel } + } + + private getQueryFileEmail: (isFile: boolean | undefined) => Array = ( + isFile, + ) => { + let queryFile = [true, false] + if (isFile === true) queryFile = [true] + else if (isFile === false) queryFile = [false] + + return queryFile + } + + private getQueryStateEmail: (state: string | undefined) => Array = ( + state, + ) => { + let queryState = ['ACTIVE', 'INACTIVE'] + if (state === 'ACTIVE') queryState = ['ACTIVE'] + else if (state === 'INACTIVE') queryState = ['INACTIVE'] + + return queryState + } + + private getQueryFileText: (isFile: boolean | undefined) => string = ( + isFile, + ) => { + let queryFile = '' + if (isFile === true) queryFile = `AND urls."isFile"=true` + else if (isFile === false) queryFile = `AND urls."isFile"=false` + + return queryFile + } + + private getQueryStateText: (state: string | undefined) => string = ( + state, + ) => { + let queryState = '' + if (state === 'ACTIVE') queryState = `AND urls.state = 'ACTIVE'` + else if (state === 'INACTIVE') queryState = `AND urls.state = 'INACTIVE'` + + return queryState + } + /** * Invalidates the redirect entry on the cache for the input * short url. diff --git a/src/server/repositories/interfaces/UrlRepositoryInterface.ts b/src/server/repositories/interfaces/UrlRepositoryInterface.ts index d4013f2df..dc59d28dc 100644 --- a/src/server/repositories/interfaces/UrlRepositoryInterface.ts +++ b/src/server/repositories/interfaces/UrlRepositoryInterface.ts @@ -1,4 +1,10 @@ -import { StorableFile, StorableUrl, UrlsPaginated } from '../types' +import { + StorableFile, + StorableUrl, + UrlDirectoryPaginated, + UrlsPaginated, +} from '../types' +import { DirectoryQueryConditions } from '../../services/interfaces/DirectorySearchServiceInterface' import { SearchResultsSortOrder } from '../../../shared/search' /** @@ -57,4 +63,13 @@ export interface UrlRepositoryInterface { limit: number, offset: number, ) => Promise + + /** + * Performs search for email and plain text search. + * @param {DirectoryQueryConditions} conditions The search query conditions. + * @returns Promise of total no. Of search results and the results on the current page. + */ + rawDirectorySearch: ( + conditions: DirectoryQueryConditions, + ) => Promise } diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 000bb2f55..8baecccf6 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -48,6 +48,19 @@ export type UrlPublic = Pick< 'shortUrl' | 'longUrl' | 'description' | 'contactEmail' | 'isFile' > +// to be possibly changed +export type UrlDirectory = { + shortUrl: string + email: string + state: string + isFile: boolean +} + +export type UrlDirectoryPaginated = { + count: number + urls: Array +} + export type UrlsPublicPaginated = { count: number urls: Array diff --git a/src/server/services/DirectorySearchService.ts b/src/server/services/DirectorySearchService.ts new file mode 100644 index 000000000..fdb8a8d6f --- /dev/null +++ b/src/server/services/DirectorySearchService.ts @@ -0,0 +1,27 @@ +import { inject, injectable } from 'inversify' +import { UrlRepositoryInterface } from '../repositories/interfaces/UrlRepositoryInterface' +import { DependencyIds } from '../constants' +import { UrlDirectoryPaginated } from '../repositories/types' +import { DirectoryQueryConditions } from './interfaces/DirectorySearchServiceInterface' + +@injectable() +export class DirectorySearchService { + private urlRepository: UrlRepositoryInterface + + public constructor( + @inject(DependencyIds.urlRepository) urlRepository: UrlRepositoryInterface, + ) { + this.urlRepository = urlRepository + } + + public plainTextSearch: ( + conditions: DirectoryQueryConditions, + ) => Promise = async (conditions) => { + // find urls from text search and email search + const results = await this.urlRepository.rawDirectorySearch(conditions) + + return results as UrlDirectoryPaginated + } +} + +export default DirectorySearchService diff --git a/src/server/services/UrlManagementService.ts b/src/server/services/UrlManagementService.ts index 89cc8d672..6c20c9de0 100644 --- a/src/server/services/UrlManagementService.ts +++ b/src/server/services/UrlManagementService.ts @@ -48,7 +48,7 @@ export class UrlManagementService implements UrlManagementServiceInterface { const owner = await this.userRepository.findUserByUrl(shortUrl) if (owner) { throw new AlreadyExistsError( - `Short link "${shortUrl}" is owned by ${owner.email}`, + `Short link "${shortUrl}" is used. Click here to find out more`, ) } diff --git a/src/server/services/interfaces/DirectorySearchServiceInterface.ts b/src/server/services/interfaces/DirectorySearchServiceInterface.ts new file mode 100644 index 000000000..98686c738 --- /dev/null +++ b/src/server/services/interfaces/DirectorySearchServiceInterface.ts @@ -0,0 +1,25 @@ +import { UrlDirectoryPaginated } from '../../repositories/types' +import { SearchResultsSortOrder } from '../../../shared/search' + +export type DirectoryQueryConditions = { + query: string + order: SearchResultsSortOrder + limit: number + offset: number + state: string | undefined + isFile: boolean | undefined + isEmail: boolean +} + +export interface DirectorySearchServiceInterface { + /** + * Returns urls that match the query based on their shortUrl and + * description. The results are ranked in order of relevance based + * on click count, length and cover density. + * @param {DirectoryQueryConditions} conditions Query conditions. + * @returns Promise of total no. Of search results and the results on the current page. + */ + plainTextSearch( + conditions: DirectoryQueryConditions, + ): Promise +} diff --git a/test/server/api/util.ts b/test/server/api/util.ts index 1bc34a0db..b3183d682 100644 --- a/test/server/api/util.ts +++ b/test/server/api/util.ts @@ -221,9 +221,32 @@ export const mockQuery = jest.fn() export const mockDefine = jest.fn() mockQuery.mockImplementation((query: string) => { + // For rawDirectorySearch -> email + if (query.includes('queryFile')) { + return [ + { + email: 'a@test.gov.sg', + shortUrl: 'a', + state: ACTIVE, + isFile: false, + }, + ] + } if (query.includes('count(*)')) { return [{ count: 10 }] } + // For rawDirectorySearch -> plain text + if (query.includes('JOIN')) { + return [ + { + email: 'test@test.gov.sg', + shortUrl: 'a', + state: ACTIVE, + isFile: false, + }, + ] + } + return [ { shortUrl: 'a', @@ -254,4 +277,8 @@ export const userModelMock = { ]), } +export const sanitiseMock = (i: string) => { + return i +} + export const redisMockClient = redisMock.createClient() diff --git a/test/server/controllers/DirectoryController.test.ts b/test/server/controllers/DirectoryController.test.ts new file mode 100644 index 000000000..af31441a2 --- /dev/null +++ b/test/server/controllers/DirectoryController.test.ts @@ -0,0 +1,123 @@ +import httpMock from 'node-mocks-http' +import { DirectoryController } from '../../../src/server/controllers/DirectoryController' +import { DirectorySearchServiceMock } from '../mocks/services/DirectorySearchService' +import { logger } from '../config' +import { SearchResultsSortOrder } from '../../../src/shared/search' + +const directorySearchService = new DirectorySearchServiceMock() +const controller = new DirectoryController(directorySearchService) +const searchSpy = jest.spyOn(directorySearchService, 'plainTextSearch') +const loggerErrorSpy = jest.spyOn(logger, 'error') + +/** + * Unit test for directory controller. + */ +describe('DirectoryController unit test', () => { + beforeEach(() => { + searchSpy.mockClear() + loggerErrorSpy.mockClear() + }) + it('should return directory search results from service', async () => { + const req = httpMock.createRequest({ + query: { + query: 'test@test.gov.sg', + order: 'recency', + limit: 10, + offset: 0, + state: 'ACTIVE', + isFile: 'false', + isEmail: 'true', + }, + }) + const res: any = httpMock.createResponse() + const okSpy = jest.fn() + res.ok = okSpy + await controller.getDirectoryWithConditions(req, res) + const conditions = { + query: 'test@test.gov.sg', + order: SearchResultsSortOrder.Recency, + limit: 10, + offset: 0, + state: 'ACTIVE', + isFile: false, + isEmail: true, + } + + expect(directorySearchService.plainTextSearch).toBeCalledWith(conditions) + expect(okSpy).toHaveBeenCalled() + expect(okSpy).toHaveBeenCalledWith({ + urls: [ + { + shortUrl: 'test-moh', + state: 'ACTIVE', + isFile: false, + email: 'test@test.gov.sg', + }, + ], + count: 0, + }) + }) + + it('should work with without isfile and state', async () => { + const req = httpMock.createRequest({ + query: { + query: 'test@test.gov.sg', + order: 'recency', + limit: 10, + offset: 0, + state: '', + isFile: '', + isEmail: 'true', + }, + }) + const res: any = httpMock.createResponse() + const okSpy = jest.fn() + res.ok = okSpy + await controller.getDirectoryWithConditions(req, res) + const conditions = { + query: 'test@test.gov.sg', + order: SearchResultsSortOrder.Recency, + limit: 10, + offset: 0, + state: '', + isFile: undefined, + isEmail: true, + } + + expect(directorySearchService.plainTextSearch).toBeCalledWith(conditions) + expect(okSpy).toHaveBeenCalled() + }) + + it('should respond with server error and log the error when service throws', async () => { + const req = httpMock.createRequest({ + query: { + query: 'test@test.gov.sg', + order: 'recency', + limit: 10, + offset: 0, + state: '', + isFile: '', + isEmail: 'true', + }, + }) + const res: any = httpMock.createResponse() + const serverErrorSpy = jest.fn() + res.serverError = serverErrorSpy + searchSpy.mockImplementationOnce(() => { + throw new Error('Service error') + }) + await controller.getDirectoryWithConditions(req, res) + expect(res.serverError).toHaveBeenCalled() + const conditions = { + query: 'test@test.gov.sg', + order: SearchResultsSortOrder.Recency, + limit: 10, + offset: 0, + state: '', + isFile: undefined, + isEmail: true, + } + expect(directorySearchService.plainTextSearch).toBeCalledWith(conditions) + expect(logger.error).toBeCalled() + }) +}) diff --git a/test/server/mocks/repositories/UrlRepository.ts b/test/server/mocks/repositories/UrlRepository.ts index 7f0e3abb1..ac22b6485 100644 --- a/test/server/mocks/repositories/UrlRepository.ts +++ b/test/server/mocks/repositories/UrlRepository.ts @@ -5,10 +5,12 @@ import { UrlRepositoryInterface } from '../../../../src/server/repositories/inte import { StorableFile, StorableUrl, + UrlDirectoryPaginated, UrlsPaginated, } from '../../../../src/server/repositories/types' import { StorableUrlState } from '../../../../src/server/repositories/enums' import { SearchResultsSortOrder } from '../../../../src/shared/search' +import { DirectoryQueryConditions } from '../../../../src/server/services/interfaces/DirectorySearchServiceInterface' @injectable() export class UrlRepositoryMock implements UrlRepositoryInterface { @@ -35,6 +37,22 @@ export class UrlRepositoryMock implements UrlRepositoryInterface { throw new Error('Not implemented') } + rawDirectorySearch: ( + condition: DirectoryQueryConditions, + ) => Promise = () => { + return Promise.resolve({ + urls: [ + { + shortUrl: 'test-moh', + state: 'ACTIVE', + isFile: false, + email: 'test@test.gov.sg', + }, + ], + count: 0, + }) + } + plainTextSearch: ( query: string, order: SearchResultsSortOrder, diff --git a/test/server/mocks/services/DirectorySearchService.ts b/test/server/mocks/services/DirectorySearchService.ts new file mode 100644 index 000000000..4ac3e7f0b --- /dev/null +++ b/test/server/mocks/services/DirectorySearchService.ts @@ -0,0 +1,28 @@ +import { injectable } from 'inversify' +import { + DirectoryQueryConditions, + DirectorySearchServiceInterface, +} from '../../../../src/server/services/interfaces/DirectorySearchServiceInterface' +import { UrlDirectoryPaginated } from '../../../../src/server/repositories/types' + +@injectable() +export class DirectorySearchServiceMock + implements DirectorySearchServiceInterface { + plainTextSearch: ( + conditions: DirectoryQueryConditions, + ) => Promise = () => { + return Promise.resolve({ + urls: [ + { + shortUrl: 'test-moh', + state: 'ACTIVE', + isFile: false, + email: 'test@test.gov.sg', + }, + ], + count: 0, + }) + } +} + +export default DirectorySearchServiceMock diff --git a/test/server/repositories/UrlRepository.test.ts b/test/server/repositories/UrlRepository.test.ts index 75645f7ce..9b058c624 100644 --- a/test/server/repositories/UrlRepository.test.ts +++ b/test/server/repositories/UrlRepository.test.ts @@ -7,6 +7,7 @@ import { mockQuery, mockTransaction, redisMockClient, + sanitiseMock, urlModelMock, } from '../api/util' import { UrlRepository } from '../../../src/server/repositories/UrlRepository' @@ -15,9 +16,11 @@ import { SearchResultsSortOrder } from '../../../src/shared/search' import { FileVisibility, S3ServerSide } from '../../../src/server/services/aws' import { NotFoundError } from '../../../src/server/util/error' import { StorableUrlState } from '../../../src/server/repositories/enums' +import { DirectoryQueryConditions } from '../../../src/server/services/interfaces/DirectorySearchServiceInterface' jest.mock('../../../src/server/models/url', () => ({ Url: urlModelMock, + sanitise: sanitiseMock, })) jest.mock('../../../src/server/models/statistics/daily', () => ({ @@ -455,4 +458,60 @@ describe('UrlRepository', () => { ).rejects.toThrowError() }) }) + + describe('rawDirectorySearch', () => { + beforeEach(() => { + mockQuery.mockClear() + }) + it('should call sequelize.query that searches for emails', async () => { + const conditions: DirectoryQueryConditions = { + query: '@test.gov.sg @test2.gov.sg', + order: SearchResultsSortOrder.Recency, + limit: 100, + offset: 0, + state: 'ACTIVE', + isFile: true, + isEmail: true, + } + + const repoawait = await repository.rawDirectorySearch(conditions) + + expect(repoawait).toStrictEqual({ + count: 1, + urls: [ + { + shortUrl: 'a', + email: 'a@test.gov.sg', + isFile: false, + state: 'ACTIVE', + }, + ], + }) + }) + + it('should call sequelize.query that searches with plain text', async () => { + const conditions: DirectoryQueryConditions = { + query: 'query', + order: SearchResultsSortOrder.Recency, + limit: 100, + offset: 0, + state: 'ACTIVE', + isFile: true, + isEmail: false, + } + + const repoawait = await repository.rawDirectorySearch(conditions) + expect(repoawait).toStrictEqual({ + count: 1, + urls: [ + { + email: 'test@test.gov.sg', + shortUrl: 'a', + state: 'ACTIVE', + isFile: false, + }, + ], + }) + }) + }) }) diff --git a/test/server/services/DirectorySearchService.test.ts b/test/server/services/DirectorySearchService.test.ts new file mode 100644 index 000000000..f18b0497e --- /dev/null +++ b/test/server/services/DirectorySearchService.test.ts @@ -0,0 +1,39 @@ +import { DirectorySearchService } from '../../../src/server/services/DirectorySearchService' +import { UrlRepositoryMock } from '../mocks/repositories/UrlRepository' +import { SearchResultsSortOrder } from '../../../src/shared/search' +import { DirectoryQueryConditions } from '../../../src/server/services/interfaces/DirectorySearchServiceInterface' + +/** + * Unit tests for DirectorySearchService. + */ +describe('DirectorySearchService tests', () => { + describe('plainTextSearch tests', () => { + it('Should return results from repository', async () => { + const repository = new UrlRepositoryMock() + const service = new DirectorySearchService(repository) + const conditions: DirectoryQueryConditions = { + query: 'test-moh', + order: SearchResultsSortOrder.Popularity, + limit: 10, + offset: 0, + state: 'ACTIVE', + isFile: false, + isEmail: false, + } + const spy = jest.spyOn(repository, 'rawDirectorySearch') + await expect(service.plainTextSearch(conditions)).resolves.toStrictEqual({ + urls: [ + { + shortUrl: 'test-moh', + state: 'ACTIVE', + isFile: false, + email: 'test@test.gov.sg', + }, + ], + count: 0, + }) + expect(repository.rawDirectorySearch).toBeCalledWith(conditions) + spy.mockClear() + }) + }) +}) diff --git a/test/server/services/UrlManagementService.test.ts b/test/server/services/UrlManagementService.test.ts index b93a655fb..676d75e66 100644 --- a/test/server/services/UrlManagementService.test.ts +++ b/test/server/services/UrlManagementService.test.ts @@ -21,6 +21,7 @@ describe('UrlManagementService', () => { findByShortUrl: jest.fn(), getLongUrl: jest.fn(), plainTextSearch: jest.fn(), + rawDirectorySearch: jest.fn(), } const service = new UrlManagementService(userRepository, urlRepository)