Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic saved items list #77

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions newswires/app/controllers/QueryController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,12 @@ class QueryController(
Ok(Json.toJson(results))
}

def item(id: Int): Action[AnyContent] = AuthAction {
FingerpostWireEntry.get(id) match {
case Some(entry) => Ok(Json.toJson(entry))
case None => NotFound
def item(id: Int, maybeFreeTextQuery: Option[String]): Action[AnyContent] =
AuthAction {
FingerpostWireEntry.get(id, maybeFreeTextQuery) match {
case Some(entry) => Ok(Json.toJson(entry))
case None => NotFound
}
}
}

}
37 changes: 30 additions & 7 deletions newswires/app/db/FingerpostWireEntry.scala
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ case class FingerpostWireEntry(
id: Long,
externalId: String,
ingestedAt: ZonedDateTime,
content: FingerpostWire
content: FingerpostWire,
highlight: Option[String] = None
)

object FingerpostWireEntry
Expand All @@ -76,7 +77,14 @@ object FingerpostWireEntry
Json.format[FingerpostWireEntry]

override val columns =
Seq("id", "external_id", "ingested_at", "content", "combined_textsearch")
Seq(
"id",
"external_id",
"ingested_at",
"content",
"combined_textsearch",
"highlight"
)
val syn = this.syntax("fm")

private val selectAllStatement = sqls"""
Expand All @@ -93,15 +101,24 @@ object FingerpostWireEntry
rs.long(fm.id),
rs.string(fm.externalId),
rs.zonedDateTime(fm.ingestedAt),
Json.parse(rs.string(fm.content)).as[FingerpostWire]
Json.parse(rs.string(fm.content)).as[FingerpostWire],
rs.stringOpt(fm.column("highlight"))
)

private def clamp(low: Int, x: Int, high: Int): Int =
math.min(math.max(x, low), high)

def get(id: Int): Option[FingerpostWireEntry] = DB readOnly {
def get(
id: Int,
maybeFreeTextQuery: Option[String] = Some("alpaca")
): Option[FingerpostWireEntry] = DB readOnly {
val highlightsClause = maybeFreeTextQuery match {
case Some(query) =>
sqls", ts_headline('english', ${syn.content}->>'body_text', websearch_to_tsquery('english', $query), 'HighlightAll=true, StartSel=<mark>, StopSel=</mark>') AS ${syn.resultName.highlight}"
case None => sqls", '' AS ${syn.resultName.highlight}"
}
implicit session =>
sql"""| SELECT ${FingerpostWireEntry.syn.result.*}
sql"""| SELECT $selectAllStatement $highlightsClause
| FROM ${FingerpostWireEntry as syn}
| WHERE ${FingerpostWireEntry.syn.id} = $id
|""".stripMargin
Expand Down Expand Up @@ -248,14 +265,20 @@ object FingerpostWireEntry
case whereParts => sqls"WHERE ${sqls.joinWithAnd(whereParts: _*)}"
}

