) {
69 |
70 | if (event.key === 'Enter') {
71 | // Otherwise the event will bubble up and the form will be submitted
72 | event.preventDefault();
73 | this.props.state.apply();
74 | }
75 | }
76 | }
77 |
78 | const DateTextField: typeof TextField = styled(TextField)({
79 | marginBottom: 10,
80 | marginLeft: 50
81 | });
82 |
--------------------------------------------------------------------------------
/src/components/DetailsDialog.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 |
5 | import {
6 | Chip, Button, Dialog, DialogActions, DialogContent, DialogTitle,
7 | LinearProgress, Paper, Tabs, Tab
8 | } from '@material-ui/core';
9 |
10 | import CloseIcon from '@material-ui/icons/Close';
11 |
12 | import { DetailsDialogMap, DetailsDialogMapHeight } from './DetailsDialogMap';
13 | import { TranscriptViewer } from './TranscriptViewer';
14 | import { MetadataViewer } from './MetadataViewer';
15 |
16 | import { DetailsDialogState, DetailsTabEnum } from '../states/DetailsDialogState';
17 |
18 | // Showing document details in a dialog
19 | @observer
20 | export class DetailsDialog extends React.Component<{ state: DetailsDialogState, hideMe: () => void, azureMapSubscriptionKey: string }> {
21 |
22 | render(): JSX.Element {
23 |
24 | const state = this.props.state;
25 |
26 | return (
27 |
79 | );
80 | }
81 | }
82 |
83 | const DetailsDialogActions: typeof DialogActions = styled(DialogActions)({
84 | padding: '20px !important'
85 | })
86 |
87 | const DetailsPaper: typeof Paper = styled(Paper)({
88 | padding: 10,
89 | height: DetailsDialogMapHeight,
90 | overflow: 'hidden'
91 | })
92 |
93 | const CloseButton: typeof Button = styled(Button)({
94 | float: 'right'
95 | })
96 |
97 | const DetailsDialogTitle: typeof DialogTitle = styled(DialogTitle)({
98 | paddingBottom: '0px !important'
99 | })
100 |
101 | const ErrorChip: typeof Chip = styled(Chip)({
102 | paddingTop: 10,
103 | paddingBottom: 10,
104 | paddingLeft: 20,
105 | paddingRight: 20
106 | })
--------------------------------------------------------------------------------
/src/components/DetailsDialogMap.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 | import * as atlas from 'azure-maps-control';
5 |
6 | import { SimpleScaleBarControl } from './SimpleScaleBarControl';
7 |
8 | export const DetailsDialogMapHeight = 550;
9 |
10 | // Azure Maps component for showing in the Details dialog
11 | @observer
12 | export class DetailsDialogMap extends React.Component<{ name: string, coordinates: number[], azureMapSubscriptionKey: string }> {
13 |
14 | componentDidMount() {
15 |
16 | // For some reason, DetailsMapDiv isn't available yet at this point, so need to do a setTimeout()
17 | setTimeout(() => {
18 |
19 | var map = new atlas.Map('DetailsMapDiv', {
20 |
21 | center: this.props.coordinates,
22 | zoom: 4,
23 | style: "road_shaded_relief",
24 | language: 'en-US',
25 |
26 | authOptions: {
27 | authType: atlas.AuthenticationType.subscriptionKey,
28 | subscriptionKey: this.props.azureMapSubscriptionKey
29 | }
30 | });
31 |
32 | map.events.add('ready', () => {
33 |
34 | //Add a metric scale bar to the map.
35 | map.controls.add(
36 | [
37 | new atlas.control.ZoomControl()
38 | ],
39 | { position: atlas.ControlPosition.BottomRight }
40 | );
41 |
42 | map.controls.add(
43 | [
44 | new SimpleScaleBarControl({ units: 'metric' }),
45 | ],
46 | { position: atlas.ControlPosition.TopRight }
47 | );
48 |
49 | const mapDataSource = new atlas.source.DataSource();
50 |
51 | mapDataSource.add(new atlas.data.Feature(
52 | new atlas.data.Point(this.props.coordinates),
53 | { name: this.props.name}));
54 |
55 | map.sources.add(mapDataSource);
56 | const layer = new atlas.layer.SymbolLayer(mapDataSource, null,
57 | {
58 | textOptions: {
59 | textField: ['get', 'name'],
60 | offset: [0, 1.2]
61 | }
62 | }
63 | );
64 | map.layers.add(layer);
65 | });
66 |
67 | }, 0);
68 | }
69 |
70 | render(): JSX.Element {
71 |
72 | return ( );
73 | }
74 | }
75 |
76 | const MapDiv = styled.div({
77 | background: '#bebebe',
78 | height: DetailsDialogMapHeight
79 | })
80 |
--------------------------------------------------------------------------------
/src/components/Facets.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 |
5 | import { Collapse, List, ListItem, ListItemText } from '@material-ui/core';
6 | import { ExpandLess, ExpandMore } from '@material-ui/icons';
7 |
8 | import { BooleanFacet } from './BooleanFacet';
9 | import { NumericFacet } from './NumericFacet';
10 | import { DateFacet } from './DateFacet';
11 | import { StringFacet } from './StringFacet';
12 | import { StringCollectionFacet } from './StringCollectionFacet';
13 |
14 | import { FacetsState } from '../states/FacetsState';
15 | import { FacetTypeEnum } from '../states/FacetState';
16 | import { StringFacetState } from '../states/StringFacetState';
17 | import { StringCollectionFacetState } from '../states/StringCollectionFacetState';
18 | import { NumericFacetState } from '../states/NumericFacetState';
19 | import { BooleanFacetState } from '../states/BooleanFacetState';
20 | import { DateFacetState } from '../states/DateFacetState';
21 |
22 | // Facets sidebar on the left
23 | @observer
24 | export class Facets extends React.Component<{ state: FacetsState, inProgress: boolean }> {
25 |
26 | render(): JSX.Element {
27 |
28 | const state = this.props.state;
29 |
30 | return (
31 |
32 | {state.facets.map(facetState => {
33 |
34 | var facetComponent: JSX.Element = null;
35 | switch (facetState.facetType) {
36 | case FacetTypeEnum.BooleanFacet:
37 | facetComponent = ();
38 | break;
39 | case FacetTypeEnum.NumericFacet:
40 | facetComponent = ();
41 | break;
42 | case FacetTypeEnum.DateFacet:
43 | facetComponent = ();
44 | break;
45 | case FacetTypeEnum.StringFacet:
46 | facetComponent = ();
47 | break;
48 | case FacetTypeEnum.StringCollectionFacet:
49 | facetComponent = ();
50 | break;
51 | }
52 |
53 | // Getting reference to a proper getHintText method in this a bit unusual and not very strongly typed way
54 | const getHintTextFunc = facetComponent?.type.getHintText;
55 |
56 | return (
57 |
58 | state.toggleExpand(facetState.fieldName)}>
59 |
63 | {!!facetState.isExpanded ? : }
64 |
65 |
66 |
67 | {facetComponent}
68 |
69 |
70 |
);
71 | })}
72 |
73 | );
74 | }
75 | }
76 |
77 | const FacetList: typeof List = styled(List)({
78 | marginTop: '32px !important'
79 | })
80 |
81 | const FacetListItem: typeof ListItem = styled(ListItem)({
82 | paddingLeft: '36px !important',
83 | })
--------------------------------------------------------------------------------
/src/components/FilterSummaryBox.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 | import * as atlas from 'azure-maps-control';
5 |
6 | import { Chip, Typography } from '@material-ui/core';
7 |
8 | import { FacetsState } from '../states/FacetsState';
9 | import { FacetTypeEnum } from '../states/FacetState';
10 | import { StringFacetState } from '../states/StringFacetState';
11 | import { StringCollectionFacetState } from '../states/StringCollectionFacetState';
12 | import { NumericFacetState } from '../states/NumericFacetState';
13 | import { BooleanFacetState } from '../states/BooleanFacetState';
14 | import { DateFacetState } from '../states/DateFacetState';
15 |
16 | import { FilterSummaryDateFacetChipId } from './DateFacet';
17 |
18 | // Facet filter visualization on the toolbar
19 | @observer
20 | export class FilterSummaryBox extends React.Component<{ state: FacetsState, inProgress: boolean }> {
21 |
22 | render(): JSX.Element {
23 |
24 | const state = this.props.state;
25 | const appliedFacets = state.facets.filter(f => f.state?.isApplied);
26 |
27 | return (
28 |
29 | {!!state.geoRegion && (
30 |
31 | Region:
32 |
33 | state.geoRegion = null}
37 | disabled={this.props.inProgress}
38 | />
39 |
40 | )}
41 |
42 | {appliedFacets.map(facet => {
43 |
44 | const facetType = facet.state.facetType;
45 | const booleanFacet = facet.state as BooleanFacetState;
46 | const numericFacet = facet.state as NumericFacetState;
47 | const dateFacet = facet.state as DateFacetState;
48 | const stringFacet = facet.state as StringFacetState;
49 | const stringCollectionFacet = facet.state as StringCollectionFacetState;
50 |
51 | return (
52 |
53 | {facet.displayName}:
54 |
55 | {facetType === FacetTypeEnum.BooleanFacet && (
56 | booleanFacet.reset()}
60 | disabled={this.props.inProgress}
61 | />
62 | )}
63 |
64 | {facetType === FacetTypeEnum.NumericFacet && (
65 | numericFacet.reset()}
69 | disabled={this.props.inProgress}
70 | />
71 | )}
72 |
73 | {facetType === FacetTypeEnum.DateFacet && (
74 | dateFacet.reset()}
79 | disabled={this.props.inProgress}
80 | />
81 | )}
82 |
83 | {facetType === FacetTypeEnum.StringFacet && stringFacet.values.filter(v => v.isSelected).map((facetValue, i) => {
84 | return (<>
85 |
86 | {i > 0 && (
87 | OR
88 | )}
89 |
90 | facetValue.isSelected = false}
95 | disabled={this.props.inProgress}
96 | />
97 | >)
98 | })}
99 |
100 | {facetType === FacetTypeEnum.StringCollectionFacet && stringCollectionFacet.values.filter(v => v.isSelected).map((facetValue, i) => {
101 | return (<>
102 |
103 | {i > 0 && (
104 | {stringCollectionFacet.useAndOperator ? 'AND' : 'OR'}
105 | )}
106 |
107 | facetValue.isSelected = false}
112 | disabled={this.props.inProgress}
113 | />
114 | >)
115 | })}
116 |
117 | )
118 | })}
119 |
);
120 | }
121 |
122 | private formatGeoRegion(region: atlas.data.BoundingBox): string {
123 |
124 | const topLeft = atlas.data.BoundingBox.getNorthWest(region);
125 | const bottomRight = atlas.data.BoundingBox.getSouthEast(region);
126 |
127 | return `[${topLeft[0].toFixed(3)},${topLeft[1].toFixed(3)}] - [${bottomRight[0].toFixed(3)},${bottomRight[1].toFixed(3)}]`;
128 | }
129 | }
130 |
131 | const FacetChipsDiv = styled.div({
132 | paddingLeft: 40,
133 | paddingBottom: 10,
134 | display: 'flex',
135 | flexWrap: 'wrap'
136 | })
137 |
138 | const FacetNameTypography: typeof Typography = styled(Typography)({
139 | marginRight: '10px !important',
140 | fontWeight: 'bold'
141 | })
142 |
143 | const OperatorTypography: typeof Typography = styled(Typography)({
144 | marginLeft: '10px !important',
145 | marginRight: '10px !important',
146 | marginBottom: '3px !important',
147 | })
148 |
--------------------------------------------------------------------------------
/src/components/LoginIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 |
4 | import { Button, Menu, MenuItem, Tooltip } from '@material-ui/core';
5 | import { AccountCircle } from '@material-ui/icons';
6 |
7 | import { LoginState } from '../states/LoginState';
8 |
9 | // Shows current login status
10 | @observer
11 | export class LoginIcon extends React.Component<{ state: LoginState }> {
12 |
13 | render(): JSX.Element {
14 |
15 | const state = this.props.state;
16 |
17 | return (<>
18 |
19 |
26 |
27 |
34 |
35 | >);
36 | }
37 | }
--------------------------------------------------------------------------------
/src/components/MetadataViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | import { Table, TableBody, TableRow, TableCell } from '@material-ui/core';
5 |
6 | import { DetailsDialogState } from '../states/DetailsDialogState';
7 |
8 | // Displays document's metadata
9 | export class MetadataViewer extends React.Component<{ state: DetailsDialogState }> {
10 |
11 | render(): JSX.Element {
12 | const state = this.props.state;
13 |
14 | return state.details && (
15 |
16 |
17 |
18 | {Object.keys(state.details).map(fieldName => {
19 | return (
20 |
21 | {fieldName}
22 | {JSON.stringify(state.details[fieldName])}
23 |
24 | );
25 | })}
26 |
27 |
28 |
29 | );
30 | }
31 | }
32 |
33 | const OverflowDiv = styled.div({
34 | height: '100%',
35 | overflow: 'auto'
36 | })
37 |
38 | const FieldNameCell: typeof TableCell = styled(TableCell)({
39 | width: 200
40 | })
41 |
42 | const FieldValueCell: typeof TableCell = styled(TableCell)({
43 | overflowWrap: 'anywhere'
44 | })
--------------------------------------------------------------------------------
/src/components/NumericFacet.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 |
5 | import { Slider } from '@material-ui/core';
6 |
7 | import { NumericFacetState } from '../states/NumericFacetState';
8 |
9 | // Renders facet for a numeric field
10 | @observer
11 | export class NumericFacet extends React.Component<{ state: NumericFacetState, inProgress: boolean }> {
12 |
13 | static getHintText(state: NumericFacetState): string {
14 | return `From ${state.range[0]} to ${state.range[1]}`;
15 | }
16 |
17 | render(): JSX.Element {
18 | const state = this.props.state;
19 | var marks = null, step = null;
20 |
21 | // If the number of distinct values is too large, the slider's look becomes messy.
22 | // So we have to switch to a fixed step
23 | if (state.values.length > 200) {
24 | step = (state.maxValue - state.minValue) / 100;
25 | } else {
26 | marks = state.values.map(v => { return { value: v } });
27 | }
28 |
29 | return (
30 | {
38 | state.range = newValue as number[];
39 | }}
40 | onChangeCommitted={(evt, newValue) => {
41 | state.range = newValue as number[];
42 | state.apply()
43 | }}
44 | valueLabelDisplay="on"
45 | />
46 | );
47 | }
48 | }
49 |
50 | const SliderDiv = styled.div({
51 | paddingTop: 40,
52 | paddingLeft: 46,
53 | paddingRight: 30
54 | });
55 |
--------------------------------------------------------------------------------
/src/components/SearchResults.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 | import { Avatar, Card, Chip, CardHeader, CardContent, Grid, Link, LinearProgress, Typography } from '@material-ui/core';
5 | import FolderIcon from '@material-ui/icons/Folder';
6 |
7 | import { SearchResultsState } from '../states/SearchResultsState';
8 |
9 | // List of search results
10 | @observer
11 | export class SearchResults extends React.Component<{ state: SearchResultsState, inProgress: boolean }> {
12 |
13 | componentDidMount() {
14 |
15 | // Doing a simple infinite scroll
16 | document.addEventListener('scroll', (evt) => {
17 |
18 | const scrollingElement = (evt.target as Document).scrollingElement;
19 | if (!scrollingElement) {
20 | return;
21 | }
22 |
23 | const scrollPos = scrollingElement.scrollHeight - window.innerHeight - scrollingElement.scrollTop;
24 | const scrollPosThreshold = 100;
25 |
26 | if (scrollPos < scrollPosThreshold) {
27 | this.props.state.loadMoreResults();
28 | }
29 | });
30 |
31 |
32 | }
33 |
34 | render(): JSX.Element {
35 |
36 | const state = this.props.state;
37 |
38 | var cards = state.searchResults.map(item => {
39 |
40 | return (
41 |
42 |
43 | state.showDetails(item)}>
46 |
47 |
48 | }
49 | title={ state.showDetails(item)}>{item.name}}
50 | />
51 |
52 | {item.otherFields.map(val => { return (
53 |
54 | {val}
55 |
56 | )})}
57 |
58 |
59 |
60 | {item.keywords.map(kw => { return (
61 | state.facetsState.filterBy(item.keywordsFieldName, kw)}
66 | disabled={this.props.inProgress}
67 | />
68 | ); })}
69 |
70 |
71 |
72 |
73 | );
74 | });
75 |
76 | return (<>
77 |
78 |
79 | {state.searchResults.length} of {state.totalResults} results shown
80 |
81 |
82 | {!!state.inProgress && ()}
83 |
84 |
85 |
86 |
87 | {!!state.errorMessage && (
88 | state.HideError()}/>
89 | )}
90 |
91 | {cards}
92 |
93 |
94 | {(!!state.inProgress && !!state.searchResults.length) && ()}
95 | >);
96 | }
97 | }
98 |
99 | const ResultsGrid: typeof Grid = styled(Grid)({
100 | paddingRight: 30,
101 | paddingBottom: 20,
102 |
103 | // required for Edge :(((
104 | marginLeft: '0px !important',
105 | })
106 |
107 | const TagButtonsDiv = styled.div({
108 | marginRight: '15px !important',
109 | marginLeft: '5px !important',
110 | marginTop: '8px !important',
111 | marginBottom: '10px !important',
112 | display: 'flex',
113 | flexWrap: 'wrap'
114 | })
115 |
116 | const CountersTypography: typeof Typography = styled(Typography)({
117 | float: 'right',
118 | width: 'auto',
119 | margin: '10px !important'
120 | })
121 |
122 | const TopLinearProgress: typeof LinearProgress = styled(LinearProgress)({
123 | top: 20
124 | })
125 |
126 | const TagChip: typeof Chip = styled(Chip)({
127 | marginLeft: '10px !important',
128 | marginBottom: '10px !important'
129 | })
130 |
131 | const ErrorChip: typeof Chip = styled(Chip)({
132 | paddingTop: 10,
133 | paddingBottom: 10,
134 | paddingLeft: 20,
135 | paddingRight: 20,
136 | marginLeft: 50,
137 | marginRight: 50,
138 | })
139 |
--------------------------------------------------------------------------------
/src/components/SearchResultsMap.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { autorun } from 'mobx'
3 | import { observer } from 'mobx-react';
4 |
5 | import * as atlas from 'azure-maps-control';
6 | import * as azmdraw from 'azure-maps-drawing-tools';
7 |
8 | import styled from 'styled-components';
9 |
10 | import { Chip, LinearProgress, Typography } from '@material-ui/core';
11 |
12 | import { MapResultsState } from '../states/MapResultsState';
13 | import { SimpleScaleBarControl } from './SimpleScaleBarControl';
14 |
15 | // I have no idea, why this CSS from Azure Maps needs to be imported explicitly
16 | import '../../node_modules/azure-maps-control/dist/atlas.css';
17 | import '../../node_modules/azure-maps-drawing-tools/dist/atlas.drawing.css';
18 |
19 | // Azure Maps component for showing search results on
20 | @observer
21 | export class SearchResultsMap extends React.Component<{ state: MapResultsState, azureMapSubscriptionKey: string, geoRegion: atlas.data.BoundingBox, geoRegionSelected: (r: atlas.data.BoundingBox) => void }> {
22 |
23 | componentDidMount() {
24 |
25 | const state = this.props.state;
26 |
27 | var map = new atlas.Map('MapDiv', {
28 |
29 | style: "road_shaded_relief",
30 | language: 'en-US',
31 |
32 | authOptions: {
33 | authType: atlas.AuthenticationType.subscriptionKey,
34 | subscriptionKey: this.props.azureMapSubscriptionKey
35 | }
36 | });
37 |
38 | map.events.add('ready', () => {
39 |
40 | //Add a metric scale bar to the map.
41 | map.controls.add(
42 | [
43 | new atlas.control.ZoomControl()
44 | ],
45 | { position: atlas.ControlPosition.BottomRight }
46 | );
47 |
48 | map.controls.add(
49 | [
50 | new SimpleScaleBarControl({ units: 'metric' }),
51 | ],
52 | { position: atlas.ControlPosition.TopRight }
53 | );
54 |
55 | // Showing the dataSource with search results
56 | map.sources.add(state.mapDataSource);
57 |
58 | const layer = new atlas.layer.SymbolLayer(state.mapDataSource, null,
59 | {
60 | textOptions: {
61 | // Corresponds to SearchResult.name field
62 | textField: ['get', 'name'],
63 | offset: [0, 1.2],
64 | size: 12,
65 | optional: true
66 | },
67 | iconOptions: {
68 | allowOverlap: true,
69 | ignorePlacement: true,
70 | size: 0.5,
71 | image: 'pin-round-red'
72 | }
73 | }
74 | );
75 | map.layers.add(layer);
76 |
77 | //Create an instance of the drawing manager and display the drawing toolbar.
78 | const drawingManager = new azmdraw.drawing.DrawingManager(map, {
79 | toolbar: new azmdraw.control.DrawingToolbar({
80 | position: 'bottom-right',
81 | buttons: ['draw-rectangle']
82 | })
83 | });
84 |
85 | // Region selection handler
86 | map.events.add('drawingcomplete', drawingManager, (rect: atlas.Shape) => {
87 |
88 | this.props.geoRegionSelected(rect.getBounds());
89 |
90 | // Reset the drawing
91 | drawingManager.setOptions({ mode: azmdraw.drawing.DrawingMode.idle });
92 | drawingManager.getSource().clear();
93 | });
94 |
95 | // Configure what happens when user clicks on a point
96 | map.events.add('click', layer, (e: atlas.MapMouseEvent) => {
97 |
98 | if (!e.shapes || e.shapes.length <= 0) {
99 | return;
100 | }
101 |
102 | const shape = e.shapes[0] as atlas.Shape;
103 | if (shape.getType() !== 'Point') {
104 | return;
105 | }
106 |
107 | state.showDetails(shape.getProperties());
108 | });
109 | });
110 |
111 | // Also adding an observer, that reacts on any change in state.mapBounds. This will zoom the map to that bounding box.
112 | autorun(() => {
113 | map.setCamera({ bounds: this.props.geoRegion ?? state.mapBounds, padding: 40 });
114 | });
115 | }
116 |
117 | render(): JSX.Element {
118 |
119 | const state = this.props.state;
120 |
121 | return (<>
122 |
123 |
124 |
125 | {state.resultsShown} results shown on map
126 |
127 |
128 | {!!state.inProgress && ()}
129 |
130 |
131 | {!!state.errorMessage && (
132 | state.HideError()}/>
133 | )}
134 |
135 |
136 | >);
137 | }
138 | }
139 |
140 | const MapDiv = styled.div({
141 | background: '#bebebe',
142 | height: '350px'
143 | })
144 |
145 | const CountersDiv = styled.div({
146 | height: 40
147 | })
148 |
149 | const TopLinearProgress: typeof LinearProgress = styled(LinearProgress)({
150 | top: 20
151 | })
152 |
153 | const CountersTypography: typeof Typography = styled(Typography)({
154 | float: 'right',
155 | width: 'auto',
156 | margin: '10px !important'
157 | })
158 |
159 | const ErrorChip: typeof Chip = styled(Chip)({
160 | zIndex: 1,
161 | position: 'absolute',
162 | paddingTop: 10,
163 | paddingBottom: 10,
164 | paddingLeft: 20,
165 | paddingRight: 20,
166 | marginTop: 50,
167 | marginLeft: 50,
168 | marginRight: 50,
169 | })
170 |
--------------------------------------------------------------------------------
/src/components/SearchTextBox.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 |
5 | import { TextField, Button } from '@material-ui/core';
6 | import Autocomplete from '@material-ui/lab/Autocomplete';
7 |
8 | import { SearchResultsState } from '../states/SearchResultsState';
9 |
10 | // TextBox for entering search query into
11 | @observer
12 | export class SearchTextBox extends React.Component<{ state: SearchResultsState, inProgress: boolean }> {
13 |
14 | render(): JSX.Element {
15 |
16 | const state = this.props.state;
17 |
18 | return (
19 |
20 |
21 | state.search()}>
22 | Search
23 |
24 |
25 |
26 |
27 | {
33 | state.searchString = newValue ?? '';
34 | if (!!newValue) {
35 | state.search();
36 | }
37 | }}
38 | renderInput={(params) => (
39 | state.searchString = evt.target.value as string}
46 | onKeyPress={(evt) => this.handleKeyPress(evt)}
47 | />
48 | )}
49 | />
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | private handleKeyPress(event: React.KeyboardEvent) {
58 | if (event.key === 'Enter') {
59 | // Otherwise the event will bubble up and the form will be submitted
60 | event.preventDefault();
61 |
62 | this.props.state.search();
63 | }
64 | }
65 | }
66 |
67 | const SearchTextBoxDiv = styled.div({
68 | overflow: 'hidden',
69 | paddingLeft: 35,
70 | paddingRight: 20
71 | })
72 |
73 |
74 | const SearchButton: typeof Button = styled(Button)({
75 | float: 'right',
76 | width: 150,
77 | height: 40
78 | });
79 |
80 | const SearchTextWrapper = styled.div({
81 | height: '100%',
82 | paddingTop: 20,
83 | paddingBottom: 20,
84 | });
85 |
--------------------------------------------------------------------------------
/src/components/SimpleScaleBarControl.ts:
--------------------------------------------------------------------------------
1 | import * as atlas from 'azure-maps-control';
2 |
3 | /** A simple scale bar control. */
4 | export class SimpleScaleBarControl {
5 |
6 | private _map: any;
7 | private _scaleBar: any;
8 | private _options: any;
9 | private _updateScaleBar: any;
10 |
11 |
12 | /****************************
13 | * Constructor
14 | ***************************/
15 | /**
16 | * A simple scale bar control.
17 | * @param options Options for defining how the control is rendered and functions.
18 | */
19 | constructor(options: any) {
20 | /****************************
21 | * Private Properties
22 | ***************************/
23 | this._map = null;
24 | this._scaleBar = null;
25 | this._options = {
26 | units: 'imperial',
27 | maxBarLength: 100
28 | };
29 | /****************************
30 | * Private Methods
31 | ***************************/
32 | /** Updates the layout of the scalebar. */
33 | this._updateScaleBar = () => {
34 | var camera = this._map.getCamera();
35 | //Get the center pixel.
36 | var cp = this._map.pixelsToPositions([camera.center]);
37 | //Calculate two coordinates that are seperated by the maxBarLength pixel distance from the center pixel.
38 | var pos = this._map.pixelsToPositions([[0, cp[0][1]], [this._options.maxBarLength, cp[0][1]]]);
39 | //Calculate the strightline distance between the positions.
40 | var units = this._options.units.toLowerCase();
41 | if (units === 'imperial') {
42 | units = 'miles';
43 | }
44 | else if (units === 'metric') {
45 | units = 'kilometers';
46 | }
47 | var trueDistance = atlas.math.getDistanceTo(pos[0], pos[1], units);
48 | //Round the true distance to a nicer number.
49 | var niceDistance = this._getRoundNumber(trueDistance);
50 | var isSmall = false;
51 | if (niceDistance < 2) {
52 | units = this._options.units.toLowerCase();
53 | if (units === 'imperial') {
54 | //Convert to feet.
55 | trueDistance *= 5280;
56 | niceDistance = this._getRoundNumber(trueDistance);
57 | isSmall = true;
58 | }
59 | else if (units === 'metric') {
60 | //Convert to meters.
61 | trueDistance *= 1000;
62 | niceDistance = this._getRoundNumber(trueDistance);
63 | isSmall = true;
64 | }
65 | }
66 | //Calculate the distanceRatio between the true and nice distances and scale the scalebar size accordingly.
67 | var distanceRatio = niceDistance / trueDistance;
68 | //Update the width of the scale bar by scaling the maxBarLength option by the distance ratio.
69 | this._scaleBar.style.width = (this._options.maxBarLength * distanceRatio) + 'px';
70 | //Update the text of the scale bar.
71 | this._scaleBar.innerHTML = this._createDistanceString(niceDistance, isSmall);
72 | };
73 | this._options = Object.assign({}, this._options, options);
74 | }
75 | /****************************
76 | * Public Methods
77 | ***************************/
78 | /**
79 | * Action to perform when the control is added to the map.
80 | * @param map The map the control was added to.
81 | * @param options The control options used when adding the control to the map.
82 | * @returns The HTML Element that represents the control.
83 | */
84 | onAdd(map: any, options: any) {
85 | this._map = map;
86 | //Add the CSS style for the control to the DOM.
87 | var style = document.createElement('style');
88 | style.innerHTML = '.atlas-map-customScaleBar {background-color:rgba(255,255,255,0.8);font-size:10px;border-width:medium 2px 2px;border-style:none solid solid;border-color:black;padding:0 5px;color:black;}';
89 | document.body.appendChild(style);
90 | this._scaleBar = document.createElement('div');
91 | this._scaleBar.className = 'atlas-map-customScaleBar';
92 | this._map.events.add('move', this._updateScaleBar);
93 | this._updateScaleBar();
94 | return this._scaleBar;
95 | }
96 | /**
97 | * Action to perform when control is removed from the map.
98 | */
99 | onRemove() {
100 | if (this._map) {
101 | this._map.events.remove('move', this._updateScaleBar);
102 | }
103 | this._map = null;
104 | this._scaleBar.remove();
105 | this._scaleBar = null;
106 | }
107 | /**
108 | * Rounds a number to a nice value.
109 | * @param num The number to round.
110 | */
111 | _getRoundNumber(num: number) {
112 | if (num >= 2) {
113 | //Convert the number to a round value string and get the number of characters. Then use this to calculate the powe of 10 increment of the number.
114 | var pow10 = Math.pow(10, (Math.floor(num) + '').length - 1);
115 | var i = num / pow10;
116 | //Shift the number to the closest nice number.
117 | if (i >= 10) {
118 | i = 10;
119 | }
120 | else if (i >= 5) {
121 | i = 5;
122 | }
123 | else if (i >= 3) {
124 | i = 3;
125 | }
126 | else if (i >= 2) {
127 | i = 2;
128 | }
129 | else {
130 | i = 1;
131 | }
132 | return pow10 * i;
133 | }
134 | return Math.round(100 * num) / 100;
135 | }
136 | /**
137 | * Create the string to display the distance information.
138 | * @param num The dustance value.
139 | * @param isSmall Specifies if the number is a small value (meters or feet).
140 | */
141 | _createDistanceString(num: number, isSmall: boolean) {
142 | if (this._options.units) {
143 | switch (this._options.units.toLowerCase()) {
144 | case 'feet':
145 | case 'foot':
146 | case 'ft':
147 | return num + ' ft';
148 | case 'kilometers':
149 | case 'kilometer':
150 | case 'kilometres':
151 | case 'kilometre':
152 | case 'km':
153 | case 'kms':
154 | return num + ' km';
155 | case 'miles':
156 | case 'mile':
157 | case 'mi':
158 | return num + ' mi';
159 | case 'nauticalmiles':
160 | case 'nauticalmile':
161 | case 'nms':
162 | case 'nm':
163 | return num + ' nm';
164 | case 'yards':
165 | case 'yard':
166 | case 'yds':
167 | case 'yrd':
168 | case 'yrds':
169 | return num + ' yds';
170 | case 'metric':
171 | if (isSmall) {
172 | return num + ' m';
173 | }
174 | else {
175 | return num + ' km';
176 | }
177 | case 'imperial':
178 | if (isSmall) {
179 | return num + ' ft';
180 | }
181 | else {
182 | return num + ' mi';
183 | }
184 | case 'meters':
185 | case 'metres':
186 | case 'm':
187 | default:
188 | return num + ' m';
189 | }
190 | }
191 | }
192 | }
--------------------------------------------------------------------------------
/src/components/StringCollectionFacet.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 |
5 | import { Checkbox, List, ListItem, ListItemText, Radio } from '@material-ui/core';
6 |
7 | import { StringCollectionFacetState } from '../states/StringCollectionFacetState';
8 |
9 | // Renders facet for a string array field
10 | @observer
11 | export class StringCollectionFacet extends React.Component<{ state: StringCollectionFacetState, inProgress: boolean }> {
12 |
13 | static getHintText(state: StringCollectionFacetState): string {
14 | return state.allSelected ? `All ${state.values.length} selected` : `${state.selectedCount} of ${state.values.length} selected`;
15 | }
16 |
17 | render(): JSX.Element {
18 | const state = this.props.state;
19 | return (
20 |
21 |
22 | state.allSelected = evt.target.checked}
26 | />
27 |
28 |
29 |
30 |
31 | state.useAndOperator = false}
35 | />
36 |
37 |
38 | state.useAndOperator = true}
42 | />
43 |
44 |
45 |
46 | {state.values.map(facetValue => {
47 | return (
48 |
49 |
50 | facetValue.isSelected = evt.target.checked}
54 | />
55 |
56 |
57 |
58 | );
59 | })}
60 |
61 | );
62 | }
63 | }
64 |
65 | const FacetValueListItem: typeof ListItem = styled(ListItem)({
66 | paddingLeft: '46px !important',
67 | });
68 |
69 | const FacetValuesList: typeof List = styled(List)({
70 | maxHeight: 340,
71 | overflowY: 'auto !important',
72 | marginRight: '18px !important'
73 | })
--------------------------------------------------------------------------------
/src/components/StringFacet.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { observer } from 'mobx-react';
3 | import styled from 'styled-components';
4 |
5 | import { Checkbox, List, ListItem, ListItemText } from '@material-ui/core';
6 |
7 | import { StringFacetState } from '../states/StringFacetState';
8 | import { StringCollectionFacetState } from '../states/StringCollectionFacetState';
9 |
10 | // Renders facet for a string field
11 | @observer
12 | export class StringFacet extends React.Component<{ state: StringFacetState, inProgress: boolean }> {
13 |
14 | static getHintText(state: StringCollectionFacetState): string {
15 | return state.allSelected ? `All ${state.values.length} selected` : `${state.selectedCount} of ${state.values.length} selected`;
16 | }
17 |
18 | render(): JSX.Element {
19 | const state = this.props.state;
20 | return (
21 |
22 |
23 | state.allSelected = evt.target.checked}
27 | />
28 |
29 |
30 |
31 | {state.values.map(facetValue => {
32 | return (
33 |
34 |
35 | facetValue.isSelected = evt.target.checked}
39 | />
40 |
41 |
42 |
43 | );
44 | })}
45 |
46 | );
47 | }
48 | }
49 |
50 | const FacetValueListItem: typeof ListItem = styled(ListItem)({
51 | paddingLeft: '46px !important',
52 | });
53 |
54 | const FacetValuesList: typeof List = styled(List)({
55 | maxHeight: 340,
56 | overflowY: 'auto !important',
57 | marginRight: '18px !important'
58 | })
--------------------------------------------------------------------------------
/src/components/TranscriptViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | import { List, ListItem, ListItemText } from '@material-ui/core';
5 |
6 | import { DetailsDialogState } from '../states/DetailsDialogState';
7 |
8 | const KeywordIdPrefix = 'keywordSpan';
9 |
10 | // Shows document's raw text with some navigation supported
11 | export class TranscriptViewer extends React.Component<{ state: DetailsDialogState }> {
12 |
13 | render(): JSX.Element { return (<>
14 | {this.getFragmentsMarkup()}
15 | {this.getTextMarkup()}
16 | >);}
17 |
18 | private getTextMarkup(): (JSX.Element | string)[] {
19 | const state = this.props.state;
20 |
21 | // returning text with keywords highlighted
22 | var i = 0;
23 | return state.textFragments.map(fragment => {
24 |
25 | const text = state.getPieceOfText(fragment.text);
26 |
27 | if (!fragment.textBefore && !fragment.textAfter) {
28 | return text;
29 | }
30 |
31 | return ({text});
32 | });
33 | }
34 |
35 | private getFragmentsMarkup(): (JSX.Element | string)[] {
36 | const state = this.props.state;
37 |
38 | // Rendering keywords only
39 | const fragments = state.textFragments.filter(f => !!f.textBefore || !!f.textAfter);
40 |
41 | const resultMarkup: (JSX.Element | string)[] = [];
42 | var i = 0;
43 | while (i < fragments.length) {
44 | var fragment = fragments[i];
45 |
46 | const fragmentMarkup: (JSX.Element | string)[] = [];
47 |
48 | fragmentMarkup.push(state.getPieceOfText(fragment.textBefore));
49 | fragmentMarkup.push((
50 | {state.getPieceOfText(fragment.text)}
51 | ));
52 |
53 | // Also bringing the selected keyword (the first one in a chain) into view upon click
54 | const keywordSpanId = `${KeywordIdPrefix}${i}`;
55 |
56 | var concatenatedFragmentsCount = 0;
57 | var nextFragment = fragments[++i];
58 | // if next keyword fits into current fragment - keep concatenating fragments, but limiting this process to 5
59 | while (!!nextFragment && nextFragment.text.start < fragment.textAfter.stop && concatenatedFragmentsCount < 4) {
60 |
61 | fragmentMarkup.push(state.getPieceOfText({ start: fragment.text.stop, stop: nextFragment.text.start }));
62 | fragmentMarkup.push((
63 | {state.getPieceOfText(nextFragment.text)}
64 | ));
65 |
66 | fragment = nextFragment;
67 | nextFragment = fragments[++i];
68 | concatenatedFragmentsCount++;
69 | }
70 |
71 | fragmentMarkup.push(state.getPieceOfText(fragment.textAfter));
72 |
73 | resultMarkup.push((
74 | document.getElementById(keywordSpanId).scrollIntoView(false)}>
75 |
76 |
77 | ));
78 | }
79 |
80 | return resultMarkup;
81 | }
82 | }
83 |
84 | const OverflowDiv = styled.div({
85 | height: '100%',
86 | width: 'auto',
87 | overflow: 'auto'
88 | })
89 |
90 | const FragmentsList: typeof List = styled(List)({
91 | float: 'right',
92 | width: 400,
93 | height: '100%',
94 | overflowY: 'auto',
95 | paddingLeft: '10px !important',
96 | overflowWrap: 'anywhere'
97 | })
98 |
99 | const HighlightedSpan = styled.span({
100 | background: 'bisque'
101 | })
102 |
103 | const WrappedPre = styled.pre({
104 | whiteSpace: 'pre-wrap'
105 | })
106 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | overflow-y: scroll;
4 | overflow-x: hidden;
5 | font-family: 'Arial', 'Lucida Grande', sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import './index.css';
5 |
6 | import App from './App';
7 | import { AppState } from './states/AppState';
8 |
9 | ReactDOM.render(
10 |
11 |
12 | ,
13 | document.getElementById('root')
14 | );
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
27 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare module 'styled-components';
--------------------------------------------------------------------------------
/src/states/AppState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx';
2 |
3 | import { LoginState } from './LoginState';
4 | import { DetailsDialogState } from './DetailsDialogState';
5 | import { MapResultsState } from './MapResultsState';
6 | import { SearchResultsState } from './SearchResultsState';
7 | import { SearchResult } from './SearchResult';
8 | import { GetServerSideConfig } from './ServerSideConfig';
9 |
10 | // The root object in app's state hierarchy
11 | export class AppState {
12 |
13 | // Object with server-side configuration values
14 | readonly serverSideConfig = GetServerSideConfig();
15 |
16 | // Progress flag
17 | @computed
18 | get inProgress(): boolean { return this.searchResultsState.inProgress || this.mapResultsState?.inProgress; }
19 |
20 | // Login state and user info
21 | readonly loginState: LoginState = new LoginState();
22 |
23 | // State of search results shown as a list
24 | readonly searchResultsState: SearchResultsState = new SearchResultsState(
25 | r => this.showDetails(r), s => this.mapResultsState?.loadResults(s), this.serverSideConfig)
26 |
27 | // State of search results shown on a map
28 | readonly mapResultsState: MapResultsState = this.areMapResultsEnabled ? new MapResultsState(r => this.showDetails(r), this.serverSideConfig) : null;
29 |
30 | // Details dialog's state
31 | get detailsState(): DetailsDialogState { return this._detailsState; };
32 |
33 | // Needed to anchor popup menu to
34 | @observable
35 | menuAnchorElement?: Element;
36 |
37 | constructor() {
38 |
39 | this.parseAndApplyQueryString();
40 |
41 | document.title = `Cognitive Search Demo - ${this.serverSideConfig.SearchServiceName}/${this.serverSideConfig.SearchIndexName}`;
42 | }
43 |
44 | // Shows Details dialog
45 | showDetails(result: SearchResult) {
46 | this._detailsState = new DetailsDialogState(this.searchResultsState.searchString,
47 | result,
48 | this.areMapResultsEnabled ? this.serverSideConfig.CognitiveSearchGeoLocationField : null,
49 | this.serverSideConfig.CognitiveSearchTranscriptFields
50 | );
51 | }
52 |
53 | // Hides Details dialog
54 | hideDetails() {
55 | this._detailsState = null;
56 | }
57 |
58 | @observable
59 | private _detailsState: DetailsDialogState;
60 |
61 | private get areMapResultsEnabled(): boolean {
62 | return !!this.serverSideConfig.CognitiveSearchGeoLocationField
63 | && !!this.serverSideConfig.AzureMapSubscriptionKey;
64 | }
65 |
66 | private parseAndApplyQueryString(): void {
67 |
68 | const queryString = window.location.search;
69 | if (!queryString) {
70 | return;
71 | }
72 |
73 | // If there is an incoming query string, we first run query without $filter clause, to collect facet values,
74 | // and then run the query again, this time with incoming $filter applied. It is slower, but makes Facets tab
75 | // look correct.
76 | var filterClause: string = null;
77 |
78 | const filterMatch = /[?&]?\$filter=([^&]+)/i.exec(window.location.search);
79 | if (!!filterMatch) {
80 | filterClause = decodeURIComponent(filterMatch[1]);
81 | }
82 |
83 | const searchQueryMatch = /[?&]?search=([^&]*)/i.exec(window.location.search);
84 | if (!!searchQueryMatch) {
85 | this.searchResultsState.searchString = decodeURIComponent(searchQueryMatch[1]);
86 | this.searchResultsState.search(filterClause);
87 | }
88 | }
89 | }
--------------------------------------------------------------------------------
/src/states/BooleanFacetState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 |
3 | import { FacetTypeEnum } from './FacetState'
4 |
5 | // Facet for a boolean field
6 | export class BooleanFacetState {
7 |
8 | readonly facetType: FacetTypeEnum = FacetTypeEnum.BooleanFacet;
9 |
10 | @computed
11 | get value(): boolean | null {
12 | return this._value;
13 | }
14 | set value(val: boolean | null) {
15 | this._value = val;
16 | this._onChanged();
17 | }
18 |
19 | @computed
20 | get trueCount(): number {
21 | return this._trueCount;
22 | }
23 |
24 | @computed
25 | get falseCount(): number {
26 | return this._falseCount;
27 | }
28 |
29 | @computed
30 | get isApplied(): boolean {
31 | return this._value !== null;
32 | }
33 |
34 | constructor(
35 | private _onChanged: () => void, readonly fieldName: string) {
36 | }
37 |
38 | reset(): void {
39 | this._value = null;
40 | this._onChanged();
41 | }
42 |
43 | populateFacetValues(facetValues: { value: boolean, count: number }[], filterClause: string) {
44 |
45 | this.updateFacetValueCounts(facetValues);
46 | this._value = this.parseFilterExpression(filterClause);
47 | }
48 |
49 | updateFacetValueCounts(facetValues: { value: boolean, count: number }[]) {
50 |
51 | this._trueCount = facetValues.find(fv => fv.value === true)?.count ?? 0;
52 | this._falseCount = facetValues.find(fv => fv.value === false)?.count ?? 0;
53 | }
54 |
55 | getFilterExpression(): string {
56 |
57 | if (!this.isApplied) {
58 | return '';
59 | }
60 |
61 | return `${this.fieldName} eq ${this._value}`;
62 | }
63 |
64 | @observable
65 | private _value?: boolean = null;
66 |
67 | @observable
68 | private _trueCount: number = 0;
69 |
70 | @observable
71 | private _falseCount: number = 0;
72 |
73 | private parseFilterExpression(filterClause: string): boolean | null {
74 |
75 | if (!filterClause) {
76 | return null;
77 | }
78 |
79 | const regex = new RegExp(`${this.fieldName} eq (true|false)`, 'gi');
80 | const match = regex.exec(filterClause);
81 | return !match ? null : match[1] === 'true';
82 | }
83 | }
--------------------------------------------------------------------------------
/src/states/DateFacetState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 |
3 | import { FacetTypeEnum } from './FacetState'
4 |
5 | // Facet for a field containing dates
6 | export class DateFacetState {
7 |
8 | readonly facetType: FacetTypeEnum = FacetTypeEnum.DateFacet;
9 |
10 | @computed
11 | get from(): Date { return this._from; }
12 | @computed
13 | get till(): Date { return this._till; }
14 |
15 | @computed
16 | get isApplied(): boolean {
17 |
18 | return this._from !== this._minDate || this._till !== this._maxDate;
19 | }
20 |
21 | @observable
22 | currentFrom: Date = new Date();
23 | @observable
24 | currentTill: Date = new Date();
25 |
26 | constructor(
27 | private _onChanged: () => void, readonly fieldName: string) {
28 | }
29 |
30 | apply(): void {
31 |
32 | if (this._from === this.currentFrom && this._till === this.currentTill) {
33 | return;
34 | }
35 |
36 | this._from = this.currentFrom;
37 | this._till = this.currentTill;
38 | this._onChanged();
39 | }
40 |
41 | reset(): void {
42 | this._from = this.currentFrom = this._minDate;
43 | this._till = this.currentTill = this._maxDate;
44 | this._onChanged();
45 | }
46 |
47 | populateFacetValues(facetValues: { value: string, count: number }[], filterClause: string) {
48 |
49 | const dates = facetValues.map(fv => new Date(fv.value).getTime());
50 |
51 | this._minDate = new Date(Math.min(...dates));
52 | if (isNaN(this._minDate.valueOf())) {
53 | this._minDate = new Date(0);
54 | }
55 |
56 | this._maxDate = new Date(Math.max(...dates));
57 | if (isNaN(this._maxDate.valueOf())) {
58 | this._maxDate = new Date();
59 | }
60 |
61 | // If there was a $filter expression in the URL, then parsing and applying it
62 | const dateRange = this.parseFilterExpression(filterClause);
63 |
64 | if (!!dateRange) {
65 |
66 | this._from = dateRange[0];
67 | this._till = dateRange[1];
68 |
69 | } else {
70 |
71 | this._from = this._minDate;
72 | this._till = this._maxDate;
73 | }
74 |
75 | this.currentFrom = this._from;
76 | this.currentTill = this._till;
77 | }
78 |
79 | updateFacetValueCounts(facetValues: { value: string, count: number }[]) {
80 | // doing nothing for now
81 | }
82 |
83 | getFilterExpression(): string {
84 |
85 | if (!this.isApplied) {
86 | return '';
87 | }
88 |
89 | return `${this.fieldName} ge ${this._from.toJSON().slice(0, 10)} and ${this.fieldName} le ${this._till.toJSON().slice(0, 10)}`;
90 | }
91 |
92 | @observable
93 | private _minDate: Date;
94 | @observable
95 | private _maxDate: Date;
96 | @observable
97 | private _from: Date = new Date();
98 | @observable
99 | private _till: Date = new Date();
100 |
101 | private parseFilterExpression(filterClause: string): Date[] {
102 |
103 | if (!filterClause) {
104 | return null;
105 | }
106 |
107 | const regex = new RegExp(`${this.fieldName} ge ([0-9-]+) and ${this.fieldName} le ([0-9-]+)`, 'gi');
108 | const match = regex.exec(filterClause);
109 | return !match ? null : [new Date(match[1]), new Date(match[2])];
110 | }
111 | }
--------------------------------------------------------------------------------
/src/states/DetailsDialogState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 | import axios from 'axios';
3 |
4 | import { ErrorMessageState } from './ErrorMessageState';
5 | import { SearchResult } from './SearchResult';
6 |
7 | const BackendUri = process.env.REACT_APP_BACKEND_BASE_URI as string;
8 |
9 | // Enum describing tabs on the Details dialog
10 | export enum DetailsTabEnum {
11 | Transcript = 0,
12 | Metadata,
13 | Map
14 | }
15 |
16 | // A pair of positions in a text
17 | interface ITextInterval {
18 | start: number;
19 | stop: number;
20 | }
21 |
22 | // Represents a fragment inside document's text
23 | interface ITextFragment {
24 | readonly text: ITextInterval;
25 | readonly textBefore?: ITextInterval;
26 | readonly textAfter?: ITextInterval;
27 | }
28 |
29 | // Num of symbols to take before and after the search keyword
30 | const TextFragmentLength = 100;
31 |
32 | // State of the Details dialog
33 | export class DetailsDialogState extends ErrorMessageState {
34 |
35 | // Tab currently selected
36 | @observable
37 | selectedTab: DetailsTabEnum = DetailsTabEnum.Transcript;
38 |
39 | // Raw text split into fragments like
40 | @computed
41 | get textFragments(): ITextFragment[] {
42 |
43 | if (this.searchWords.length <= 0) {
44 | return [{ text: { start: 0, stop: this._text.length } }];
45 | }
46 |
47 | const results: ITextFragment[] = []
48 | var prevIndex = 0;
49 |
50 | // searching for any of search keywords...
51 | const regex = new RegExp(this.searchWords.join('|'), 'gi');
52 | var match: RegExpExecArray | null;
53 | while (!!(match = regex.exec(this._text))) {
54 |
55 | const keyword = { start: match.index, stop: match.index + match[0].length };
56 |
57 | if (keyword.start > prevIndex) {
58 | results.push({ text: { start: prevIndex, stop: keyword.start } });
59 | }
60 |
61 | // A fragment with textBefore and textAfter denotes a keyword (which is to be highlighted by markup)
62 | results.push({
63 | textBefore: { start: keyword.start - TextFragmentLength, stop: keyword.start },
64 | text: keyword,
65 | textAfter: { start: keyword.stop, stop: keyword.stop + TextFragmentLength }
66 | });
67 |
68 | prevIndex = keyword.stop;
69 | }
70 |
71 | if (this._text.length > prevIndex) {
72 | results.push({ text: { start: prevIndex, stop: this._text.length } });
73 | }
74 |
75 | return results;
76 | }
77 |
78 | // Progress flag
79 | @computed
80 | get inProgress(): boolean { return this._inProgress; }
81 |
82 | // Document's display name
83 | @computed
84 | get name(): string { return this._searchResult.name; }
85 |
86 | // Document's coordinates
87 | @computed
88 | get coordinates(): number[] { return !!this._details && this._details[this._geoLocationFieldName]?.coordinates; }
89 |
90 | // All document's properties
91 | @computed
92 | get details(): any { return this._details; }
93 |
94 | // Search query split into words (for highlighting)
95 | readonly searchWords: string[];
96 |
97 | constructor(searchQuery: string, private _searchResult: SearchResult, private _geoLocationFieldName: string, transcriptFieldNames: string) {
98 | super();
99 |
100 | this.searchWords = this.extractSearchWords(searchQuery, this._searchResult);
101 |
102 | axios.get(`${BackendUri}/lookup/${_searchResult.key}`).then(lookupResponse => {
103 |
104 | this._details = lookupResponse.data;
105 |
106 | // Aggregating all document fields to display them in Transcript view
107 | this._text = this.collectAllTextFields(this._details, transcriptFieldNames);
108 |
109 | }, err => {
110 |
111 | this.ShowError(`Failed to load details. ${err}`);
112 |
113 | }).finally(() => {
114 | this._inProgress = false;
115 | });
116 | }
117 |
118 | // Returns a piece of text within specified boundaries
119 | getPieceOfText(interval: ITextInterval): string {
120 |
121 | const start = interval.start > 0 ? interval.start : 0;
122 | const stop = interval.stop > this._text.length ? this._text.length : interval.stop;
123 |
124 | return this._text.slice(start, stop);
125 | }
126 |
127 | @observable
128 | private _details: any;
129 |
130 | @observable
131 | private _inProgress: boolean = true;
132 |
133 | private _text: string = '';
134 |
135 | private extractSearchWords(searchQuery: string, searchResult: SearchResult): string[] {
136 |
137 | const results: string[] = [];
138 |
139 | // Also adding highlighted words returned by Cognitive Search, if any
140 | for (const highlightedWord of searchResult.highlightedWords) {
141 |
142 | if (!results.includes[highlightedWord]) {
143 | results.push(highlightedWord);
144 | }
145 | }
146 |
147 | // Skipping search query operators
148 | const queryOperators = ["and", "or"];
149 |
150 | const regex = /\w+/gi
151 | var match: RegExpExecArray | null;
152 | while (!!(match = regex.exec(searchQuery))) {
153 |
154 | const word = match[0];
155 | if (!queryOperators.includes(word.toLowerCase()) && !results.includes(word)) {
156 | results.push(word);
157 | }
158 | }
159 |
160 | return results;
161 | }
162 |
163 | private collectAllTextFields(details: any, transcriptFieldNames: string): string {
164 |
165 | var result = '';
166 |
167 | // If CognitiveSearchTranscriptFields is defined, then using it.
168 | // Otherwise just aggregating all fields that look like string.
169 | if (!transcriptFieldNames) {
170 |
171 | for (const fieldName in details) {
172 | const fieldValue = details[fieldName];
173 |
174 | if (typeof fieldValue === 'string' && !fieldValue.includes('$metadata#docs')) {
175 | result += fieldValue + '\n';
176 | }
177 | }
178 |
179 | } else {
180 |
181 | for (const fieldName of transcriptFieldNames.split(',')) {
182 | const fieldValue = details[fieldName];
183 |
184 | result += (typeof fieldValue === 'string' ? fieldValue : JSON.stringify(fieldValue)) + '\n';
185 | }
186 | }
187 |
188 | return result;
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/src/states/ErrorMessageState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 |
3 | // Base class for all states, that might display error messages
4 | export class ErrorMessageState {
5 |
6 | @computed
7 | get errorMessage(): string { return this._errorMessage; }
8 |
9 | HideError() {
10 | this._errorMessage = '';
11 | }
12 |
13 | protected ShowError(msg: string) {
14 | this._errorMessage = msg;
15 | }
16 |
17 | @observable
18 | private _errorMessage: string;
19 | }
20 |
--------------------------------------------------------------------------------
/src/states/FacetState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 |
3 | import { StringFacetState } from './StringFacetState'
4 | import { StringCollectionFacetState } from './StringCollectionFacetState'
5 | import { NumericFacetState } from './NumericFacetState'
6 | import { BooleanFacetState } from './BooleanFacetState'
7 | import { DateFacetState } from './DateFacetState'
8 | import { isArrayFieldName, extractFieldName } from './SearchResult';
9 |
10 | export enum FacetTypeEnum {
11 | StringFacet,
12 | StringCollectionFacet,
13 | NumericFacet,
14 | BooleanFacet,
15 | DateFacet
16 | }
17 |
18 | // State of each specific facet on the left
19 | export class FacetState {
20 |
21 | // State of facet values extracted into a separate object, to avail from polymorphism
22 | @computed
23 | get state(): StringFacetState | StringCollectionFacetState | NumericFacetState | BooleanFacetState | DateFacetState {
24 | return this._valuesState;
25 | };
26 |
27 | // Dynamically determined type of underlying facet field
28 | @computed
29 | get facetType(): FacetTypeEnum { return this._valuesState?.facetType; };
30 |
31 | // Whether the sidebar tab is currently expanded
32 | @observable
33 | isExpanded: boolean;
34 |
35 | get fieldName(): string { return this._fieldName; }
36 | get displayName(): string { return this._fieldName; }
37 |
38 | constructor(
39 | private _onChanged: () => void,
40 | fieldName: string,
41 | isInitiallyExpanded: boolean) {
42 |
43 | this._isArrayField = isArrayFieldName(fieldName);
44 | this._fieldName = extractFieldName(fieldName);
45 | this.isExpanded = isInitiallyExpanded;
46 | }
47 |
48 | // Dynamically creates the values state object from the search result
49 | populateFacetValues(facetValues: { value: string | number | boolean, count: number }[], fieldValue: any, filterClause: string) {
50 |
51 | this._valuesState = null;
52 | if (!facetValues.length) {
53 | return;
54 | }
55 |
56 | // Dynamically detecting facet field type by analyzing first non-empty value
57 | const firstFacetValue = facetValues.map(v => v.value).find(v => v !== null && v !== undefined );
58 |
59 | if (typeof firstFacetValue === 'boolean') {
60 |
61 | // If this is a boolean facet
62 | this._valuesState = new BooleanFacetState(this._onChanged, this.fieldName);
63 |
64 | }
65 | else if (typeof firstFacetValue === 'number') {
66 |
67 | // If this is a numeric facet
68 | this._valuesState = new NumericFacetState(this._onChanged, this.fieldName);
69 |
70 | } else if (FacetState.JsonDateRegex.test(firstFacetValue)) {
71 |
72 | // If this looks like a Date facet
73 | this._valuesState = new DateFacetState(this._onChanged, this.fieldName);
74 |
75 | } else if (this._isArrayField || (!!fieldValue && fieldValue.constructor === Array)) {
76 |
77 | // If this is a field containing arrays of strings
78 | this._valuesState = new StringCollectionFacetState(this._onChanged, this.fieldName);
79 |
80 | } else {
81 |
82 | //If this is a plain string field
83 | this._valuesState = new StringFacetState(this._onChanged, this.fieldName);
84 | }
85 |
86 | this._valuesState.populateFacetValues(facetValues as any, filterClause);
87 | }
88 |
89 | // Updates number of occurences for each value in the facet
90 | updateFacetValueCounts(facetValues: { value: string | number, count: number }[]) {
91 | this._valuesState?.updateFacetValueCounts(facetValues as any);
92 | }
93 |
94 | // Formats the $filter expression out of currently selected facet values
95 | getFilterExpression(): string {
96 | return this._valuesState?.getFilterExpression();
97 | }
98 |
99 | @observable
100 | private _valuesState: StringFacetState | StringCollectionFacetState | NumericFacetState | BooleanFacetState | DateFacetState;
101 |
102 | private readonly _fieldName: string;
103 | private readonly _isArrayField: boolean;
104 |
105 | private static JsonDateRegex = /^[0-9]{4}-[0-9]{2}-[0-9]{2}/;
106 | }
--------------------------------------------------------------------------------
/src/states/FacetValueState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 |
3 | export const MaxFacetValueLength = 50;
4 |
5 | // State of each specific facet value on the left
6 | export class FacetValueState {
7 |
8 | @computed
9 | get isSelected(): boolean { return this._isSelected; }
10 | set isSelected(val: boolean) {
11 | this._isSelected = val;
12 | this._onChanged();
13 | }
14 |
15 | unsetSilently() {
16 | this._isSelected = false;
17 | }
18 |
19 | constructor(readonly value: string, readonly count: number, private _onChanged: () => void, isSelected: boolean = false) {
20 | this._isSelected = isSelected;
21 | }
22 |
23 | @observable
24 | private _isSelected: boolean = false;
25 | }
26 |
27 | // Checks if a facet value looks pretty
28 | export function isValidFacetValue(value: string): boolean {
29 |
30 | // Filtering out garbage
31 | return (value.length < MaxFacetValueLength)
32 | && (!/ {2}|\n|\t/.test(value))
33 | }
34 |
35 | // Need to deal with special characters and replace one single quote with two single quotes
36 | export function encodeFacetValue(v: string): string {
37 | return encodeURIComponent(v.replace('\'', '\'\''));
38 | }
39 |
40 | // Need to deal with special characters and replace one single quote with two single quotes
41 | export function decodeFacetValue(v: string): string {
42 | return decodeURIComponent(v).replace('\'\'', '\'');
43 | }
44 |
--------------------------------------------------------------------------------
/src/states/FacetsState.ts:
--------------------------------------------------------------------------------
1 | import * as atlas from 'azure-maps-control';
2 |
3 | import { FacetState, FacetTypeEnum } from './FacetState';
4 | import { StringCollectionFacetState } from './StringCollectionFacetState';
5 | import { IServerSideConfig } from './ServerSideConfig';
6 |
7 | export const MaxFacetValues = 500;
8 |
9 | // State of facets on the left
10 | export class FacetsState {
11 |
12 | // Facets to be displayed on the left
13 | get facets(): FacetState[] { return this._facets; }
14 |
15 | // Bounding box for geo filtering
16 | get geoRegion(): atlas.data.BoundingBox { return this._geoRegion; }
17 | set geoRegion(r: atlas.data.BoundingBox) {
18 | this._geoRegion = r;
19 | this._onChanged();
20 | }
21 |
22 | constructor(private _onChanged: () => void, private _config: IServerSideConfig) {
23 | // Dynamically creating the facet states out of config settings
24 | this.createFacetStates();
25 | }
26 |
27 | // Expands this facet and collapses all others.
28 | toggleExpand(facetName: string) {
29 |
30 | const selectedFacet = this._facets.find(f => f.fieldName === facetName);
31 |
32 | if (!!selectedFacet.isExpanded) {
33 | selectedFacet.isExpanded = false;
34 | return;
35 | }
36 |
37 | for (const facet of this._facets) {
38 | facet.isExpanded = false;
39 | }
40 | selectedFacet.isExpanded = true;
41 | }
42 |
43 | // Fills facets with values returned by Cognitive Search
44 | populateFacetValues(facetResults: any, firstSearchResult: any, filterClause: string) {
45 |
46 | this._geoRegion = this.parseGeoFilterExpression(filterClause);
47 |
48 | for (const facetState of this._facets) {
49 |
50 | const facetValues = facetResults[facetState.fieldName];
51 | const fieldValue = firstSearchResult[facetState.fieldName];
52 |
53 | facetState.populateFacetValues(!!facetValues ? facetValues : [], fieldValue, filterClause);
54 | }
55 | }
56 |
57 | // Updates counters for facet values
58 | updateFacetValueCounts(facetResults: any) {
59 |
60 | for (const facetState of this._facets) {
61 |
62 | const facetValues = facetResults[facetState.fieldName];
63 | if (!!facetValues) {
64 | facetState.updateFacetValueCounts(facetValues);
65 | }
66 | }
67 | }
68 |
69 | // Constructs $filter clause for a search request
70 | getFilterExpression(): string {
71 |
72 | const filterExpressions = this._facets
73 | .map(f => f.getFilterExpression())
74 | .concat(this.getGeoFilterExpression())
75 | .filter(f => (!!f));
76 |
77 | return !!filterExpressions.length ? `&$filter=${filterExpressions.join(' and ')}` : '';
78 | }
79 |
80 | // Selects a value in the specified facet
81 | filterBy(fieldName: string, fieldValue: string) {
82 |
83 | const facet = this._facets.find(f => f.fieldName === fieldName);
84 | if (!facet || facet.facetType !== FacetTypeEnum.StringCollectionFacet ) {
85 | return;
86 | }
87 |
88 | const stringCollectionFacet = facet.state as StringCollectionFacetState;
89 |
90 | stringCollectionFacet.values.forEach(v => {
91 | if (v.value === fieldValue) {
92 | v.isSelected = true;
93 | }
94 | });
95 | }
96 |
97 | private _facets: FacetState[] = [];
98 | private _geoRegion: atlas.data.BoundingBox;
99 |
100 | // Dynamically generates facets from 'CognitiveSearchFacetFields' config parameter
101 | private createFacetStates() {
102 |
103 | const facetFields = this._config.CognitiveSearchFacetFields.split(',').filter(f => !!f);
104 |
105 | // Leaving the first facet expanded and all others collapsed
106 | var isFirstFacet = true;
107 |
108 | for (var facetField of facetFields) {
109 | this._facets.push(new FacetState(this._onChanged, facetField, isFirstFacet));
110 | isFirstFacet = false;
111 | }
112 | }
113 |
114 | private getGeoFilterExpression(): string {
115 |
116 | if (!this._geoRegion) {
117 | return '';
118 | }
119 |
120 | const topLeft = atlas.data.BoundingBox.getNorthWest(this._geoRegion);
121 | const bottomLeft = atlas.data.BoundingBox.getSouthWest(this._geoRegion);
122 | const bottomRight = atlas.data.BoundingBox.getSouthEast(this._geoRegion);
123 | const topRight = atlas.data.BoundingBox.getNorthEast(this._geoRegion);
124 |
125 | const points = `${topLeft[0]} ${topLeft[1]}, ${bottomLeft[0]} ${bottomLeft[1]}, ${bottomRight[0]} ${bottomRight[1]}, ${topRight[0]} ${topRight[1]}, ${topLeft[0]} ${topLeft[1]}`;
126 | return `geo.intersects(${this._config.CognitiveSearchGeoLocationField},geography'POLYGON((${points}))')`;
127 | }
128 |
129 | private parseGeoFilterExpression(filterClause: string): atlas.data.BoundingBox {
130 |
131 | if (!filterClause) {
132 | return null;
133 | }
134 |
135 | const regex = new RegExp(`geo.intersects\\(${this._config.CognitiveSearchGeoLocationField},geography'POLYGON\\(\\(([0-9\\., -]+)\\)\\)'\\)`, 'gi');
136 | const match = regex.exec(filterClause);
137 | if (!match) {
138 | return null;
139 | }
140 |
141 | const positions = match[1].split(',').slice(0, 4).map(s => s.split(' ').filter(s => !!s));
142 | if (positions.length < 4) {
143 | return null;
144 | }
145 |
146 | const bottomLeft = positions[1].map(s => Number(s));
147 | const topRight = positions[3].map(s => Number(s));
148 |
149 | const boundingBox = new atlas.data.BoundingBox(bottomLeft, topRight);
150 | return boundingBox;
151 | }
152 | }
--------------------------------------------------------------------------------
/src/states/LoginState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 | import axios from 'axios';
3 |
4 | const BackendUri = process.env.REACT_APP_BACKEND_BASE_URI as string;
5 |
6 | // Handles login stuff
7 | export class LoginState {
8 |
9 | // Currently logged in user's name
10 | @computed
11 | get userName(): string { return this._userName; }
12 |
13 | // Whether there was a login or not
14 | @computed
15 | get isLoggedInAnonymously(): boolean { return !this._userName; };
16 |
17 | // Needed to anchor popup menu to
18 | @observable
19 | menuAnchorElement?: Element;
20 |
21 | constructor() {
22 | this.initializeAuth();
23 | }
24 |
25 | // Redirects user to EasyAuth's logout endpoint (so that they can choose a different login)
26 | logout() {
27 | this.menuAnchorElement = undefined;
28 | window.location.href = `/.auth/logout`
29 | }
30 |
31 | @observable
32 | private _userName: string;
33 |
34 | private initializeAuth(): void {
35 |
36 | // Auth cookies do expire. Here is a primitive way to forcibly re-authenticate the user
37 | // (by refreshing the page), if that ever happens during an API call.
38 | axios.interceptors.response.use(response => response, err => {
39 |
40 | // This is what happens when an /api call fails because of expired/non-existend auth cookie
41 | if (err.message === 'Network Error' && !!err.config && (err.config.url as string).startsWith(BackendUri) ) {
42 | window.location.reload(true);
43 | return;
44 | }
45 |
46 | return Promise.reject(err);
47 | });
48 |
49 | // Trying to obtain user info, as described here: https://docs.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=javascript
50 | axios.get(`/.auth/me`).then(result => {
51 | this._userName = result.data?.clientPrincipal?.userDetails;
52 | });
53 | }
54 | }
--------------------------------------------------------------------------------
/src/states/MapResultsState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 | import axios from 'axios';
3 | import * as atlas from 'azure-maps-control';
4 |
5 | import { ErrorMessageState } from './ErrorMessageState';
6 | import { SearchResult } from './SearchResult';
7 | import { MaxFacetValues } from './FacetsState';
8 | import { IServerSideConfig } from './ServerSideConfig';
9 |
10 | const MapPageSize = 500;
11 | const MaxMapResults = 5000;
12 |
13 | const MapInitialCoordinates: atlas.data.Position[] = [[-115, 50], [-95, 20]];
14 |
15 | // State of search results shown on a map
16 | export class MapResultsState extends ErrorMessageState {
17 |
18 | // Progress flag
19 | @computed
20 | get inProgress(): boolean { return this._inProgress; }
21 |
22 | // Number of results fetched so far
23 | @computed
24 | get resultsShown(): number { return this._resultsShown; }
25 |
26 | // Azure Maps DataSource object
27 | get mapDataSource(): atlas.source.DataSource { return this._mapDataSource; }
28 |
29 | @observable
30 | mapBounds: atlas.data.BoundingBox = atlas.data.BoundingBox.fromPositions(MapInitialCoordinates);
31 |
32 | constructor(readonly showDetails: (r: SearchResult) => void, private _config: IServerSideConfig) {
33 | super();
34 | }
35 |
36 | // Proceed with search
37 | loadResults(searchUrl: string) {
38 |
39 | this.HideError();
40 | this._totalResults = 0;
41 | this._resultsLoaded = 0;
42 | this._mapDataSource.clear();
43 | this._collectedCoordinates = [];
44 | this._resultsShown = 0;
45 | this._inProgress = true;
46 |
47 | this.loadMoreResults(searchUrl);
48 | }
49 |
50 | private loadMoreResults(searchUrl: string) {
51 |
52 | const fields = `${this._config.CognitiveSearchKeyField},${this._config.CognitiveSearchNameField},${this._config.CognitiveSearchGeoLocationField}`;
53 | const uri = `${searchUrl}&$select=${fields}&$top=${MapPageSize}&$skip=${this._resultsLoaded}`;
54 |
55 | axios.get(uri).then(response => {
56 |
57 | this._totalResults = Math.min(MaxMapResults, response.data['@odata.count']);
58 | const results = response.data.value;
59 |
60 | for (const rawResult of results) {
61 |
62 | const result = new SearchResult(rawResult, this._config);
63 |
64 | if (!result.coordinates || !result.key) {
65 | continue;
66 | }
67 |
68 | // Not showing more than what is shown in facets
69 | if (this._resultsShown >= MaxFacetValues) {
70 | break;
71 | }
72 |
73 | this._mapDataSource.add(new atlas.data.Feature(
74 | new atlas.data.Point(result.coordinates),
75 | result));
76 |
77 | this._collectedCoordinates.push(result.coordinates);
78 | this._resultsShown++;
79 | }
80 | this._resultsLoaded += results.length;
81 |
82 | if (!results.length || this._resultsLoaded >= this._totalResults || this._resultsShown >= MaxFacetValues) {
83 |
84 | this._inProgress = false;
85 |
86 | // Causing the map to be zoomed to this bounding box
87 | this.mapBounds = atlas.data.BoundingBox.fromPositions(this._collectedCoordinates);
88 |
89 | } else {
90 |
91 | // Keep loading until no more found or until we reach the limit
92 | this.loadMoreResults(searchUrl);
93 | }
94 |
95 | }, (err) => {
96 |
97 | this.ShowError(`Loading map results failed. ${err}`);
98 | this._inProgress = false;
99 | });
100 | }
101 |
102 | @observable
103 | private _inProgress: boolean = false;
104 |
105 | @observable
106 | private _resultsShown: number = 0;
107 |
108 | private _totalResults: number = 0;
109 | private _resultsLoaded: number = 0;
110 |
111 | private _mapDataSource = new atlas.source.DataSource();
112 |
113 | // Also storing collected coordinates, to eventually zoom the map to
114 | private _collectedCoordinates: atlas.data.Position[] = [];
115 | }
--------------------------------------------------------------------------------
/src/states/NumericFacetState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 |
3 | import { FacetTypeEnum } from './FacetState'
4 |
5 | // Facet for a numeric field
6 | export class NumericFacetState {
7 |
8 | readonly facetType: FacetTypeEnum = FacetTypeEnum.NumericFacet;
9 |
10 | @computed
11 | get values(): number[] { return this._values; };
12 |
13 | @computed
14 | get minValue(): number { return this._minValue; };
15 |
16 | @computed
17 | get maxValue(): number { return this._maxValue; };
18 |
19 | @observable
20 | range: number[] = [0, 0];
21 |
22 | @computed
23 | get isApplied(): boolean {
24 |
25 | return this.range[0] !== this._minValue || this.range[1] !== this._maxValue;
26 | }
27 |
28 | constructor(
29 | private _onChanged: () => void, readonly fieldName: string) {
30 | }
31 |
32 | apply(): void {
33 | this._onChanged();
34 | }
35 |
36 | reset(): void {
37 | this.range = [this._minValue, this._maxValue];
38 | this._onChanged();
39 | }
40 |
41 | populateFacetValues(facetValues: { value: number, count: number }[], filterClause: string) {
42 |
43 | this._values = facetValues.map(fv => fv.value as number);
44 | this._minValue = Math.min(...this._values);
45 | this._maxValue = Math.max(...this._values);
46 |
47 | // If there was a $filter expression in the URL, then parsing and applying it
48 | var numericRange = this.parseFilterExpression(filterClause);
49 |
50 | if (!numericRange) {
51 | numericRange = [this._minValue, this._maxValue];
52 | }
53 |
54 | this.range = numericRange;
55 | }
56 |
57 | updateFacetValueCounts(facetValues: { value: number, count: number }[]) {
58 | // doing nothing for now
59 | }
60 |
61 | getFilterExpression(): string {
62 |
63 | if (!this.isApplied) {
64 | return '';
65 | }
66 |
67 | return `${this.fieldName} ge ${this.range[0]} and ${this.fieldName} le ${this.range[1]}`;
68 | }
69 |
70 | @observable
71 | private _values: number[] = [];
72 | @observable
73 | private _minValue: number;
74 | @observable
75 | private _maxValue: number;
76 |
77 | private parseFilterExpression(filterClause: string): number[] {
78 |
79 | if (!filterClause) {
80 | return null;
81 | }
82 |
83 | const regex = new RegExp(`${this.fieldName} ge ([0-9.]+) and ${this.fieldName} le ([0-9.]+)`, 'gi');
84 | const match = regex.exec(filterClause);
85 | return !match ? null : [Number(match[1]), Number(match[2])];
86 | }
87 | }
--------------------------------------------------------------------------------
/src/states/SearchResult.ts:
--------------------------------------------------------------------------------
1 | import { isValidFacetValue } from './FacetValueState';
2 | import { IServerSideConfig } from './ServerSideConfig';
3 |
4 | // Maps raw search results.
5 | export class SearchResult {
6 |
7 | readonly key: string;
8 | readonly name: string;
9 | readonly keywordsFieldName: string;
10 | readonly keywords: string[] = [];
11 | readonly coordinates: number[];
12 | readonly otherFields: string[] = [];
13 | readonly highlightedWords: string[] = [];
14 |
15 | constructor(rawResult: any, private _config: IServerSideConfig) {
16 |
17 | this.key = rawResult[this._config.CognitiveSearchKeyField];
18 | this.coordinates = this.extractCoordinates(rawResult);
19 | this.highlightedWords = this.extractHighlightedWords(rawResult);
20 |
21 | this.name = this._config.CognitiveSearchNameField
22 | .split(',')
23 | .map(fieldName => rawResult[fieldName])
24 | .join(',');
25 |
26 | // Collecting other fields
27 | for (var fieldName of this._config.CognitiveSearchOtherFields.split(',').filter(f => !!f)) {
28 |
29 | const fieldValue = rawResult[fieldName];
30 |
31 | if (!fieldValue) {
32 | continue;
33 | }
34 |
35 | // If the field contains an array, then treating it as a list of keywords
36 | if (fieldValue.constructor === Array) {
37 | this.keywordsFieldName = extractFieldName(fieldName);
38 | this.keywords = fieldValue
39 | .filter(isValidFacetValue)
40 | .filter((val, index, self) => self.indexOf(val) === index); // getting distinct values
41 | continue;
42 | }
43 |
44 | // otherwise collecting all other fields into a dictionary
45 | this.otherFields.push(fieldValue.toString());
46 | }
47 | }
48 |
49 | // Extracts coordinates by just treating the very first array-type field as an array of coordinates
50 | private extractCoordinates(rawResult: any): number[] {
51 |
52 | const coordinatesValue = rawResult[this._config.CognitiveSearchGeoLocationField];
53 | if (!!coordinatesValue && coordinatesValue.constructor === Array) {
54 | return coordinatesValue;
55 | }
56 |
57 | for (const fieldName in coordinatesValue) {
58 | const fieldValue = coordinatesValue[fieldName];
59 |
60 | if (!!fieldValue && fieldValue.constructor === Array) {
61 | return fieldValue;
62 | }
63 | }
64 |
65 | return null;
66 | }
67 |
68 | // Tries to extract highlighted words from the @search.highlights field returned by Cognitive Search (if returned)
69 | private extractHighlightedWords(rawResult: any): string[] {
70 |
71 | var result: string[] = [];
72 |
73 | const searchHighlights = rawResult['@search.highlights'];
74 | if (!searchHighlights) {
75 | return result;
76 | }
77 |
78 | for (const fieldName in searchHighlights) {
79 | const highlightsArray = searchHighlights[fieldName] as string[];
80 |
81 | for (const highlightString of highlightsArray) {
82 |
83 | const regex = /([^]+)<\/em>/gi;
84 | var match: RegExpExecArray | null;
85 | while (!!(match = regex.exec(highlightString))) {
86 | result.push(match[1]);
87 | }
88 | }
89 | }
90 |
91 | return result;
92 | }
93 | }
94 |
95 | // Checks whether this field name represents an array-type field (those field names are expected to have a trailing star)
96 | export function isArrayFieldName(fieldName: string): boolean {
97 | return fieldName.endsWith('*');
98 | }
99 |
100 | // Removes trailing star (if any) from a field name
101 | export function extractFieldName(str: string): string {
102 | return str.endsWith('*') ? str.substr(0, str.length - 1) : str;
103 | }
104 |
--------------------------------------------------------------------------------
/src/states/SearchResultsState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 | import axios from 'axios';
3 |
4 | import { ErrorMessageState } from './ErrorMessageState';
5 | import { FacetsState, MaxFacetValues } from './FacetsState';
6 | import { SearchResult } from './SearchResult';
7 | import { IServerSideConfig } from './ServerSideConfig';
8 |
9 | const BackendUri = process.env.REACT_APP_BACKEND_BASE_URI as string;
10 |
11 | const PageSize = 30;
12 |
13 | // State of the list of search results
14 | export class SearchResultsState extends ErrorMessageState {
15 |
16 | // String to search for
17 | @computed
18 | get searchString(): string {
19 | return this._searchString;
20 | }
21 | set searchString(s: string) {
22 | this._searchString = s;
23 | this.reloadSuggestions();
24 | }
25 |
26 | // Search suggestions
27 | @computed
28 | get suggestions(): string[] {
29 | return this._suggestions;
30 | }
31 |
32 | // Need to empty the suggestions list, once the user typed an exact match, to make the Autocomplete component work smoother.
33 | @computed
34 | get isExactMatch(): boolean {
35 | return this._suggestions.length === 1 && this._suggestions[0] === this._searchString;
36 | }
37 |
38 | // Results loaded so far
39 | @observable
40 | searchResults: SearchResult[] = [];
41 |
42 | // When page is just loaded, returns true. Later on returns false. Used to show a landing page.
43 | @computed
44 | get isInInitialState(): boolean { return this._isInInitialState; }
45 |
46 | // Progress flag
47 | @computed
48 | get inProgress(): boolean { return this._inProgress; }
49 |
50 | // Total number of documents matching the current query
51 | @computed
52 | get totalResults(): number { return this._totalResults; }
53 |
54 | // State of facets on the left
55 | get facetsState(): FacetsState { return this._facetsState; }
56 |
57 | constructor(readonly showDetails: (r: SearchResult) => void, private loadMapResults: (s) => void, private _config: IServerSideConfig) {
58 | super();
59 | this.initializeWindowOnPopState();
60 | }
61 |
62 | // Proceed with search
63 | search(filterClauseFromQueryString: string = null) {
64 |
65 | if (this._inProgress) {
66 | return;
67 | }
68 |
69 | // Cleaning up suggestions
70 | this._suggestions = [];
71 |
72 | // Moving from the initial landing page
73 | this._isInInitialState = false;
74 |
75 | // Resetting the facets tree
76 | this._facetsState.populateFacetValues({}, {}, null);
77 |
78 | // Caching $filter clause, that came from URL, if any. We will apply it later on.
79 | this._filterClauseFromQueryString = filterClauseFromQueryString;
80 |
81 | this.reloadResults(true);
82 | }
83 |
84 | // Try loading the next page of results
85 | loadMoreResults(isInitialSearch: boolean = false) {
86 |
87 | if (this._inProgress || this._allResultsLoaded) {
88 | return;
89 | }
90 |
91 | const facetsClause = this._facetsState.facets.map(f => `facet=${f.fieldName},count:${MaxFacetValues}`).join('&');
92 | const fields = `${this._config.CognitiveSearchKeyField},${this._config.CognitiveSearchNameField},${this._config.CognitiveSearchOtherFields}`;
93 |
94 | // Asking for @search.highlights field to extract fuzzy search keywords from. But only if CognitiveSearchTranscriptFields setting is defined.
95 | const highlightClause = !this._config.CognitiveSearchTranscriptFields ? `` : `&highlight=${this._config.CognitiveSearchTranscriptFields}`;
96 |
97 | const uri = `${BackendUri}${this.searchClauseAndQueryType}${this._filterClause}&${facetsClause}&$select=${fields}${highlightClause}&$top=${PageSize}&$skip=${this.searchResults.length}`;
98 |
99 | this._inProgress = true;
100 | axios.get(uri).then(response => {
101 |
102 | this._totalResults = response.data['@odata.count'];
103 |
104 | const facetValues = response.data['@search.facets'];
105 | const firstSearchResult = !!response.data.value ? (response.data.value[0] ?? {}) : {};
106 |
107 | if (!!isInitialSearch) {
108 |
109 | // Only re-populating facets after Search button has actually been clicked
110 | this._facetsState.populateFacetValues(facetValues, firstSearchResult, this._filterClauseFromQueryString);
111 |
112 | if (!!this._filterClauseFromQueryString) {
113 | this._filterClauseFromQueryString = null;
114 |
115 | // Causing the previous query to cancel and triggering a new query, now with $filter clause applied.
116 | // Yes, this is a bit more slowly, but we need the first query to be without $filter clause, because
117 | // we need to have full set of facet values loaded.
118 | this._inProgress = false;
119 | this.reloadResults(false);
120 | return;
121 | }
122 |
123 | } else {
124 | // Otherwise only updating counters for each facet value
125 | this._facetsState.updateFacetValueCounts(facetValues);
126 | }
127 |
128 | const results: SearchResult[] = response.data.value?.map(r => new SearchResult(r, this._config));
129 |
130 | if (!results || !results.length) {
131 | this._allResultsLoaded = true;
132 | } else {
133 | this.searchResults.push(...results);
134 | }
135 |
136 | this._inProgress = false;
137 | }, (err) => {
138 |
139 | this.ShowError(`Loading search results failed. ${err}`);
140 | this._allResultsLoaded = true;
141 | this._inProgress = false;
142 |
143 | });
144 | }
145 |
146 | private get searchClause(): string { return `?search=${this._searchString}`; }
147 | private get searchClauseAndQueryType(): string { return `/search${this.searchClause}&$count=true&queryType=full`; }
148 |
149 | @observable
150 | private _searchString: string = '';
151 |
152 | @observable
153 | private _suggestions: string[] = [];
154 |
155 | @observable
156 | private _isInInitialState: boolean = true;
157 |
158 | @observable
159 | private _inProgress: boolean = false;
160 |
161 | @observable
162 | private _totalResults: number = 0;
163 |
164 | private _facetsState = new FacetsState(() => this.reloadResults(false), this._config);
165 |
166 | private _filterClause: string = '';
167 | private _filterClauseFromQueryString: string = null;
168 | private _doPushState: boolean = true;
169 | private _allResultsLoaded: boolean = false;
170 |
171 | private reloadResults(isInitialSearch: boolean) {
172 |
173 | this.HideError();
174 | this.searchResults = [];
175 | this._totalResults = 0;
176 | this._allResultsLoaded = false;
177 |
178 | this._filterClause = this._facetsState.getFilterExpression();
179 |
180 | this.loadMoreResults(isInitialSearch);
181 |
182 | // Triggering map results to be loaded only if we're currently not handling an incoming query string.
183 | // When handling an incoming query string, the search query will be submitted twice, and we'll reload the map
184 | // during the second try.
185 | if (!!this._filterClauseFromQueryString) {
186 | return;
187 | }
188 |
189 | if (!!this.loadMapResults) {
190 | this.loadMapResults(BackendUri + this.searchClauseAndQueryType + this._filterClause);
191 | }
192 |
193 | // Placing the search query into browser's address bar, to enable Back button and URL sharing
194 | this.pushStateWhenNeeded();
195 | }
196 |
197 | private pushStateWhenNeeded() {
198 |
199 | if (this._doPushState) {
200 |
201 | const pushState = {
202 | query: this._searchString,
203 | filterClause: this._filterClause
204 | };
205 | window.history.pushState(pushState, '', this.searchClause + this._filterClause);
206 | }
207 | this._doPushState = true;
208 | }
209 |
210 | private initializeWindowOnPopState() {
211 |
212 | // Enabling Back arrow
213 | window.onpopstate = (evt: PopStateEvent) => {
214 |
215 | const pushState = evt.state;
216 |
217 | if (!pushState) {
218 | this._isInInitialState = true;
219 | return;
220 | }
221 |
222 | // When handling onPopState we shouldn't be re-pushing current URL into history
223 | this._doPushState = false;
224 | this.searchString = pushState.query;
225 | this.search(pushState.filterClause);
226 | }
227 | }
228 |
229 | // Reloads the list of suggestions, if CognitiveSearchSuggesterName is defined
230 | private reloadSuggestions(): void {
231 |
232 | if (!this._config.CognitiveSearchSuggesterName) {
233 | return;
234 | }
235 |
236 | if (!this._searchString) {
237 | this._suggestions = [];
238 | return;
239 | }
240 |
241 | const uri = `${BackendUri}/autocomplete?suggesterName=${this._config.CognitiveSearchSuggesterName}&fuzzy=true&search=${this._searchString}`;
242 | axios.get(uri).then(response => {
243 |
244 | if (!response.data || !response.data.value || !this._searchString) {
245 | this._suggestions = [];
246 | return;
247 | }
248 |
249 | this._suggestions = response.data.value.map(v => v.queryPlusText);
250 | });
251 | }
252 | }
--------------------------------------------------------------------------------
/src/states/ServerSideConfig.ts:
--------------------------------------------------------------------------------
1 |
2 | // This object is produced by a dedicated Functions Proxy and contains parameters
3 | // configured on the backend side. Backend produces it in form of a script, which is included into index.html.
4 | // Here we just assume that the object exists.
5 | declare const ServerSideConfig: IServerSideConfig;
6 |
7 | export interface IServerSideConfig {
8 | SearchServiceName: string;
9 | SearchIndexName: string;
10 | AzureMapSubscriptionKey: string;
11 | CognitiveSearchKeyField: string;
12 | CognitiveSearchNameField: string;
13 | CognitiveSearchGeoLocationField: string;
14 | CognitiveSearchOtherFields: string;
15 | CognitiveSearchTranscriptFields: string;
16 | CognitiveSearchFacetFields: string;
17 | CognitiveSearchSuggesterName: string;
18 | }
19 |
20 | // Produces a purified ServerSideConfig object
21 | export function GetServerSideConfig(): IServerSideConfig {
22 | const result = ServerSideConfig;
23 |
24 | for (const fieldName in result) {
25 | if (!isConfigSettingDefined(result[fieldName])) {
26 | result[fieldName] = null;
27 | }
28 | }
29 |
30 | return result;
31 | }
32 |
33 | // Checks if the value is defined in the backend's config settings
34 | function isConfigSettingDefined(value: string) {
35 | return !!value && !(
36 | value.startsWith('%') && value.endsWith('%') // if this parameter isn't specified in Config Settings, the proxy returns env variable name instead
37 | );
38 | }
--------------------------------------------------------------------------------
/src/states/StringCollectionFacetState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 |
3 | import { FacetTypeEnum } from './FacetState'
4 | import { FacetValueState, isValidFacetValue, encodeFacetValue, decodeFacetValue } from './FacetValueState'
5 |
6 | // Facet for a field containing an array of strings
7 | export class StringCollectionFacetState {
8 |
9 | readonly facetType: FacetTypeEnum = FacetTypeEnum.StringCollectionFacet;
10 |
11 | @computed
12 | get values(): FacetValueState[] { return this._values; };
13 |
14 | // Whether selected values should be combined with OR (false) or AND (true).
15 | // Only makes sense for array fields
16 | @computed
17 | get useAndOperator(): boolean { return this._useAndOperator; };
18 | set useAndOperator(val: boolean) {
19 | this._useAndOperator = val;
20 | this._onChanged();
21 | }
22 |
23 | @computed
24 | get selectedCount(): number {
25 | return this._values.filter(v => v.isSelected).length;
26 | }
27 |
28 | @computed
29 | get allSelected(): boolean {
30 | return this._values.every(v => !v.isSelected);
31 | }
32 | set allSelected(val: boolean) {
33 | for (const v of this._values) {
34 | v.unsetSilently();
35 | }
36 | this._useAndOperator = false;
37 | this._onChanged();
38 | }
39 |
40 | @computed
41 | get isApplied(): boolean {
42 | return !this.allSelected;
43 | }
44 |
45 | constructor(private _onChanged: () => void, readonly fieldName: string) {
46 | }
47 |
48 | populateFacetValues(facetValues: { value: string, count: number }[], filterClause: string) {
49 |
50 | this._valuesSet = {};
51 |
52 | // If there was a $filter expression in the URL, then parsing and applying it
53 | const parsedFilterClause = this.parseFilterExpression(filterClause);
54 |
55 | // Replacing the entire array, for faster rendering
56 | this._values = facetValues
57 | .filter(fv => isValidFacetValue(fv.value as string))
58 | .map(fv => {
59 |
60 | const facetValue = new FacetValueState(fv.value as string, fv.count, this._onChanged, !!parsedFilterClause.selectedValues[fv.value]);
61 | this._valuesSet[fv.value] = facetValue;
62 |
63 | return facetValue;
64 | });
65 |
66 | // Filter clause from query string can still contain some values, that were not returned by Cognitive Search.
67 | // So we have to add them explicitly as well.
68 | for (const fv in parsedFilterClause.selectedValues) {
69 |
70 | if (!!this._valuesSet[fv]) {
71 | continue;
72 | }
73 |
74 | const facetValue = new FacetValueState(fv, 1, this._onChanged, true);
75 | this._valuesSet[fv] = facetValue;
76 |
77 | this._values.push(facetValue);
78 | }
79 |
80 | this._useAndOperator = parsedFilterClause.useAndOperator;
81 | }
82 |
83 | updateFacetValueCounts(facetValues: { value: string, count: number }[]) {
84 |
85 | // converting array into a map, for faster lookup
86 | const valuesMap = facetValues.reduce((map: { [v: string]: number }, kw) => {
87 | map[kw.value] = kw.count;
88 | return map;
89 | }, {});
90 |
91 | // recreating the whole array, for faster rendering
92 | this._values = this._values.map(fv => {
93 |
94 | const count = valuesMap[fv.value];
95 |
96 | const facetValue = new FacetValueState(fv.value, !!count ? count : 0, this._onChanged, fv.isSelected);
97 |
98 | // Also storing this FacetValueState object in a set, for faster access
99 | this._valuesSet[fv.value] = facetValue;
100 |
101 | return facetValue;
102 | });
103 | }
104 |
105 | getFilterExpression(): string {
106 |
107 | const selectedValues = this.values.filter(v => v.isSelected).map(v => encodeFacetValue(v.value));
108 | if (selectedValues.length <= 0) {
109 | return '';
110 | }
111 |
112 | return this._useAndOperator ?
113 | selectedValues.map(v => `${this.fieldName}/any(f: search.in(f, '${v}', '|'))`).join(' and ') :
114 | `${this.fieldName}/any(f: search.in(f, '${selectedValues.join('|')}', '|'))`;
115 | }
116 |
117 | @observable
118 | private _values: FacetValueState[] = [];
119 |
120 | @observable
121 | private _useAndOperator: boolean;
122 |
123 | private _valuesSet: { [k: string]: FacetValueState } = {};
124 |
125 | private parseFilterExpression(filterClause: string): { selectedValues: { [v: string]: string }, useAndOperator: boolean } {
126 | const result = {
127 | selectedValues: {},
128 | useAndOperator: false
129 | };
130 |
131 | if (!filterClause) {
132 | return result;
133 | }
134 |
135 | const regex = new RegExp(`${this.fieldName}/any\\(f: search.in\\(f, '([^']+)', '\\|'\\)\\)( and )?`, 'gi');
136 | var match: RegExpExecArray | null;
137 | var matchesCount = 0;
138 | while (!!(match = regex.exec(filterClause))) {
139 | matchesCount++;
140 |
141 | const facetValues = match[1].split('|');
142 | for (const facetValue of facetValues.map(fv => decodeFacetValue(fv))) {
143 | result.selectedValues[facetValue] = facetValue;
144 | }
145 | }
146 |
147 | // if AND operator was used to combine selected values, then there should be at least two regex matches in the $filter clause
148 | result.useAndOperator = matchesCount > 1;
149 |
150 | return result;
151 | }
152 | }
--------------------------------------------------------------------------------
/src/states/StringFacetState.ts:
--------------------------------------------------------------------------------
1 | import { observable, computed } from 'mobx'
2 |
3 | import { FacetTypeEnum } from './FacetState'
4 | import { FacetValueState, isValidFacetValue, encodeFacetValue, decodeFacetValue } from './FacetValueState'
5 |
6 | // Facet for a plain string field
7 | export class StringFacetState {
8 |
9 | readonly facetType: FacetTypeEnum = FacetTypeEnum.StringFacet;
10 |
11 | @computed
12 | get values(): FacetValueState[] { return this._values; };
13 |
14 | @computed
15 | get selectedCount(): number {
16 | return this._values.filter(v => v.isSelected).length;
17 | }
18 |
19 | @computed
20 | get allSelected(): boolean {
21 | return this._values.every(v => !v.isSelected);
22 | }
23 | set allSelected(val: boolean) {
24 | for (const v of this._values) {
25 | v.unsetSilently();
26 | }
27 | this._onChanged();
28 | }
29 |
30 | @computed
31 | get isApplied(): boolean {
32 | return !this.allSelected;
33 | }
34 |
35 | constructor(private _onChanged: () => void, readonly fieldName: string) {
36 | }
37 |
38 | populateFacetValues(facetValues: { value: string, count: number }[], filterClause: string) {
39 |
40 | this._valuesSet = {};
41 |
42 | // If there was a $filter expression in the URL, then parsing and applying it
43 | const parsedFilterClause = this.parseFilterExpression(filterClause);
44 |
45 | // Replacing the entire array, for faster rendering
46 | this._values = facetValues
47 | .filter(fv => isValidFacetValue(fv.value as string))
48 | .map(fv => {
49 |
50 | const facetValue = new FacetValueState(fv.value as string, fv.count, this._onChanged, !!parsedFilterClause[fv.value]);
51 | this._valuesSet[fv.value] = facetValue;
52 |
53 | return facetValue;
54 | });
55 |
56 | // Filter clause from query string can still contain some values, that were not returned by Cognitive Search.
57 | // So we have to add them explicitly as well.
58 | for (const fv in parsedFilterClause) {
59 |
60 | if (!!this._valuesSet[fv]) {
61 | continue;
62 | }
63 |
64 | const facetValue = new FacetValueState(fv, 1, this._onChanged, true);
65 | this._valuesSet[fv] = facetValue;
66 |
67 | this._values.push(facetValue);
68 | }
69 | }
70 |
71 | updateFacetValueCounts(facetValues: { value: string, count: number }[]) {
72 |
73 | // converting array into a map, for faster lookup
74 | const valuesMap = facetValues.reduce((map: { [v: string]: number }, kw) => {
75 | map[kw.value] = kw.count;
76 | return map;
77 | }, {});
78 |
79 | // recreating the whole array, for faster rendering
80 | this._values = this._values.map(fv => {
81 |
82 | const count = valuesMap[fv.value];
83 |
84 | const facetValue = new FacetValueState(fv.value, !!count ? count : 0, this._onChanged, fv.isSelected);
85 |
86 | // Also storing this FacetValueState object in a set, for faster access
87 | this._valuesSet[fv.value] = facetValue;
88 |
89 | return facetValue;
90 | });
91 | }
92 |
93 | getFilterExpression(): string {
94 |
95 | const selectedValues = this.values.filter(v => v.isSelected).map(v => encodeFacetValue(v.value));
96 | if (selectedValues.length <= 0) {
97 | return '';
98 | }
99 |
100 | return `search.in(${this.fieldName}, '${selectedValues.join('|')}', '|')`;
101 | }
102 |
103 | @observable
104 | private _values: FacetValueState[] = [];
105 |
106 | private _valuesSet: { [k: string]: FacetValueState } = {};
107 |
108 | private parseFilterExpression(filterClause: string): { [v: string]: string } {
109 |
110 | const result = {};
111 | if (!filterClause) {
112 | return result;
113 | }
114 |
115 | const regex = new RegExp(`search.in\\(${this.fieldName}, '([^']+)', '\\|'\\)( and )?`, 'gi');
116 | var match: RegExpExecArray | null;
117 | while (!!(match = regex.exec(filterClause))) {
118 |
119 | const facetValues = match[1].split('|');
120 | for (const facetValue of facetValues.map(fv => decodeFacetValue(fv))) {
121 | result[facetValue] = facetValue;
122 | }
123 | }
124 |
125 | return result;
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/staticwebapp.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "routes": [
3 | {
4 | "route":"/*",
5 | "allowedRoles": ["anonymous"]
6 | }
7 | ],
8 |
9 | "responseOverrides": {
10 | "401": {
11 | "statusCode": 302,
12 | "redirect": "/.auth/login/aad"
13 | }
14 | }
15 | ,
16 |
17 | "platform": {
18 | "apiRuntime": "node:18"
19 | }
20 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": false,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react",
21 | "noUnusedLocals": false,
22 | "experimentalDecorators": true,
23 | "noFallthroughCasesInSwitch": true
24 | },
25 | "include": [
26 | "src"
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------