Skip to content

Commit ca8cde5

Browse files
committed
Add component for exporting links
1 parent 56a17e7 commit ca8cde5

File tree

9 files changed

+466
-28
lines changed

9 files changed

+466
-28
lines changed

src/client/components/facet_results/ApexCharts.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import Select from '@mui/material/Select'
1010
import Typography from '@mui/material/Typography'
1111
import GeneralDialog from '../main_layout/GeneralDialog'
1212
import InstaceList from '../main_layout/InstanceList'
13+
import querystring from 'querystring'
14+
import history from '../../History'
1315

1416
const defaultPadding = 32
1517
const smallScreenPadding = 8
@@ -34,11 +36,43 @@ class ApexChart extends React.Component {
3436
? apexChartsConfig[resultClassConfig.createChartData]
3537
: apexChartsConfig[resultClassConfig.chartTypes[0].createChartData],
3638
chartType: resultClassConfig.dropdownForChartTypes ? resultClassConfig.chartTypes[0].id : null,
37-
dialogData: null
39+
dialogData: null,
40+
defaultFacetFetchingRequired: false
3841
}
3942
}
4043

4144
componentDidMount = () => {
45+
let constraints = []
46+
47+
// first check if page or constraints were given as url parameter
48+
if (this.props.location.search !== '') {
49+
const qs = this.props.location.search.replace('?', '')
50+
const parsedConstraints = querystring.parse(qs).constraints
51+
// do not try to import constraints twice for ApexChartsDouble components
52+
if (!this.props.doNotImportConstraints) constraints = parsedConstraints ? JSON.parse(decodeURIComponent(parsedConstraints)) : []
53+
}
54+
55+
// update imported facets
56+
for (const constraint of constraints) {
57+
this.props.updateFacetOption({
58+
facetClass: this.props.facetClass,
59+
facetID: constraint.facetId,
60+
option: constraint.filterType,
61+
value: constraint.value
62+
})
63+
}
64+
65+
// check if default facets need to be refetched due to imported facets
66+
if (constraints.length > 0) {
67+
// remove query from URL
68+
const tabPath = this.props.resultClassConfig.tabPath ? this.props.resultClassConfig.tabPath : this.props.tabPath
69+
history.replace({
70+
pathname: `${this.props.rootUrl}/${this.props.facetClass}/faceted-search/${tabPath}`
71+
})
72+
73+
this.setState({ defaultFacetFetchingRequired: true })
74+
}
75+
4276
const { pageType = 'facetResults' } = this.props
4377
if (this.props.fetchData) {
4478
this.props.fetchData({
@@ -71,6 +105,20 @@ class ApexChart extends React.Component {
71105
order: this.props.order
72106
})
73107
}
108+
109+
// check if facets are still fetching
110+
let someFacetIsFetching = false
111+
if (pageType === 'facetResults') Object.values(this.props.facetState.facets).forEach(facet => { if (facet.isFetching) { someFacetIsFetching = true } })
112+
113+
// refetch default facets (excl. text facets) when facets have been updated
114+
if (this.state.defaultFacetFetchingRequired && this.props.facetUpdateID > 0 && !someFacetIsFetching) {
115+
const defaultFacets = this.props.perspectiveConfig.defaultActiveFacets
116+
for (const facet of defaultFacets) {
117+
if (this.props.perspectiveConfig.facets[facet].filterType !== 'textFilter') this.props.fetchFacet({ facetClass: this.props.facetClass, facetID: facet })
118+
}
119+
this.setState({ defaultFacetFetchingRequired: false })
120+
}
121+
74122
if (pageType === 'clientFSResults' && prevProps.facetUpdateID !== this.props.facetUpdateID) {
75123
this.renderChart()
76124
}

src/client/components/facet_results/Deck.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import DeckArcLayerLegend from './DeckArcLayerLegend'
99
import DeckArcLayerDialog from './DeckArcLayerDialog'
1010
import DeckArcLayerTooltip from './DeckArcLayerTooltip'
1111
import CircularProgress from '@mui/material/CircularProgress'
12+
import history from '../../History'
13+
import querystring from 'querystring'
1214

1315
/* Documentation links:
1416
https://deck.gl/#/documentation/getting-started/using-with-react?section=adding-a-base-map
@@ -66,10 +68,40 @@ class Deck extends React.Component {
6668
from: null,
6769
to: null
6870
},
69-
hoverInfo: null
71+
hoverInfo: null,
72+
defaultFacetFetchingRequired: false
7073
}
7174

7275
componentDidMount = () => {
76+
let constraints = []
77+
78+
// first check if page or constraints were given as url parameter
79+
if (this.props.location.search !== '') {
80+
const qs = this.props.location.search.replace('?', '')
81+
const parsedConstraints = querystring.parse(qs).constraints
82+
constraints = parsedConstraints ? JSON.parse(decodeURIComponent(parsedConstraints)) : []
83+
}
84+
85+
// update imported facets
86+
for (const constraint of constraints) {
87+
this.props.updateFacetOption({
88+
facetClass: this.props.facetClass,
89+
facetID: constraint.facetId,
90+
option: constraint.filterType,
91+
value: constraint.value
92+
})
93+
}
94+
95+
// check if default facets need to be refetched due to imported facets
96+
if (constraints.length > 0) {
97+
// remove query from URL
98+
history.replace({
99+
pathname: `${this.props.rootUrl}/${this.props.facetClass}/faceted-search/${this.props.tabPath}`
100+
})
101+
102+
this.setState({ defaultFacetFetchingRequired: true })
103+
}
104+
73105
this.props.fetchResults({
74106
resultClass: this.props.resultClass,
75107
facetClass: this.props.facetClass,
@@ -79,6 +111,19 @@ class Deck extends React.Component {
79111
}
80112

81113
componentDidUpdate = prevProps => {
114+
// check if facets are still fetching
115+
let someFacetIsFetching = false
116+
if (this.props.pageType === 'facetResults' && this.props.facetState) Object.values(this.props.facetState.facets).forEach(facet => { if (facet.isFetching) { someFacetIsFetching = true } })
117+
118+
// refetch default facets (excl. text facets) when facets have been updated
119+
if (this.state.defaultFacetFetchingRequired && this.props.facetUpdateID > 0 && !someFacetIsFetching) {
120+
const defaultFacets = this.props.perspectiveConfig.defaultActiveFacets
121+
for (const facet of defaultFacets) {
122+
if (this.props.perspectiveConfig.facets[facet].filterType !== 'textFilter') this.props.fetchFacet({ facetClass: this.props.facetClass, facetID: facet })
123+
}
124+
this.setState({ defaultFacetFetchingRequired: false })
125+
}
126+
82127
// check if filters have changed
83128
if (prevProps.facetUpdateID !== this.props.facetUpdateID) {
84129
this.props.fetchResults({
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import React from 'react'
2+
import PropTypes from 'prop-types'
3+
import withStyles from '@mui/styles/withStyles'
4+
import Button from '@mui/material/Button'
5+
import Paper from '@mui/material/Paper'
6+
import { stateToUrl } from '../../helpers/helpers'
7+
import { Alert, AlertTitle, FormControl, FormHelperText, InputLabel, MenuItem, Select, TextField } from '@mui/material'
8+
import intl from 'react-intl-universal'
9+
import parse from 'html-react-parser'
10+
11+
const styles = theme => ({
12+
root: {
13+
height: 'calc(100% - 72px)',
14+
width: '100%',
15+
display: 'flex',
16+
alignItems: 'center',
17+
justifyContent: 'center',
18+
borderTop: '1px solid rgba(224, 224, 224, 1);',
19+
flexDirection: 'column'
20+
},
21+
link: {
22+
textDecoration: 'none'
23+
},
24+
button: {
25+
margin: theme.spacing(3)
26+
},
27+
rightIcon: {
28+
marginLeft: theme.spacing(1)
29+
},
30+
alert: {
31+
margin: theme.spacing(1),
32+
width: '75%',
33+
boxSizing: 'border-box'
34+
},
35+
linkContainer: {
36+
margin: theme.spacing(1),
37+
display: 'flex',
38+
alignItems: 'center',
39+
flexDirection: 'column',
40+
width: '75%'
41+
},
42+
linkField: {
43+
width: '100%',
44+
textOverflow: 'ellipsis'
45+
}
46+
})
47+
48+
/**
49+
* A component for creating a link to a specific search.
50+
*/
51+
class ExportLink extends React.Component {
52+
constructor (props) {
53+
super(props)
54+
this.state = {
55+
downloadLink: '',
56+
selectedView: 'table'
57+
}
58+
this.handleViewChange = this.handleViewChange.bind(this)
59+
}
60+
61+
componentDidMount = () => {
62+
this.setState({ downloadLink: this.createDownloadLink() })
63+
}
64+
65+
componentDidUpdate = prevProps => {
66+
// check if filters have changed
67+
if (prevProps.facetUpdateID !== this.props.facetUpdateID) {
68+
this.setState({ downloadLink: this.createDownloadLink() })
69+
}
70+
}
71+
72+
createDownloadLink = () => {
73+
const params = stateToUrl({
74+
facetClass: this.props.facetClass,
75+
facets: this.props.facets
76+
})
77+
78+
const constraints = params.constraints ? params.constraints : []
79+
const mappedConstraints = []
80+
// go through constraints:
81+
for (const constraint of constraints) {
82+
const facetId = constraint.facetID
83+
const filterType = constraint.filterType
84+
let value
85+
86+
switch (constraint.filterType) {
87+
case 'uriFilter':
88+
// go through each object (can have multiple)
89+
for (const [k, v] of Object.entries(this.props.facets[constraint.facetID].uriFilter)) {
90+
value = {
91+
path: v.path,
92+
node: {
93+
id: k,
94+
prefLabel: v.node.prefLabel,
95+
instanceCount: v.node.instanceCount,
96+
...(v.node.parent && { parent: v.node.parent }),
97+
...(v.node.children && { children: v.node.children.map(child => ({ id: child.id, prefLabel: child.prefLabel, instanceCount: child.instanceCount })) })
98+
}
99+
}
100+
mappedConstraints.push({ facetId: facetId, filterType: filterType, value: value })
101+
}
102+
break
103+
case 'textFilter':
104+
value = this.props.facets[constraint.facetID].textFilter
105+
mappedConstraints.push({ facetId: facetId, filterType: filterType, value: value })
106+
break
107+
case 'dateFilter':
108+
case 'timespanFilter':
109+
case 'directTimespanFilter':
110+
value = [this.props.facets[constraint.facetID].timespanFilter.start, this.props.facets[constraint.facetID].timespanFilter.end]
111+
mappedConstraints.push({ facetId: facetId, filterType: filterType, value: value })
112+
break
113+
case 'dateNoTimespanFilter':
114+
value = [this.props.facets[constraint.facetID].dateNoTimespanFilter.start, this.props.facets[constraint.facetID].dateNoTimespanFilter.end]
115+
mappedConstraints.push({ facetId: facetId, filterType: filterType, value: value })
116+
break
117+
case 'integerFilter':
118+
case 'integerFilterRange':
119+
value = [this.props.facets[constraint.facetID].integerFilter.start, this.props.facets[constraint.facetID].integerFilter.end]
120+
mappedConstraints.push({ facetId: facetId, filterType: filterType, value: value })
121+
break
122+
default:
123+
break
124+
}
125+
}
126+
127+
const pageNumber = this.props.data.page === -1 ? 0 : this.props.data.page
128+
const pageNumberString = this.state.selectedView === 'table' ? `page=${pageNumber}` : ''
129+
const constraintsString = mappedConstraints.length > 0 ? `constraints=${encodeURIComponent(JSON.stringify(mappedConstraints))}` : ''
130+
const separator = (pageNumberString.length > 0 && constraintsString.length > 0) ? '&' : ''
131+
const queryString = (pageNumberString.length > 0 || constraintsString.length > 0) ? `?${pageNumberString}${separator}${constraintsString}` : ''
132+
return `${this.props.rootUrl}/${this.props.facetClass}/faceted-search/${this.state.selectedView}${queryString}`
133+
}
134+
135+
updateDownloadLink = () => {
136+
this.setState({ downloadLink: this.createDownloadLink() })
137+
}
138+
139+
handleViewChange (event) {
140+
this.setState({ selectedView: event.target.value }, () => { this.updateDownloadLink() })
141+
}
142+
143+
render = () => {
144+
const { classes } = this.props
145+
const fullLink = window.location.origin + this.state.downloadLink
146+
const fieldLabel = intl.get('exportLink.fieldLabel') ? intl.get('exportLink.fieldLabel') : 'Generated link (read-only)'
147+
const infoBody = intl.getHTML('exportLink.infoBody') ? intl.getHTML('exportLink.infoBody') : 'You can share the query you made by using the link below. The link is generated based on what you have selected for different facets and will open the search view of this perpsective with those choices on the selected tab. You can change the opened tab to any of the supported ones using the dropdown menu below. If you make additional choices while on this page, the link will be automatically updated to include those.'
148+
const warningTitle = intl.get('exportLink.warningTitle') ? intl.get('exportLink.warningTitle') : 'Generated link might be too long for some browsers'
149+
const warningBody = intl.getHTML('exportLink.warningBody') ? intl.getHTML('exportLink.warningBody') : parse('The current length of the generated link is more than 2,000 characters. Browsers have different limits for the maximum lengths of links they can handle. <strong>This link might not work on all browsers</strong> — you can reduce the length of the link by deselecting some facet options.')
150+
const errorTitle = intl.get('exportLink.errorTitle') ? intl.get('exportLink.errorTitle') : 'Generated link is too long'
151+
const errorBody = intl.getHTML('exportLink.errorBody') ? intl.getHTML('exportLink.errorBody') : parse('The current length of the generated link is more than 15,800 characters. <strong>The server will refuse to handle requests that go over certain length limits</strong> — you can reduce the length of the link by deselecting some facet options.')
152+
const copyLinkToClipboard = intl.get('exportLink.copyLinkToClipboard') ? intl.get('exportLink.copyLinkToClipboard') : 'Copy link to clipboard'
153+
154+
const acceptedComponentTypes = ['ApexCharts', 'ApexChartsDouble', 'LeafletMap', 'Deck']
155+
const defaultTab = this.props.perspectiveConfig.resultClasses[this.props.perspectiveConfig.id].paginatedResultsConfig
156+
const suitableResultClasses = Object.values(this.props.perspectiveConfig.resultClasses).filter(resultClass => acceptedComponentTypes.includes(resultClass.component))
157+
return (
158+
<Paper square className={classes.root}>
159+
<Alert severity='info' className={classes.alert}>{infoBody}</Alert>
160+
{fullLink.length > 2000 && fullLink.length <= 15800 ? (<Alert severity='warning' className={classes.alert}><AlertTitle>{warningTitle}</AlertTitle>{warningBody}</Alert>) : ''}
161+
{fullLink.length > 15800 ? (<Alert severity='error' className={classes.alert}><AlertTitle>{errorTitle}</AlertTitle>{errorBody}</Alert>) : ''}
162+
<div className={classes.linkContainer}>
163+
<FormControl sx={{ m: 1, minWidth: 120 }}>
164+
<InputLabel id='view-select-helper-label'>{intl.get('exportLink.viewLabel')}</InputLabel>
165+
<Select
166+
labelId='view-select-helper-label'
167+
value={this.state.selectedView}
168+
label={intl.get('exportLink.viewLabel')}
169+
onChange={this.handleViewChange}
170+
>
171+
<MenuItem value={defaultTab.tabPath} default>{intl.get(`tabs.${defaultTab.tabPath}`) ? intl.get(`tabs.${defaultTab.tabPath}`) : defaultTab.tabPath}</MenuItem>
172+
{suitableResultClasses.map(resultClass => <MenuItem value={resultClass.tabPath} key={resultClass.tabPath}>{intl.get(`tabs.${resultClass.tabPath}`) ? intl.get(`tabs.${resultClass.tabPath}`) : resultClass.tabPath}</MenuItem>)}
173+
</Select>
174+
<FormHelperText>{intl.get('exportLink.viewInstructions')}</FormHelperText>
175+
</FormControl>
176+
<TextField className={classes.linkField} label={fieldLabel} value={fullLink} InputProps={{ readOnly: true }} variant='filled' size='small' disabled={fullLink.length > 15800} />
177+
<Button variant='contained' color='primary' className={classes.button} onClick={() => navigator.clipboard.writeText(fullLink)} disabled={fullLink.length > 15800}>
178+
{copyLinkToClipboard}
179+
</Button>
180+
</div>
181+
</Paper>
182+
)
183+
}
184+
}
185+
186+
ExportLink.propTypes = {
187+
classes: PropTypes.object.isRequired,
188+
resultClass: PropTypes.string.isRequired,
189+
facetClass: PropTypes.string.isRequired,
190+
facets: PropTypes.object.isRequired,
191+
facetUpdateID: PropTypes.number.isRequired,
192+
rootUrl: PropTypes.string.isRequired,
193+
data: PropTypes.object.isRequired
194+
}
195+
196+
export const ExportLinkComponent = ExportLink
197+
198+
export default withStyles(styles)(ExportLink)

src/client/components/facet_results/FacetedSearchPerspective.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ const FacetedSearchPerspective = props => {
147147
updatePage={props.updatePage}
148148
updateRowsPerPage={props.updateRowsPerPage}
149149
updateFacetOption={props.updateFacetOption}
150+
fetchFacet={props.fetchFacet}
150151
updateMapBounds={props.updateMapBounds}
151152
sortResults={props.sortResults}
152153
showError={props.showError}

0 commit comments

Comments
 (0)