val query = sql"""| SELECT $selectAllStatement
val highlightsClause = search.text match {
case Some(query) =>
sqls", ts_headline('english', ${syn.content}->>'body_text', websearch_to_tsquery('english', $query)) AS ${syn.resultName.highlight}"
case None => sqls", '' AS ${syn.resultName.highlight}"
}

val query = sql"""| SELECT $selectAllStatement $highlightsClause
| FROM ${FingerpostWireEntry as syn}
| $whereClause
| ORDER BY ${FingerpostWireEntry.syn.ingestedAt} DESC
| LIMIT $effectivePageSize
| """.stripMargin

// logger.info(s"QUERY: ${query.statement}; PARAMS: ${query.parameters}")
logger.info(s"QUERY: ${query.statement}; PARAMS: ${query.parameters}")

val results = query
.map(FingerpostWireEntry(syn.resultName))
Expand Down
53 changes: 49 additions & 4 deletions newswires/client/src/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,23 @@
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { pandaFetch } from './panda-session';
import { type WireData, WireDataSchema } from './sharedTypes';
import { paramsToQuerystring } from './urlState';
import { useSavedItems } from './useSavedItemsList';
import { useSearch } from './useSearch';
import { WireDetail } from './WireDetail';

export const Item = ({ id }: { id: string }) => {
const { handleDeselectItem } = useSearch();
const { handleDeselectItem, config } = useSearch();
const { savedItems, addSaved, removeSaved } = useSavedItems();

const [itemData, setItemData] = useState<WireData | undefined>(undefined);
const isSaved = useMemo(
() => savedItems.map((s) => s.id).includes(id),
[savedItems, id],
);
const [error, setError] = useState<string | undefined>(undefined);

const currentUrl = window.location.href;
Expand All @@ -31,9 +38,17 @@
prefix: 'pushedFlyoutTitle',
});

const maybeSearchParams = useMemo(() => {
const q = config.query.q;
if (q) {
return paramsToQuerystring({ q, supplier: [] });
}
return '';
}, [config.query.q]);

useEffect(() => {
// fetch item data from /api/item/:id
pandaFetch(`/api/item/${id}`)
pandaFetch(`/api/item/${id}${maybeSearchParams}`)
.then((res) => {
if (res.status === 404) {
throw new Error('Item not found');
Expand Down Expand Up @@ -62,7 +77,7 @@
: 'unknown error',
);
});
}, [id]);

Check warning on line 80 in newswires/client/src/Item.tsx

View workflow job for this annotation

GitHub Actions / Build and upload to riffraff

React Hook useEffect has a missing dependency: 'maybeSearchParams'. Either include it or remove the dependency array

return (
<EuiSplitPanel.Outer>
Expand All @@ -85,7 +100,37 @@
aria-label="send to composer"
size="s"
/>
<EuiButtonIcon iconType={'heart'} aria-label="save" size="s" />
{isSaved ? (
<EuiButtonIcon
iconType={'starFilled'}
aria-label="remove from favourites"
size="s"
onClick={() =>
removeSaved({
id: id,
headline:
itemData.content.headline ??
itemData.content.slug ??
'',
})
}
/>
) : (
<EuiButtonIcon
iconType={'starEmpty'}
aria-label="save"
size="s"
onClick={() =>
addSaved({
id: id,
headline:
itemData.content.headline ??
itemData.content.slug ??
'',
})
}
/>
)}
<CopyButton textToCopy={currentUrl} />
</EuiFlexGroup>
</EuiFlexGroup>
Expand Down
74 changes: 56 additions & 18 deletions newswires/client/src/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
EuiBadgeGroup,
EuiCollapsibleNav,
EuiCollapsibleNavGroup,
EuiFlexGroup,

Check warning on line 6 in newswires/client/src/SideNav.tsx

View workflow job for this annotation

GitHub Actions / Build and upload to riffraff

'EuiFlexGroup' is defined but never used. Allowed unused vars must match /^_/u
EuiFlexItem,

Check warning on line 7 in newswires/client/src/SideNav.tsx

View workflow job for this annotation

GitHub Actions / Build and upload to riffraff

'EuiFlexItem' is defined but never used. Allowed unused vars must match /^_/u
EuiHeaderSectionItemButton,
EuiIcon,
EuiListGroup,
Expand All @@ -18,6 +18,7 @@
import { SearchBox } from './SearchBox';
import { brandColours } from './sharedStyles';
import type { Query } from './sharedTypes';
import { useSavedItems } from './useSavedItemsList';
import { useSearch } from './useSearch';

function decideLabelForQueryBadge(query: Query): string {
Expand All @@ -44,7 +45,14 @@
const [navIsOpen, setNavIsOpen] = useState<boolean>(false);
const theme = useEuiTheme();

const { state, config, handleEnterQuery, toggleAutoUpdate } = useSearch();
const {
state,
config,
handleEnterQuery,
handleSelectItem,
toggleAutoUpdate,
} = useSearch();
const { savedItems: saved } = useSavedItems();

const searchHistory = state.successfulQueryHistory;
const activeSuppliers = useMemo(
Expand Down Expand Up @@ -204,31 +212,61 @@
})}
</EuiListGroup>
</EuiCollapsibleNavGroup>
<EuiCollapsibleNavGroup title={'Search history'}>
<EuiCollapsibleNavGroup
title={'Search history'}
css={css`
min-height: 20%;
max-height: 30%;
overflow-y: auto;
`}
>
{searchHistoryItems.length === 0 ? (
<EuiText size="s">{'No history yet'}</EuiText>
) : (
<EuiBadgeGroup color="subdued" gutterSize="s">
{searchHistoryItems.map(({ label, query, resultsCount }) => {
return (
<EuiBadge
key={label}
color="secondary"
onClick={() => {
handleEnterQuery(query);
}}
onClickAriaLabel="Apply filters"
>
{label}{' '}
<EuiBadge color="hollow">
{resultsCount === 30 ? '30+' : resultsCount}
{searchHistoryItems
.slice(0, 6)
.map(({ label, query, resultsCount }) => {
return (
<EuiBadge
key={label}
color="secondary"
onClick={() => {
handleEnterQuery(query);
}}
onClickAriaLabel="Apply filters"
>
{label}{' '}
<EuiBadge color="hollow">
{resultsCount === 30 ? '30+' : resultsCount}
</EuiBadge>
</EuiBadge>
</EuiBadge>
);
})}
);
})}
</EuiBadgeGroup>
)}
</EuiCollapsibleNavGroup>
<EuiCollapsibleNavGroup
title={'Saved items'}
css={css`
overflow-y: auto;
`}
>
{saved.length === 0 ? (
<EuiText size="s">{'No saved items'}</EuiText>
) : (
<EuiListGroup>
{saved.map((item) => (
<EuiListGroupItem
key={item.id}
label={item.headline}
onClick={() => handleSelectItem(item.id.toString())}
size="s"
/>
))}
</EuiListGroup>
)}
</EuiCollapsibleNavGroup>
</div>

<div style={{ padding: '20px 10px' }}>
Expand Down
25 changes: 22 additions & 3 deletions newswires/client/src/WireDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
EuiFlexItem,
EuiScreenReaderLive,
EuiSpacer,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { Fragment, useMemo } from 'react';
Expand All @@ -21,6 +22,7 @@ export const WireDetail = ({
wire: WireData;
isShowingJson: boolean;
}) => {
const theme = useEuiTheme();
const { byline, keywords, usage } = wire.content;

const safeBodyText = useMemo(() => {
Expand All @@ -29,6 +31,14 @@ export const WireDetail = ({
: undefined;
}, [wire]);

const safeHighlightText = useMemo(() => {
return wire.highlight.trim().length > 0
? sanitizeHtml(wire.highlight)
: undefined;
}, [wire]);

const bodyTextContent = safeHighlightText ?? safeBodyText;

const nonEmptyKeywords = useMemo(
() => keywords?.filter((keyword) => keyword.trim().length > 0) ?? [],
[keywords],
Expand All @@ -54,7 +64,16 @@ export const WireDetail = ({

<EuiSpacer size="m" />

<EuiDescriptionList>
<EuiDescriptionList
css={css`
& mark {
background-color: ${theme.euiTheme.colors.highlight};
font-weight: bold;
position: relative;
border: 3px solid ${theme.euiTheme.colors.highlight};
}
`}
>
{byline && (
<>
<EuiDescriptionListTitle>Byline</EuiDescriptionListTitle>
Expand Down Expand Up @@ -87,12 +106,12 @@ export const WireDetail = ({
</EuiDescriptionListDescription>
</>
)}
{safeBodyText && (
{bodyTextContent && (
<>
<EuiDescriptionListTitle>Body text</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<article
dangerouslySetInnerHTML={{ __html: safeBodyText }}
dangerouslySetInnerHTML={{ __html: bodyTextContent }}
data-pinboard-selection-target
/>
</EuiDescriptionListDescription>
Expand Down
Loading
Loading