Skip to content

Commit

Permalink
Makes the case library searchable (#161)
Browse files Browse the repository at this point in the history
  • Loading branch information
cbothner authored Nov 1, 2017
2 parents 6e8749b + f8cf733 commit af88dba
Show file tree
Hide file tree
Showing 33 changed files with 3,020 additions and 545 deletions.
7 changes: 7 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ When developing the LTI tool provider components of Gala, it is useful to be abl
1. Trust the certificate on your host machine using Keychain Access. Drag `localhost.crt` into the app, then Get Info and choose Always Trust.
1. Start the development servers with `LOCALHOST_SSL=true foreman start`
1. Browse to https://localhost:3000 (http will not work)

## Cron jobs

The full-text case search is powered by a Postgres materialized view so it’s
really fast. The consequence is that changes don’t appear in search results
until the view is refreshed. Set a cron job or use Heroku Scheduler or the
equivalent to run `rake indices:refresh` as frequently as makes sense.
4 changes: 1 addition & 3 deletions app/controllers/features_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ class FeaturesController < ApplicationController
def index
enrolled = current_user.enrollments.pluck(:case_id)
features = Case.where.not(id: enrolled)
.order(<<~SQL)
featured_at DESC NULLS LAST, published_at DESC NULLS LAST
SQL
.ordered
.limit(6)
.pluck(:slug)
render json: { features: features }
Expand Down
15 changes: 15 additions & 0 deletions app/controllers/libraries_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

class LibrariesController < ApplicationController
before_action :set_library

def show
head(:not_found) && return unless @library
end

private

def set_library
@library = Library.find_by_slug params[:slug]
end
end
35 changes: 35 additions & 0 deletions app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

class SearchController < ApplicationController
def index
@cases = Case.all
.merge(libraries_query)
.merge(full_text_query)
.pluck(:slug)
render json: @cases
end

private

def libraries_query
return Case.all unless params[:libraries]

Case.joins(:library)
.where(libraries: { slug: params[:libraries] })
.ordered
end

def full_text_query
return Case.all unless params[:q]

query = params[:q].is_a?(Array) ? params[:q].join(' ') : params[:q]
Case.joins('JOIN cases_search_index_en ON cases_search_index_en.id = cases.id')
.where('cases_search_index_en.document @@ plainto_tsquery(?)', query)
.reorder(
'ts_rank(' \
'cases_search_index_en.document, ' \
"plainto_tsquery(#{ActiveRecord::Base.connection.quote(query)})" \
') DESC'
)
end
end
1 change: 1 addition & 0 deletions app/javascript/catalog/CaseList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const UnstyledList = styled.ul`

const Image = styled.img.attrs({ role: 'presentation' })`
width: 50px;
min-width: 50px;
height: 50px;
border-radius: 2px;
margin-right: 1em;
Expand Down
86 changes: 81 additions & 5 deletions app/javascript/catalog/CatalogToolbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,27 @@
* @flow
*/

import React from 'react'
import * as React from 'react'
import styled, { css } from 'styled-components'
import { injectIntl } from 'react-intl'
import { withRouter } from 'react-router-dom'

import { InputGroup } from '@blueprintjs/core'

import Toolbar from 'utility/Toolbar'
import { getSearchPath } from 'catalog/SearchForm'

import type { IntlShape } from 'react-intl'
import type { ContextRouter } from 'react-router-dom'

const CatalogToolbar = () => (
const CatalogToolbar = ({ history }: ContextRouter) => (
<Toolbar
groups={[
[
{
message: 'catalog',
iconName: 'home',
onClick: () => (window.location = '/'),
onClick: () => history.push('/'),
},
{
message: 'catalog.proposeACase',
Expand All @@ -24,9 +33,76 @@ const CatalogToolbar = () => (
},
],
[],
[],
[{ component: <Search /> }],
]}
/>
)

export default CatalogToolbar
export default withRouter(CatalogToolbar)

class SearchField extends React.Component<
ContextRouter & { intl: IntlShape },
{ active: boolean }
> {
state = { active: false }
input: ?HTMLInputElement

handleSubmit = (e: SyntheticEvent<*>) => {
e.preventDefault()
if (!this.input || this.input.value === '') return

this.input &&
this.props.history.push(
getSearchPath({
q: this.input.value,
})
)
this.input && this.input.blur()
this.input && (this.input.value = '')
}

render () {
return (
<FormCoveringToolbarOnMobile
active={this.state.active}
onSubmit={this.handleSubmit}
>
<InputGroup
inputRef={el => (this.input = el)}
className="pt-round"
leftIconName="search"
rightElement={
<button
className="pt-button pt-minimal pt-icon-arrow-right"
onClick={this.handleSubmit}
/>
}
placeholder={this.props.intl.formatMessage({
id: 'catalog.search',
defaultMessage: 'Search cases',
})}
onFocus={() => this.setState({ active: true })}
onBlur={() => this.setState({ active: false })}
/>
</FormCoveringToolbarOnMobile>
)
}
}

const Search = withRouter(injectIntl(SearchField))

const FormCoveringToolbarOnMobile = styled.form`
@media screen and (max-width: 513px) {
background-color: #1d3f5e;
margin-left: -24px;
${({ active }) =>
active
? css`
margin-left: 0px;
position: absolute;
left: 14px;
width: calc(100vw - 28px);
`
: ''};
}
`
92 changes: 92 additions & 0 deletions app/javascript/catalog/Home.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* @providesModule Home
* @flow
*/

import * as React from 'react'
import { values, omit } from 'ramda'

import Sidebar from 'catalog/Sidebar'
import Features from 'catalog/Features'
import MapView from 'catalog/MapView'
import CaseList from 'catalog/CaseList'
import { Main, CatalogSection, SectionTitle } from 'catalog/shared'

import type { Case, Enrollment, Reader } from 'redux/state'
import type { Loading } from 'catalog'

class Home extends React.Component<{
loading: Loading,
reader: ?Reader,
cases: { [string]: Case },
enrollments: Enrollment[],
features: string[],
readerIsEditor: boolean,
onDeleteEnrollment: (
slug: string,
options: { displayBetaWarning?: boolean }
) => any,
}> {
render () {
const {
loading,
reader,
onDeleteEnrollment,
readerIsEditor,
cases,
} = this.props
return [
<Sidebar
key="sidebar"
loading={loading}
reader={reader}
enrolledCases={this._enrolledCases()}
onDeleteEnrollment={onDeleteEnrollment}
/>,
<Main key="main">
<Features
readerIsEditor={readerIsEditor}
featuredCases={this._featuredCases()}
/>
<MapView
cases={values(cases).filter(x => !!x.publishedAt)}
title={{
id: 'catalog.locations',
defaultMessage: 'Site locations',
}}
startingViewport={{
latitude: 17.770231041567445,
longitude: 16.286555860170893,
zoom: 1.1606345336768273,
}}
/>
<CatalogSection>
<SectionTitle>All cases</SectionTitle>
<CaseList
readerIsEditor={readerIsEditor}
cases={this._allOtherCases()}
/>
</CatalogSection>
</Main>,
]
}

_enrolledCases = () =>
this.props.enrollments
.map(e => this.props.cases[e.caseSlug])
.filter(x => !!x)

_featuredCases = () =>
this.props.features.map(slug => this.props.cases[slug]).filter(x => !!x)

_allOtherCases = () =>
values(
omit(
this.props.enrollments.map(e => e.caseSlug).concat(this.props.features),
this.props.cases
)
)
.filter(x => !!x.kicker)
.sort((a, b) => a.kicker.localeCompare(b.kicker))
}
export default Home
69 changes: 69 additions & 0 deletions app/javascript/catalog/LibraryInfo.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @providesModule LibraryInfo
* @flow
*/

import * as React from 'react'
import styled from 'styled-components'

import { withRouter } from 'react-router-dom'
import { Orchard } from 'shared/orchard'

import LibraryLogo from 'overview/LibraryLogo'
import { CatalogSection, SectionTitle } from 'catalog/shared'

import type { ContextRouter } from 'react-router-dom'
import type { Library } from 'redux/state'

type Props = ContextRouter & {| slug: string |}
class LibraryInfo extends React.Component<Props, Library> {
componentDidMount () {
this._fetchLibraryInfo()
}

componentDidUpdate (prevProps: Props) {
if (this.props.slug !== prevProps.slug) this._fetchLibraryInfo()
}

render () {
if (!this.state) return null

const { name, description, url } = this.state
return (
<CatalogSection solid>
<RightFloatLogoContainer>
<LibraryLogo library={this.state} />
</RightFloatLogoContainer>
<SectionTitle>{name}</SectionTitle>
<Description>{description}</Description>
<LearnMore href={url}>Learn more ›</LearnMore>
</CatalogSection>
)
}

_fetchLibraryInfo = () =>
Orchard.harvest(`libraries/${this.props.slug}`)
.then((library: Library) => this.setState(library))
.catch(() => this.props.history.replace('/'))
}
export default withRouter(LibraryInfo)

const RightFloatLogoContainer = styled.div`
position: relative;
float: right;
width: 67px;
height: 116px;
margin: -10px 0 10px 10px;
pointer-events: none;
`
const Description = styled.p`
color: #ebeae4;
margin-bottom: 0.5em;
`
const LearnMore = styled.a`
color: #6acb72;
&:hover {
color: #6acb72;
text-decoration: underline;
}
`
15 changes: 11 additions & 4 deletions app/javascript/catalog/MapView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,26 @@ import { Button, Intent } from '@blueprintjs/core'
import { SectionTitle } from 'catalog/shared'
import Pin from 'catalog/Pin'

import type { IntlShape } from 'react-intl'
import type { Case, Viewport } from 'redux/state'

type OwnProps = {
cases: { [string]: Case },
type Props = {
cases: Case[],
editing?: boolean,
height?: number,
intl: any,
intl: IntlShape,
startingViewport: Viewport,
title: { id: string, defaultMessage: string },
onChangeViewport?: Viewport => any,
}
type State = {
hasError: boolean,
viewport: Viewport,
acceptingScroll: boolean,
openPin: string,
}

class MapViewController extends React.Component<OwnProps, *> {
class MapViewController extends React.Component<Props, State> {
state = {
hasError: false,
viewport: this.props.startingViewport,
Expand Down
Loading

0 comments on commit af88dba

Please sign in to comment.