├── .devcontainer └── devcontainer.json ├── .env ├── .env.development.local ├── .github └── workflows │ └── azure-static-web-apps-calm-cliff-07e291203.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── api ├── .funcignore ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── autocomplete │ ├── function.json │ └── index.ts ├── config-script │ ├── function.json │ └── index.ts ├── host.json ├── lookup │ ├── function.json │ └── index.ts ├── package-lock.json ├── package.json ├── search │ ├── function.json │ └── index.ts └── tsconfig.json ├── arm-template.json ├── package-lock.json ├── package.json ├── public ├── favicon.png ├── index.html ├── robots.txt ├── screenshot1.png └── screenshot2.png ├── src ├── App.tsx ├── __test │ ├── ErrorMessageState.test.ts │ ├── LoginState.test.ts │ └── SearchResult.test.ts ├── components │ ├── BooleanFacet.tsx │ ├── DateFacet.tsx │ ├── DetailsDialog.tsx │ ├── DetailsDialogMap.tsx │ ├── Facets.tsx │ ├── FilterSummaryBox.tsx │ ├── LoginIcon.tsx │ ├── MetadataViewer.tsx │ ├── NumericFacet.tsx │ ├── SearchResults.tsx │ ├── SearchResultsMap.tsx │ ├── SearchTextBox.tsx │ ├── SimpleScaleBarControl.ts │ ├── StringCollectionFacet.tsx │ ├── StringFacet.tsx │ └── TranscriptViewer.tsx ├── index.css ├── index.tsx ├── logo.svg ├── react-app-env.d.ts └── states │ ├── AppState.ts │ ├── BooleanFacetState.ts │ ├── DateFacetState.ts │ ├── DetailsDialogState.ts │ ├── ErrorMessageState.ts │ ├── FacetState.ts │ ├── FacetValueState.ts │ ├── FacetsState.ts │ ├── LoginState.ts │ ├── MapResultsState.ts │ ├── NumericFacetState.ts │ ├── SearchResult.ts │ ├── SearchResultsState.ts │ ├── ServerSideConfig.ts │ ├── StringCollectionFacetState.ts │ └── StringFacetState.ts ├── staticwebapp.config.json └── tsconfig.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "customizations": { 3 | "vscode": { 4 | "extensions": [ 5 | "ms-azuretools.vscode-azurestaticwebapps", 6 | "ms-azuretools.vscode-azurefunctions", 7 | "ms-dotnettools.csharp" 8 | ] 9 | } 10 | }, 11 | "updateContentCommand": "npm i -g azure-functions-core-tools && npm i --legacy-peer-deps" 12 | } 13 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_BASE_URI=/api -------------------------------------------------------------------------------- /.env.development.local: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_BASE_URI=http://localhost:7071/api -------------------------------------------------------------------------------- /.github/workflows/azure-static-web-apps-calm-cliff-07e291203.yml: -------------------------------------------------------------------------------- 1 | name: Azure Static Web Apps CI/CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize, reopened, closed] 9 | branches: 10 | - master 11 | 12 | jobs: 13 | build_and_deploy_job: 14 | if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') 15 | runs-on: ubuntu-latest 16 | name: Build and Deploy Job 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | submodules: true 21 | lfs: false 22 | - name: Build And Deploy 23 | id: builddeploy 24 | uses: Azure/static-web-apps-deploy@v1 25 | with: 26 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_CALM_CLIFF_07E291203 }} 27 | repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) 28 | action: "upload" 29 | ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### 30 | # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig 31 | app_location: "/" # App source code path 32 | api_location: "api" # Api source code path - optional 33 | output_location: "build" # Built app content directory - optional 34 | ###### End of Repository/Build Configurations ###### 35 | 36 | close_pull_request_job: 37 | if: github.event_name == 'pull_request' && github.event.action == 'closed' 38 | runs-on: ubuntu-latest 39 | name: Close Pull Request Job 40 | steps: 41 | - name: Close Pull Request 42 | id: closepullrequest 43 | uses: Azure/static-web-apps-deploy@v1 44 | with: 45 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_CALM_CLIFF_07E291203 }} 46 | action: "close" 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Visual Studio 4 | .vs 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | 21 | # Normally this file should not be committed. 22 | # Exclude it once you start adding connection strings and other local parameters. 23 | #.env.development.local 24 | 25 | .env.test.local 26 | .env.production.local 27 | 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurestaticwebapps", 4 | "DurableFunctionsMonitor.az-func-as-a-graph" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Node Functions", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 9229, 9 | "preLaunchTask": "func: host start" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": "api", 3 | "azureFunctions.postDeployTask": "npm install (functions)", 4 | "azureFunctions.projectLanguage": "TypeScript", 5 | "azureFunctions.projectRuntime": "~3", 6 | "debug.internalConsoleOptions": "neverOpen", 7 | "azureFunctions.preDeployTask": "npm prune (functions)" 8 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "command": "host start", 7 | "problemMatcher": "$func-node-watch", 8 | "isBackground": true, 9 | "dependsOn": "npm build (functions)", 10 | "options": { 11 | "cwd": "${workspaceFolder}/api" 12 | } 13 | }, 14 | { 15 | "type": "shell", 16 | "label": "npm build (functions)", 17 | "command": "npm run build", 18 | "dependsOn": "npm install (functions)", 19 | "problemMatcher": "$tsc", 20 | "options": { 21 | "cwd": "${workspaceFolder}/api" 22 | } 23 | }, 24 | { 25 | "type": "shell", 26 | "label": "npm install (functions)", 27 | "command": "npm install", 28 | "options": { 29 | "cwd": "${workspaceFolder}/api" 30 | } 31 | }, 32 | { 33 | "type": "shell", 34 | "label": "npm prune (functions)", 35 | "command": "npm prune --production", 36 | "dependsOn": "npm build (functions)", 37 | "problemMatcher": [], 38 | "options": { 39 | "cwd": "${workspaceFolder}/api" 40 | } 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Konstantin Lepeshenkov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure AI Search Static Web Apps sample 2 | 3 | ![screenshot1](https://raw.githubusercontent.com/scale-tone/cognitive-search-static-web-apps-sample-ui/master/public/screenshot1.png) 4 | 5 | A simple sample UI for your [Azure AI Search](https://azure.microsoft.com/en-us/services/search/) index. Similar to [this official sample](https://github.com/Azure-Samples/azure-search-knowledge-mining/tree/master/02%20-%20Web%20UI%20Template), but is implemented as a [Azure Static Web App](https://docs.microsoft.com/en-us/azure/static-web-apps/) and built with React and TypeScript. Implements the so called *faceted search* user experience, when the user first enters their search phrase and then narrows down search resuls with facets on the left sidebar. 6 | 7 | [![Azure Static Web Apps CI/CD](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/actions/workflows/azure-static-web-apps-calm-cliff-07e291203.yml/badge.svg)](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/actions/workflows/azure-static-web-apps-calm-cliff-07e291203.yml) 8 | 9 | The [client part](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/tree/master/src) is a typical React-based SPA (Single-Page App) written in TypeScript with extensive use of [MobX](https://mobx.js.org/README.html) and [Material-UI](https://material-ui.com/). And it doesn't have a [backend](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/tree/master/api) as such, all requests to [Azure AI Search REST API](https://docs.microsoft.com/en-us/azure/search/search-query-overview) are transparently propagated through an Azure Function, that appends the Azure AI Search **api-key** to each request - so the **api-key** is not exposed to the clients. 10 | 11 | Queries made by user are also reflected in the browser's address bar. This serves three purposes: makes those links sharable, enables navigation history ("Back" and "Forward" buttons) and also helps you learn Azure AI Search REST API's query syntax. 12 | 13 | List of search results supports infinite scrolling. If your documents have geo coordinates attached, then search results are also visualized with an [Azure Maps](https://azure.microsoft.com/en-us/services/azure-maps/) control. Clicking on a search result produces the *Details* view, the *Trascript* tab of it highlights all occurences of your search phrase in the document and allows to navigate across them. 14 | 15 | ## Live demo 16 | 17 | https://calm-cliff-07e291203.5.azurestaticapps.net 18 | 19 | That deployment is pointed to [the official Azure AI Search Sample Data](https://docs.microsoft.com/en-us/samples/azure-samples/azure-search-sample-data/azure-search-sample-data/) index (some sample hotel info in there), which is publicly available. You could point your deployment to that one as well, but normally you would like to build your own index [as described here](https://docs.microsoft.com/en-us/azure/search/search-get-started-portal#step-1---start-the-import-data-wizard-and-create-a-data-source). 20 | 21 | ## Config settings 22 | 23 | This code requires the following settings to be provided. When running locally on your devbox, you configure them via your **/api/local.settings.json** file (you'll need to create this file first). After deploying to Azure you'll need to configure these values via your Static Web App's **Configuration** tab in Azure Portal. 24 | 25 | * **SearchServiceName** - the name of your Azure AI Search service instance, e.g. `azs-playground`. 26 | * **SearchIndexName** - the name of your search index, e.g. `hotels`. You can use your existing index if any, or you can create a sample index [as described here](https://docs.microsoft.com/en-us/azure/search/search-get-started-portal#step-1---start-the-import-data-wizard-and-create-a-data-source). 27 | * **SearchApiKey** - your Azure AI Search query **api-key**. Find it on your Azure AI Search service's *Keys* tab in Azure Portal. 28 | * **AzureMapSubscriptionKey** - (optional) a subscription key for your Azure Maps account (Azure Maps is used for visualizing geolocation data). Please, get your own key [as described here](https://docs.microsoft.com/en-us/azure/azure-maps/azure-maps-authentication). If not specified, the map will not be shown. 29 | 30 | * **CognitiveSearchKeyField** - name of the field in your search index, that uniquely identifies a document. E.g. `HotelId`. 31 | * **CognitiveSearchNameField** - name of the field in your search index, that contains a short title of a document. E.g. `HotelName`. You can also put a comma-delimited list of field names here. 32 | * **CognitiveSearchGeoLocationField** - (optional) name of the field in your search index, that contains geo coordinates for each document. E.g. `Location`. 33 | * **CognitiveSearchOtherFields** - comma-separated list of other fields to be shown on search result cards. E.g. `Tags,Description,Description_fr,Category,LastRenovationDate`. If you include an *array-type* field (a field that contains an array of values, like the **Tags** field in the sample **hotels** index), it will be shown as a list of clickable chips. 34 | * **CognitiveSearchFacetFields** - comma-separated list of fields to be shown as facets on the left sidebar. Please, append a trailing star ('\*') to the name of the field, if that field is an *array-type* field. E.g. `Tags*,Rating,Category,ParkingIncluded,LastRenovationDate`. NOTE: all fields mentioned here need to be *facetable* and *filterable*. 35 | * **CognitiveSearchSuggesterName** - (optional) name of the [autocomplete suggester](https://docs.microsoft.com/en-us/azure/search/index-add-suggesters) to be used. E.g. `sg`. Create and configure a suggester for your search index and put its name here - then the search query textbox will start showing suggestions as you type. 36 | * **CognitiveSearchTranscriptFields** - (optional) comma-separated list of fields to be shown on the *Transcript* tab of the *Details* dialog. E.g. `HotelName,Description,Description_fr`. The fields, that you specify in this setting need to be *searchable*, because they are also used to get the [hit highlights](https://docs.microsoft.com/en-us/azure/search/search-pagination-page-layout#hit-highlighting). If not specified, that tab will simply show all string-type fields and get hit highlights from the search string. 37 | 38 | ## How to run locally 39 | 40 | As per prerequisites, you will need: 41 | - [Node.js](https://nodejs.org/en). 42 | - [Azure Functions Core Tools](https://github.com/Azure/azure-functions-core-tools#installing) package installed **globally** (`npm i -g azure-functions-core-tools`). 43 | 44 | Clone this repo to your devbox, then in the **/api** folder create a **local.settings.json** file, which should look like this: 45 | ``` 46 | { 47 | "IsEncrypted": false, 48 | "Values": { 49 | "FUNCTIONS_WORKER_RUNTIME": "node", 50 | 51 | "SearchServiceName": "azs-playground", 52 | "SearchIndexName": "hotels", 53 | "SearchApiKey": "your-search-api-key", 54 | "AzureMapSubscriptionKey": "your-azure-map-subscription-key", 55 | 56 | "CognitiveSearchKeyField":"HotelId", 57 | "CognitiveSearchNameField": "HotelName", 58 | "CognitiveSearchGeoLocationField": "Location", 59 | "CognitiveSearchOtherFields": "Tags,Description,Description_fr,Category", 60 | "CognitiveSearchFacetFields": "Tags*,Rating,Category,ParkingIncluded,LastRenovationDate", 61 | "CognitiveSearchTranscriptFields": "HotelName,Description,Description_fr", 62 | "CognitiveSearchSuggesterName": "sg" 63 | }, 64 | "Host": { 65 | "CORS": "http://localhost:3000", 66 | "CORSCredentials": true 67 | } 68 | } 69 | ``` 70 | 71 | Then type the following from the root folder: 72 | ``` 73 | npm install 74 | npm run start-with-backend 75 | ``` 76 | 77 | The latter command also compiles and starts the /api project under the local 'http://localhost:7071/api' URL. 78 | 79 | If a browser window doesn't open automatically, then navigate to http://localhost:3000. 80 | 81 | ## How to deploy to Azure 82 | 83 | - **Fork** this repo (a fork is needed, because Static Web Apps deployment needs to have write access to the repo). 84 | - Make sure GitHub Actions are enabled for your newly forked repo: 85 | 86 | 87 | 88 | - Use this button: 89 | 90 | [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fscale-tone%2Fcognitive-search-static-web-apps-sample-ui%2Fmaster%2Farm-template.json) 91 | 92 | Alternative deployment methods are described [here](https://docs.microsoft.com/en-us/azure/static-web-apps/getting-started?tabs=react#create-a-static-web-app). 93 | But then you'll need to manually configure the above-described Application Settings via your Static Web App's **Configuration** tab in Azure Portal. The tab should then look like this: 94 | 95 | ![screenshot2](https://raw.githubusercontent.com/scale-tone/cognitive-search-static-web-apps-sample-ui/master/public/screenshot2.png) 96 | 97 | ## Authentication/Authorization 98 | 99 | By default there will be **no authentication** configured for your Static Web App instance, so anyone could potentially access it. You can then explicitly configure authentication/authorization rules [as described here](https://learn.microsoft.com/en-us/azure/static-web-apps/authentication-authorization). E.g. to force every user to authenticate with their Microsoft Account just replace `anonymous` with `authenticated` in [this section](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/blob/master/staticwebapp.config.json#L5) of `staticwebapp.config.json` file. Note though, that `authenticated` is a built-in role, which refers to anybody anyhow authenticated. To restrict the list of allowed users further, you will need to [define and assign your own custom roles and routes](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#routes). Also, when using Microsoft Accounts, you [might want to configure and use your own AAD application](https://learn.microsoft.com/en-us/azure/static-web-apps/authentication-custom?tabs=aad%2Cinvitations#configure-a-custom-identity-provider) (instead of the global default one). 100 | 101 | ## Implementation details 102 | 103 | Thanks to [MobX](https://mobx.js.org/README.html), this React app looks very much like a typical [MVVM](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) app. It has a [hierarchy of state objects](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/tree/master/src/states) (aka viewmodels) and a corresponding [hierarchy of pure (stateless) React components](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/tree/master/src/components) (aka views). These two hierarchies are welded together at the root level [here](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/blob/master/src/index.tsx#L11). For example, [here](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/blob/master/src/states/SearchResultsState.ts) is the state object, that represents the list of search results, and [here](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/blob/master/src/components/SearchResults.tsx) is its markup. 104 | 105 | [HTTP GET requests](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/blob/master/src/states/DetailsDialogState.ts#L102) to Azure AI Search REST API are made by means of [axios](https://www.npmjs.com/package/axios) and are transparently proxied through a [lightweight Azure Functions-based backend](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/tree/master/api) - this is where the **api-key** is being applied to them. 106 | 107 | The list of facets and their possible values on the left sidebar is [generated dynamically](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/blob/master/src/states/FacetsState.ts#L15), based on **CognitiveSearchFacetFields** config value and results returned by Azure AI Search. The type of each faceted field (and the way it needs to be visualized) is also detected dynamically [here](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/blob/master/src/states/FacetState.ts#L49). Multiple faceted field types is currently supported already, and more support is coming. 108 | 109 | User's authentication/authorization relies entirely on what [Azure Static Web Apps offer today](https://docs.microsoft.com/en-us/azure/static-web-apps/authentication-authorization), and that in turn relies on what is known as [EasyAuth server-directed login flow](https://github.com/cgillum/easyauth/wiki/Login#server-directed-login). That's why there're no any client-side JavaScript auth libraries and/or custom code for authenticating users. The only login-related code resides in [this state object](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/blob/master/src/states/LoginState.ts), and it only tries to fetch the current user's nickname, but for that code to work the authN/authZ rules need to be configured [as explained here](https://docs.microsoft.com/en-us/azure/static-web-apps/authentication-authorization). 110 | 111 | As usual with Azure Static Web Apps, deployment to Azure is done with GitHub Actions. There's a couple of workflows [in this folder](https://github.com/scale-tone/cognitive-search-static-web-apps-sample-ui/tree/master/.github/workflows), that are currently being used for deploying this repo to demo environments. A good idea would be to remove them from your copy after cloning/forking (so that they don't annoy you with failed runs). 112 | -------------------------------------------------------------------------------- /api/.funcignore: -------------------------------------------------------------------------------- 1 | *.js.map 2 | *.ts 3 | .git* 4 | .vscode 5 | local.settings.json 6 | test 7 | tsconfig.json -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | .env.test 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # nuxt.js build output 72 | .nuxt 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless/ 79 | 80 | # FuseBox cache 81 | .fusebox/ 82 | 83 | # DynamoDB Local files 84 | .dynamodb/ 85 | 86 | # TypeScript output 87 | dist 88 | out 89 | 90 | # Azure Functions artifacts 91 | bin 92 | obj 93 | appsettings.json 94 | local.settings.json -------------------------------------------------------------------------------- /api/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /api/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Node Functions", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 9229, 9 | "preLaunchTask": "func: host start" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /api/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": ".", 3 | "azureFunctions.postDeployTask": "npm install", 4 | "azureFunctions.projectLanguage": "TypeScript", 5 | "azureFunctions.projectRuntime": "~2", 6 | "debug.internalConsoleOptions": "neverOpen", 7 | "azureFunctions.preDeployTask": "npm prune" 8 | } -------------------------------------------------------------------------------- /api/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "command": "host start", 7 | "problemMatcher": "$func-watch", 8 | "isBackground": true, 9 | "dependsOn": "npm build" 10 | }, 11 | { 12 | "type": "shell", 13 | "label": "npm build", 14 | "command": "npm run build", 15 | "dependsOn": "npm install", 16 | "problemMatcher": "$tsc" 17 | }, 18 | { 19 | "type": "shell", 20 | "label": "npm install", 21 | "command": "npm install" 22 | }, 23 | { 24 | "type": "shell", 25 | "label": "npm prune", 26 | "command": "npm prune --production", 27 | "dependsOn": "npm build", 28 | "problemMatcher": [] 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /api/autocomplete/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ] 11 | }, 12 | { 13 | "type": "http", 14 | "direction": "out", 15 | "name": "res" 16 | } 17 | ], 18 | "scriptFile": "../dist/autocomplete/index.js" 19 | } -------------------------------------------------------------------------------- /api/autocomplete/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from "@azure/functions" 2 | import axios from 'axios'; 3 | 4 | const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { 5 | 6 | const searchApiUrl = `https://${process.env.SearchServiceName}.search.windows.net/indexes/${process.env.SearchIndexName}/docs/autocomplete?api-version=2019-05-06&`; 7 | const url = req.url.replace(/^http(s)?:\/\/[^/]+\/api\/autocomplete(\?)?/i, searchApiUrl); 8 | 9 | try { 10 | 11 | const response = await axios.get(url, { 12 | headers: { 13 | "api-key": process.env.SearchApiKey, 14 | } }); 15 | 16 | context.res = { 17 | status: response.status, 18 | body: response.data, 19 | }; 20 | 21 | } catch (err) { 22 | context.res = { 23 | status: err.response?.status ?? 500 24 | }; 25 | } 26 | }; 27 | 28 | export default httpTrigger; -------------------------------------------------------------------------------- /api/config-script/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/config-script/index.js" 20 | } -------------------------------------------------------------------------------- /api/config-script/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context } from "@azure/functions" 2 | 3 | const httpTrigger: AzureFunction = async function (context: Context): Promise { 4 | 5 | const serverSideConfig = { 6 | SearchServiceName: process.env.SearchServiceName, 7 | SearchIndexName: process.env.SearchIndexName, 8 | AzureMapSubscriptionKey: process.env.AzureMapSubscriptionKey, 9 | CognitiveSearchKeyField: process.env.CognitiveSearchKeyField, 10 | CognitiveSearchNameField: process.env.CognitiveSearchNameField, 11 | CognitiveSearchGeoLocationField: process.env.CognitiveSearchGeoLocationField, 12 | CognitiveSearchOtherFields: process.env.CognitiveSearchOtherFields, 13 | CognitiveSearchTranscriptFields: process.env.CognitiveSearchTranscriptFields, 14 | CognitiveSearchFacetFields: process.env.CognitiveSearchFacetFields, 15 | CognitiveSearchSuggesterName: process.env.CognitiveSearchSuggesterName 16 | }; 17 | 18 | context.res = { 19 | body: `const ServerSideConfig = ${JSON.stringify(serverSideConfig)}`, 20 | 21 | headers: { 22 | "Content-Type": "application/javascript; charset=UTF-8" 23 | } 24 | }; 25 | }; 26 | 27 | export default httpTrigger; -------------------------------------------------------------------------------- /api/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 13 | "version": "[3.*, 4.0.0)" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /api/lookup/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ], 11 | "route": "lookup/{key}" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/lookup/index.js" 20 | } -------------------------------------------------------------------------------- /api/lookup/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from "@azure/functions" 2 | import axios from 'axios'; 3 | 4 | const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { 5 | 6 | const searchApiUrl = `https://${process.env.SearchServiceName}.search.windows.net/indexes/${process.env.SearchIndexName}/docs`; 7 | const url = req.url.replace(/^http(s)?:\/\/[^/]+\/api\/lookup/i, searchApiUrl); 8 | 9 | try { 10 | 11 | const response = await axios.get(`${url}?api-version=2019-05-06`, { 12 | headers: { 13 | "api-key": process.env.SearchApiKey, 14 | } }); 15 | 16 | context.res = { 17 | status: response.status, 18 | body: response.data, 19 | }; 20 | 21 | } catch (err) { 22 | context.res = { 23 | status: err.response?.status ?? 500 24 | }; 25 | } 26 | }; 27 | 28 | export default httpTrigger; -------------------------------------------------------------------------------- /api/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cognitive-search-static-web-apps-demo-api", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "cognitive-search-static-web-apps-demo-api", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "axios": "^1.6.0" 12 | }, 13 | "devDependencies": { 14 | "@azure/functions": "^3.0.0", 15 | "@types/node": "^18.x", 16 | "typescript": "^4.0.0" 17 | } 18 | }, 19 | "node_modules/@azure/functions": { 20 | "version": "3.5.1", 21 | "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-3.5.1.tgz", 22 | "integrity": "sha512-6UltvJiuVpvHSwLcK/Zc6NfUwlkDLOFFx97BHCJzlWNsfiWwzwmTsxJXg4kE/LemKTHxPpfoPE+kOJ8hAdiKFQ==", 23 | "dev": true, 24 | "dependencies": { 25 | "iconv-lite": "^0.6.3", 26 | "long": "^4.0.0", 27 | "uuid": "^8.3.0" 28 | } 29 | }, 30 | "node_modules/@types/node": { 31 | "version": "18.19.70", 32 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.70.tgz", 33 | "integrity": "sha512-RE+K0+KZoEpDUbGGctnGdkrLFwi1eYKTlIHNl2Um98mUkGsm1u2Ff6Ltd0e8DktTtC98uy7rSj+hO8t/QuLoVQ==", 34 | "dev": true, 35 | "dependencies": { 36 | "undici-types": "~5.26.4" 37 | } 38 | }, 39 | "node_modules/asynckit": { 40 | "version": "0.4.0", 41 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 42 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 43 | }, 44 | "node_modules/axios": { 45 | "version": "1.7.9", 46 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", 47 | "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", 48 | "dependencies": { 49 | "follow-redirects": "^1.15.6", 50 | "form-data": "^4.0.0", 51 | "proxy-from-env": "^1.1.0" 52 | } 53 | }, 54 | "node_modules/combined-stream": { 55 | "version": "1.0.8", 56 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 57 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 58 | "dependencies": { 59 | "delayed-stream": "~1.0.0" 60 | }, 61 | "engines": { 62 | "node": ">= 0.8" 63 | } 64 | }, 65 | "node_modules/delayed-stream": { 66 | "version": "1.0.0", 67 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 68 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 69 | "engines": { 70 | "node": ">=0.4.0" 71 | } 72 | }, 73 | "node_modules/follow-redirects": { 74 | "version": "1.15.9", 75 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 76 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", 77 | "funding": [ 78 | { 79 | "type": "individual", 80 | "url": "https://github.com/sponsors/RubenVerborgh" 81 | } 82 | ], 83 | "engines": { 84 | "node": ">=4.0" 85 | }, 86 | "peerDependenciesMeta": { 87 | "debug": { 88 | "optional": true 89 | } 90 | } 91 | }, 92 | "node_modules/form-data": { 93 | "version": "4.0.1", 94 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", 95 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 96 | "dependencies": { 97 | "asynckit": "^0.4.0", 98 | "combined-stream": "^1.0.8", 99 | "mime-types": "^2.1.12" 100 | }, 101 | "engines": { 102 | "node": ">= 6" 103 | } 104 | }, 105 | "node_modules/iconv-lite": { 106 | "version": "0.6.3", 107 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", 108 | "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 109 | "dev": true, 110 | "dependencies": { 111 | "safer-buffer": ">= 2.1.2 < 3.0.0" 112 | }, 113 | "engines": { 114 | "node": ">=0.10.0" 115 | } 116 | }, 117 | "node_modules/long": { 118 | "version": "4.0.0", 119 | "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", 120 | "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", 121 | "dev": true 122 | }, 123 | "node_modules/mime-db": { 124 | "version": "1.52.0", 125 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 126 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 127 | "engines": { 128 | "node": ">= 0.6" 129 | } 130 | }, 131 | "node_modules/mime-types": { 132 | "version": "2.1.35", 133 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 134 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 135 | "dependencies": { 136 | "mime-db": "1.52.0" 137 | }, 138 | "engines": { 139 | "node": ">= 0.6" 140 | } 141 | }, 142 | "node_modules/proxy-from-env": { 143 | "version": "1.1.0", 144 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 145 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 146 | }, 147 | "node_modules/safer-buffer": { 148 | "version": "2.1.2", 149 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 150 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", 151 | "dev": true 152 | }, 153 | "node_modules/typescript": { 154 | "version": "4.9.5", 155 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", 156 | "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", 157 | "dev": true, 158 | "bin": { 159 | "tsc": "bin/tsc", 160 | "tsserver": "bin/tsserver" 161 | }, 162 | "engines": { 163 | "node": ">=4.2.0" 164 | } 165 | }, 166 | "node_modules/undici-types": { 167 | "version": "5.26.5", 168 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 169 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 170 | "dev": true 171 | }, 172 | "node_modules/uuid": { 173 | "version": "8.3.2", 174 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", 175 | "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", 176 | "dev": true, 177 | "bin": { 178 | "uuid": "dist/bin/uuid" 179 | } 180 | } 181 | }, 182 | "dependencies": { 183 | "@azure/functions": { 184 | "version": "3.5.1", 185 | "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-3.5.1.tgz", 186 | "integrity": "sha512-6UltvJiuVpvHSwLcK/Zc6NfUwlkDLOFFx97BHCJzlWNsfiWwzwmTsxJXg4kE/LemKTHxPpfoPE+kOJ8hAdiKFQ==", 187 | "dev": true, 188 | "requires": { 189 | "iconv-lite": "^0.6.3", 190 | "long": "^4.0.0", 191 | "uuid": "^8.3.0" 192 | } 193 | }, 194 | "@types/node": { 195 | "version": "18.19.70", 196 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.70.tgz", 197 | "integrity": "sha512-RE+K0+KZoEpDUbGGctnGdkrLFwi1eYKTlIHNl2Um98mUkGsm1u2Ff6Ltd0e8DktTtC98uy7rSj+hO8t/QuLoVQ==", 198 | "dev": true, 199 | "requires": { 200 | "undici-types": "~5.26.4" 201 | } 202 | }, 203 | "asynckit": { 204 | "version": "0.4.0", 205 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 206 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 207 | }, 208 | "axios": { 209 | "version": "1.7.9", 210 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", 211 | "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", 212 | "requires": { 213 | "follow-redirects": "^1.15.6", 214 | "form-data": "^4.0.0", 215 | "proxy-from-env": "^1.1.0" 216 | } 217 | }, 218 | "combined-stream": { 219 | "version": "1.0.8", 220 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 221 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 222 | "requires": { 223 | "delayed-stream": "~1.0.0" 224 | } 225 | }, 226 | "delayed-stream": { 227 | "version": "1.0.0", 228 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 229 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" 230 | }, 231 | "follow-redirects": { 232 | "version": "1.15.9", 233 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 234 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" 235 | }, 236 | "form-data": { 237 | "version": "4.0.1", 238 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", 239 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 240 | "requires": { 241 | "asynckit": "^0.4.0", 242 | "combined-stream": "^1.0.8", 243 | "mime-types": "^2.1.12" 244 | } 245 | }, 246 | "iconv-lite": { 247 | "version": "0.6.3", 248 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", 249 | "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 250 | "dev": true, 251 | "requires": { 252 | "safer-buffer": ">= 2.1.2 < 3.0.0" 253 | } 254 | }, 255 | "long": { 256 | "version": "4.0.0", 257 | "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", 258 | "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", 259 | "dev": true 260 | }, 261 | "mime-db": { 262 | "version": "1.52.0", 263 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 264 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 265 | }, 266 | "mime-types": { 267 | "version": "2.1.35", 268 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 269 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 270 | "requires": { 271 | "mime-db": "1.52.0" 272 | } 273 | }, 274 | "proxy-from-env": { 275 | "version": "1.1.0", 276 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 277 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 278 | }, 279 | "safer-buffer": { 280 | "version": "2.1.2", 281 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 282 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", 283 | "dev": true 284 | }, 285 | "typescript": { 286 | "version": "4.9.5", 287 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", 288 | "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", 289 | "dev": true 290 | }, 291 | "undici-types": { 292 | "version": "5.26.5", 293 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 294 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 295 | "dev": true 296 | }, 297 | "uuid": { 298 | "version": "8.3.2", 299 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", 300 | "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", 301 | "dev": true 302 | } 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cognitive-search-static-web-apps-demo-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "tsc", 7 | "watch": "tsc -w", 8 | "prestart": "npm run build", 9 | "start": "func start", 10 | "test": "echo \"No tests yet...\"" 11 | }, 12 | "dependencies": { 13 | "axios": "^1.6.0" 14 | }, 15 | "devDependencies": { 16 | "@azure/functions": "^3.0.0", 17 | "@types/node": "^18.x", 18 | "typescript": "^4.0.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/search/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ] 11 | }, 12 | { 13 | "type": "http", 14 | "direction": "out", 15 | "name": "res" 16 | } 17 | ], 18 | "scriptFile": "../dist/search/index.js" 19 | } -------------------------------------------------------------------------------- /api/search/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from "@azure/functions" 2 | import axios from 'axios'; 3 | 4 | const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { 5 | 6 | const searchApiUrl = `https://${process.env.SearchServiceName}.search.windows.net/indexes/${process.env.SearchIndexName}/docs?api-version=2019-05-06&`; 7 | const url = req.url.replace(/^http(s)?:\/\/[^/]+\/api\/search(\?)?/i, searchApiUrl); 8 | 9 | try { 10 | 11 | const response = await axios.get(url, { 12 | headers: { 13 | "api-key": process.env.SearchApiKey, 14 | } }); 15 | 16 | context.res = { 17 | status: response.status, 18 | body: response.data, 19 | }; 20 | 21 | } catch (err) { 22 | context.res = { 23 | status: err.response?.status ?? 500 24 | }; 25 | } 26 | }; 27 | 28 | export default httpTrigger; -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "dist", 6 | "rootDir": ".", 7 | "sourceMap": true, 8 | "strict": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /arm-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | 6 | "location": { 7 | "type": "string", 8 | "defaultValue": "[resourceGroup().location]", 9 | "allowedValues": [ 10 | "centralus", 11 | "eastus2", 12 | "eastasia", 13 | "westeurope", 14 | "westus2" 15 | ], 16 | "metadata": { 17 | "description": "Location for your new Static Web Apps instance. Note that this parameter is different from 'Region' parameter above. 'Region' applies to Resource Group only, 'Location' applies to app instance itself." 18 | } 19 | }, 20 | 21 | "staticWebAppInstanceName": { 22 | "type": "string", 23 | "defaultValue": "[concat('static-web-app-',uniqueString(resourceGroup().id))]", 24 | "metadata": { 25 | "description": "Name for your new Static Web App instance." 26 | } 27 | }, 28 | "repositoryUrl": { 29 | "type": "string", 30 | "metadata": { 31 | "description": "Full URL to your forked repo _without_ '.git' at the end. E.g. 'https://github.com/my-name/my-cognitive-search-static-web-apps-sample-ui-fork'" 32 | } 33 | }, 34 | "repositoryToken": { 35 | "type": "securestring", 36 | "metadata": { 37 | "description": "Your GitHub Personal Access Token. Generate it as described here: https://docs.microsoft.com/en-us/azure/static-web-apps/publish-azure-resource-manager?tabs=azure-cli#create-a-github-personal-access-token" 38 | } 39 | }, 40 | "searchServiceName": { 41 | "type": "string", 42 | "metadata": { 43 | "description": "Your Azure Cognitive Search service instance name." 44 | } 45 | }, 46 | "searchIndexName": { 47 | "type": "string", 48 | "metadata": { 49 | "description": "Your search index name. This index must exist in your Cognitive Search service." 50 | } 51 | }, 52 | "searchApiKey": { 53 | "type": "securestring", 54 | "metadata": { 55 | "description": "Your query api-key. Find it on your Cognitive Search service's 'Keys' tab in Azure Portal." 56 | } 57 | }, 58 | "azureMapSubscriptionKey": { 59 | "type": "securestring", 60 | "defaultValue": "", 61 | "metadata": { 62 | "description": "Ssubscription key for your Azure Maps account (Azure Maps is used for visualizing geolocation data). This value is optional. If not specified, the map will not be shown." 63 | } 64 | }, 65 | 66 | "cognitiveSearchKeyField": { 67 | "type": "string", 68 | "metadata": { 69 | "description": "Name of the field in your search index, that uniquely identifies a document. E.g. 'HotelId'." 70 | } 71 | }, 72 | "cognitiveSearchNameField": { 73 | "type": "string", 74 | "metadata": { 75 | "description": "Name of the field in your search index, that contains a short document title. E.g. 'HotelName'. You can also put a comma-delimited list of field names here, e.g. 'HotelName,Address'" 76 | } 77 | }, 78 | "cognitiveSearchGeoLocationField": { 79 | "type": "string", 80 | "defaultValue": "", 81 | "metadata": { 82 | "description": "Name of the field in your search index, that contains geo coordinates for each document. E.g. 'Location'. The field type must be 'Edm.GeographyPoint'" 83 | } 84 | }, 85 | "cognitiveSearchOtherFields": { 86 | "type": "string", 87 | "metadata": { 88 | "description": "Comma-separated list of other fields to be shown on search result cards. E.g. 'Tags,Description,Description_fr,Category,LastRenovationDate'" 89 | } 90 | }, 91 | "cognitiveSearchFacetFields": { 92 | "type": "string", 93 | "metadata": { 94 | "description": "Comma-separated list of fields to be shown as facets on the left sidebar. If a field is an array-type field, append trailing star ('*') to its name. E.g. 'Tags*,Rating,Category,ParkingIncluded,LastRenovationDate1. All these fields must be facetable and filterable." 95 | } 96 | }, 97 | "cognitiveSearchTranscriptFields": { 98 | "type": "string", 99 | "defaultValue": "", 100 | "metadata": { 101 | "description": "Comma-separated list of fields to be shown on the 'Transcript' tab of the Details dialog. E.g. 'HotelName,Description,Description_fr'. All these fields must be searchable. If not specified, that tab will simply show all string-type fields and get hit highlights from the search string." 102 | } 103 | }, 104 | "CognitiveSearchSuggesterName": { 105 | "type": "string", 106 | "defaultValue": "", 107 | "metadata": { 108 | "description": "Optional name of the autocomplete suggester to be used. E.g. 'sg'. Create and configure a suggester for your search index and put its name here - then the search query textbox will start showing suggestions as you type." 109 | } 110 | } 111 | }, 112 | "resources": [ 113 | { 114 | "apiVersion": "2021-01-15", 115 | "name": "[parameters('staticWebAppInstanceName')]", 116 | "type": "Microsoft.Web/staticSites", 117 | "location": "[parameters('location')]", 118 | "properties": { 119 | "repositoryUrl": "[parameters('repositoryUrl')]", 120 | "branch": "master", 121 | "repositoryToken": "[parameters('repositoryToken')]", 122 | "buildProperties": { 123 | "appLocation": "/", 124 | "apiLocation": "api", 125 | "appArtifactLocation": "build" 126 | } 127 | }, 128 | "sku": { 129 | "Tier": "Free", 130 | "Name": "Free" 131 | }, 132 | "resources":[ 133 | { 134 | "apiVersion": "2021-01-15", 135 | "name": "appsettings", 136 | "type": "config", 137 | "location": "[parameters('location')]", 138 | 139 | "properties": { 140 | 141 | "SWA_ENABLE_PROXIES_MANAGED_FUNCTIONS": "true", 142 | 143 | "SearchServiceName": "[parameters('searchServiceName')]", 144 | "SearchIndexName": "[parameters('searchIndexName')]", 145 | "SearchApiKey": "[parameters('searchApiKey')]", 146 | "AzureMapSubscriptionKey": "[parameters('azureMapSubscriptionKey')]", 147 | "CognitiveSearchKeyField": "[parameters('cognitiveSearchKeyField')]", 148 | "CognitiveSearchNameField": "[parameters('cognitiveSearchNameField')]", 149 | "CognitiveSearchGeoLocationField": "[parameters('cognitiveSearchGeoLocationField')]", 150 | "CognitiveSearchOtherFields": "[parameters('cognitiveSearchOtherFields')]", 151 | "CognitiveSearchFacetFields": "[parameters('cognitiveSearchFacetFields')]", 152 | "CognitiveSearchTranscriptFields": "[parameters('cognitiveSearchTranscriptFields')]", 153 | "CognitiveSearchSuggesterName": "[parameters('cognitiveSearchSuggesterName')]" 154 | }, 155 | 156 | "dependsOn": [ 157 | "[resourceId('Microsoft.Web/staticSites', parameters('staticWebAppInstanceName'))]" 158 | ] 159 | } 160 | ] 161 | } 162 | ] 163 | } 164 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cognitive-search-static-web-apps-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.12.3", 7 | "@material-ui/icons": "^4.11.2", 8 | "@material-ui/lab": "^4.0.0-alpha.60", 9 | "@testing-library/jest-dom": "^5.16.2", 10 | "@testing-library/react": "^12.1.2", 11 | "@testing-library/user-event": "^13.5.0", 12 | "axios": "^1.6.0", 13 | "azure-maps-control": "^2.1.8", 14 | "azure-maps-drawing-tools": "^1.0.0", 15 | "mobx": "^5.15.7", 16 | "mobx-react": "^6.3.1", 17 | "react": "^17.0.2", 18 | "react-dom": "^17.0.2", 19 | "react-scripts": "^5.0.0", 20 | "styled-components": "^5.3.3" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject", 27 | "start-with-backend": "npm run start | (cd ./api && npm install && npm start)" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "devDependencies": { 33 | "@types/jest": "^27.4.0", 34 | "@types/node": "^17.0.14", 35 | "@types/react": "^17.0.2", 36 | "@types/react-dom": "^17.0.1", 37 | "typescript": "^3.6.4" 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scale-tone/cognitive-search-static-web-apps-sample-ui/ca3bee6c73422743fa2a29f8a610b2fe4547519e/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 20 | Cognitive Search Demo 21 | 22 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scale-tone/cognitive-search-static-web-apps-sample-ui/ca3bee6c73422743fa2a29f8a610b2fe4547519e/public/screenshot1.png -------------------------------------------------------------------------------- /public/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scale-tone/cognitive-search-static-web-apps-sample-ui/ca3bee6c73422743fa2a29f8a610b2fe4547519e/public/screenshot2.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import styled from 'styled-components'; 4 | 5 | import { AppBar, Box, Button, Link, Toolbar, Typography } from '@material-ui/core'; 6 | import GitHubIcon from '@material-ui/icons/GitHub'; 7 | 8 | import logo from './logo.svg'; 9 | 10 | import { SearchResultsMap } from './components/SearchResultsMap'; 11 | import { SearchResults } from './components/SearchResults'; 12 | import { FilterSummaryBox } from './components/FilterSummaryBox'; 13 | import { SearchTextBox } from './components/SearchTextBox'; 14 | import { Facets } from './components/Facets'; 15 | import { DetailsDialog } from './components/DetailsDialog'; 16 | import { LoginIcon } from './components/LoginIcon'; 17 | 18 | import { AppState } from './states/AppState'; 19 | 20 | const SidebarWidth = '300px'; 21 | 22 | // Main app page 23 | @observer 24 | export default class App extends React.Component<{ state: AppState }> { 25 | 26 | render(): JSX.Element { 27 | 28 | const state = this.props.state; 29 | 30 | return (<> 31 | 32 | 33 | 34 | 35 | {state.searchResultsState.isInInitialState ? (<> 36 | 37 | 38 | 39 | 40 | Cognitive Search Demo 41 | 42 | 43 | ) : (<> 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Cognitive Search Demo 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | )} 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {state.searchResultsState.isInInitialState ? ( 73 | 74 | 75 | 76 | 77 | 78 | 84 | 85 | 86 | ) : (<> 87 | 88 | 89 | 90 | 91 | 92 |
93 | {!!state.mapResultsState && ( 94 | state.searchResultsState.facetsState.geoRegion = points} 99 | /> 100 | )} 101 | 102 |
103 | 104 | )} 105 | 106 | {!!state.detailsState && ( 107 | state.hideDetails()} azureMapSubscriptionKey={state.serverSideConfig.AzureMapSubscriptionKey}/> 108 | )} 109 | 110 | ); 111 | } 112 | } 113 | 114 | const BottomBar: typeof AppBar = styled(AppBar)({ 115 | top: 'auto', 116 | bottom: 0 117 | }) 118 | 119 | const Sidebar = styled.div({ 120 | width: SidebarWidth, 121 | float: 'left', 122 | }) 123 | 124 | const Main = styled.div({ 125 | marginLeft: SidebarWidth 126 | }) 127 | 128 | const LandingDiv = styled.div({ 129 | margin: 150 130 | }) 131 | 132 | const ToolbarSearchBoxDiv = styled.div({ 133 | width: '100%' 134 | }) 135 | 136 | const TitleTypography: typeof Typography = styled(Typography)({ 137 | width: 220 138 | }) 139 | 140 | const FilterDiv = styled.div({ 141 | paddingLeft: SidebarWidth 142 | }) 143 | -------------------------------------------------------------------------------- /src/__test/ErrorMessageState.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessageState } from '../states/ErrorMessageState'; 2 | 3 | test('does its job', async () => { 4 | 5 | const errMsg = 'Oops...'; 6 | 7 | const state = new ErrorMessageState(); 8 | 9 | (state as any).ShowError(errMsg); 10 | 11 | expect(state.errorMessage).toBe(errMsg); 12 | 13 | state.HideError(); 14 | 15 | expect(state.errorMessage).toBe(''); 16 | }); 17 | -------------------------------------------------------------------------------- /src/__test/LoginState.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { LoginState } from '../states/LoginState'; 3 | 4 | test('initializes auth', async () => { 5 | 6 | // arrange 7 | 8 | (axios as any).get = (url: string) => { 9 | 10 | expect(url).toBe('/.auth/me'); 11 | 12 | return Promise.resolve({ 13 | data: { 14 | clientPrincipal: { 15 | userDetails: 'tino' 16 | } 17 | } 18 | }); 19 | } 20 | 21 | // act 22 | 23 | const state = new LoginState(); 24 | 25 | await new Promise(resolve => setTimeout(resolve, 10)); 26 | 27 | // assert 28 | 29 | expect(state.userName).toBe('tino'); 30 | }); 31 | -------------------------------------------------------------------------------- /src/__test/SearchResult.test.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from '../states/SearchResult'; 2 | import { IServerSideConfig } from '../states/ServerSideConfig'; 3 | 4 | test('parses raw result', async () => { 5 | 6 | // arrange 7 | 8 | const config: IServerSideConfig = { 9 | 10 | SearchServiceName: 'mySearchService', 11 | SearchIndexName: 'mySearchIndex', 12 | AzureMapSubscriptionKey: undefined, 13 | CognitiveSearchKeyField: 'myKeyField', 14 | CognitiveSearchNameField: 'myNameField1,myNameField2', 15 | CognitiveSearchGeoLocationField: 'myCoordinatesField', 16 | CognitiveSearchOtherFields: 'myField1,myField2,myField3,myKeywordsField', 17 | CognitiveSearchTranscriptFields: undefined, 18 | CognitiveSearchFacetFields: undefined, 19 | CognitiveSearchSuggesterName: undefined 20 | }; 21 | 22 | const rawSearchResult = { 23 | myKeyField: 'key123', 24 | myNameField1: 'First Name', 25 | myNameField2: 'Second Name', 26 | myField1: 'value1', 27 | myField2: 'value2', 28 | myField3: 'value3', 29 | myCoordinatesField: [12.34, 56.78], 30 | myKeywordsField: ['keyword1', 'keyword2', 'invalid keyword', 'keyword1'] 31 | }; 32 | 33 | rawSearchResult['@search.highlights'] = { 34 | myField1: ['Lorem ipsum dolor sit amet', 'consectetur adipiscing elit'], 35 | myField2: ['sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'], 36 | }; 37 | 38 | // act 39 | 40 | const searchResult = new SearchResult(rawSearchResult, config); 41 | 42 | // assert 43 | 44 | expect(searchResult.key).toBe(rawSearchResult.myKeyField); 45 | expect(searchResult.name).toBe(`${rawSearchResult.myNameField1},${rawSearchResult.myNameField2}`); 46 | 47 | expect(searchResult.coordinates.length).toBe(rawSearchResult.myCoordinatesField.length); 48 | expect(searchResult.coordinates[0]).toBe(rawSearchResult.myCoordinatesField[0]); 49 | expect(searchResult.coordinates[1]).toBe(rawSearchResult.myCoordinatesField[1]); 50 | 51 | expect(searchResult.highlightedWords.length).toBe(3); 52 | expect(searchResult.highlightedWords[0]).toBe('ipsum'); 53 | expect(searchResult.highlightedWords[1]).toBe('adipiscing elit'); 54 | expect(searchResult.highlightedWords[2]).toBe('sed do eiusmod'); 55 | 56 | expect(searchResult.keywordsFieldName).toBe('myKeywordsField'); 57 | expect(searchResult.keywords.length).toBe(2); 58 | expect(searchResult.keywords[0]).toBe('keyword1'); 59 | expect(searchResult.keywords[1]).toBe('keyword2'); 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/BooleanFacet.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import styled from 'styled-components'; 4 | 5 | import { List, ListItem, ListItemText, Radio } from '@material-ui/core'; 6 | 7 | import { BooleanFacetState } from '../states/BooleanFacetState'; 8 | 9 | // Renders facet for a boolean field 10 | @observer 11 | export class BooleanFacet extends React.Component<{ state: BooleanFacetState, inProgress: boolean }> { 12 | 13 | static getHintText(state: BooleanFacetState): string { 14 | return state.isApplied ? (state.value ? 'true' : 'false') : 'any'; 15 | } 16 | 17 | render(): JSX.Element { 18 | const state = this.props.state; 19 | return ( 20 | 21 | 22 | 23 | state.value = null} 27 | /> 28 | 29 | 30 | 31 | state.value = true} 35 | /> 36 | 37 | 38 | 39 | state.value = false} 43 | /> 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | } 51 | 52 | const FacetValueListItem: typeof ListItem = styled(ListItem)({ 53 | paddingLeft: '46px !important', 54 | }); 55 | 56 | const FacetValuesList: typeof List = styled(List)({ 57 | maxHeight: 340, 58 | overflowY: 'auto !important', 59 | marginRight: '18px !important' 60 | }) -------------------------------------------------------------------------------- /src/components/DateFacet.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import styled from 'styled-components'; 4 | 5 | import { TextField } from '@material-ui/core'; 6 | 7 | import { DateFacetState } from '../states/DateFacetState'; 8 | 9 | // An id of the filter summary chip for this facet. Need to handle blur events from it separately. 10 | export const FilterSummaryDateFacetChipId = 'FilterSummaryDateFacetChipId'; 11 | 12 | // Renders facet for a Date field 13 | @observer 14 | export class DateFacet extends React.Component<{ state: DateFacetState, inProgress: boolean }> { 15 | 16 | static getHintText(state: DateFacetState): string { 17 | return `from ${state.from.toLocaleDateString()} till ${state.till.toLocaleDateString()}`; 18 | } 19 | 20 | render(): JSX.Element { 21 | const state = this.props.state; 22 | 23 | return (<> 24 | { state.currentFrom = this.getDateValue(evt); }} 28 | onBlur={(evt) => this.handleBlur(evt)} 29 | onKeyPress={(evt) => this.handleKeyPress(evt)} 30 | /> 31 | 32 | { state.currentTill = this.getDateValue(evt); }} 36 | onBlur={(evt) => this.handleBlur(evt)} 37 | onKeyPress={(evt) => this.handleKeyPress(evt)} 38 | /> 39 | ); 40 | } 41 | 42 | private formatDate(dt: Date) { 43 | return dt.toJSON().slice(0, 10); 44 | } 45 | 46 | private getDateValue(evt: any): Date { 47 | 48 | var dt = new Date(evt.target.value.slice(0, 10)); 49 | 50 | // If invalid date entered, then setting it to current date 51 | if (isNaN(dt.valueOf())) { 52 | dt = new Date(); 53 | } 54 | 55 | return dt; 56 | } 57 | 58 | private handleBlur(event: React.FocusEvent) { 59 | 60 | // Skipping this event, if user jumped from date textbox straight to the corresponding chip on FilterSummaryBox 61 | if (!!event.relatedTarget && (event.relatedTarget as any).id === FilterSummaryDateFacetChipId) { 62 | return; 63 | } 64 | 65 | this.props.state.apply(); 66 | } 67 | 68 | private handleKeyPress(event: React.KeyboardEvent) { 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 | this.props.hideMe()} 30 | fullWidth={true} 31 | maxWidth="xl" 32 | > 33 | 34 | {state.name} 35 | 36 | this.props.hideMe()}> 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {!!state.errorMessage && ( 45 | state.HideError()} /> 46 | )} 47 | 48 | , val: DetailsTabEnum) => state.selectedTab = val} 50 | > 51 | 52 | 53 | {!!state.coordinates && ()} 54 | 55 | 56 | 57 | 58 | {state.selectedTab === DetailsTabEnum.Transcript && !state.inProgress && 59 | () 60 | } 61 | 62 | {state.selectedTab === DetailsTabEnum.Metadata && !state.inProgress && 63 | () 64 | } 65 | 66 | {state.selectedTab === DetailsTabEnum.Map && !state.inProgress && 67 | () 68 | } 69 | 70 | 71 | 72 | {!!state.inProgress && ()} 73 | 74 | 75 | 76 | 77 | 78 | 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 | state.menuAnchorElement = undefined} 31 | > 32 | state.logout()}>Login under a different name 33 | 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 | 6 | 9 | 12 | 14 | 17 | 19 | 24 | 26 | 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 = /([^/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 | --------------------------------------------------------------------------------