Note This article is a part of a series of tutorials "Creating an Electron application with Svelte 3 and TypeScript".
- Part 1 - Introduction and setup our dev environment
- Part 2 - Define our goals and implement an interactive search bar
- Part 3 - Display search results and call the API
- Part 4 - Coming soon... Stay tuned! 👀
Check out the rest if you haven't yet 😉.
In the previous article we implemented an interactive search. The next natural step is to display the search results!
As a reminder, in part 2 I made a mockup of the application that we are building. That's how the list of result was represented:
What can we get from this?
So pretty standard. Similar to how we implemented the search bar, we will start with a static version that consists of the markup and styles, then make it dynamic by iterating on our list of search results and adding the necessary logic.
We know that we want to add a content area that contains two panels. Looking at the work we've done so far we can see that src/App.svelte
is the place where we import and render our components. Given that we currently only have one screen that seems to be the good place to define our general application layout. In the case where we start to have multiple screens, App.svelte
will manage the routing while the actual screen content can be moved to its own Svelte file.
First in src/components/TopMenu/SearchBar.svelte
we remove the <header>
tag and the topmenu
ID, that nows becomes the responsibility of the parent component:
<!-- src/components/TopMenu/SearchBar.svelte -->
<!-- 1. Remove that header tag -->
<!-- <header id="topmenu"></header> 👈 here -->
<div class="actions">
{#await searchResults}
<!-- ... -->
{:then results}
<!-- ... -->
{/await}
</div>
<!-- </header> 👈 and here -->
We can then modify src/App.svelte
to define our main layout:
<!-- src/App.svelte -->
<script lang="ts">
import SearchBar from "./components/TopMenu/SearchBar.svelte"
</script>
<header id="topmenu">
<SearchBar />
</header>
<div id="content">
<!-- Left panel -->
<div id="search-results" />
<!-- Right panel -->
<div id="article" />
</div>
<style>
:root {
--topmenu-height: 45px;
}
#topmenu {
height: var(--topmenu-height);
}
#content {
height: calc(100% - var(--topmenu-height));
display: grid;
grid-template-columns: minmax(300px, 25%) 1fr;
}
#search-results {
overflow-y: auto;
background-color: blue;
}
#article {
background-color: red;
}
</style>
In more details, what we are doing here:
<header>
tag and explicitely set its height using a CSS variable --topmenu-height
. That way we can define the height of our content section to be exactly the full height minus our header.:root {
--topmenu-height: 45px;
}
#topmenu {
height: var(--topmenu-height);
}
#content {
height: calc(100% - var(--topmenu-height));
/* ... */
}
display: grid
and a grid-template-columns
that defines the layout we want to have. minmax(300px, 25%) 1fr
can be read as "the first column should have a minimum width of 300px, a maximum width of 25%, and the rest should use the remaining space (1fr)".The result should look like this:
Don't mind the ugly blue and red, that's just to see how our space is being divided. The part we will be concerned with in the rest of this article is the blue one, feel free to change or remove the background color if that bothers you too much.
Similar to what we've done with our search bar, we should create a Svelte component file and import it. I decided to put in a directory LeftPan
but you're free to decide how you manage your files and directories:
<!-- new file src/components/LeftPan/SearchResults.svelte -->
<ul>
<li class="current">
<div class="header">
<div class="title">Sam (1967 film)</div>
<div class="url">
<a target="_blank" href="https://en.wikipedia.org/wiki/Sam_(1967_film)">Open in browser</a>
</div>
</div>
<div class="description">Sam is a 1967 Western film directed by Larry Buchanan.</div>
</li>
<li>
<div class="header">
<div class="title">Sam (army dog)</div>
<div class="url">
<a target="_blank" href="https://en.wikipedia.org/wiki/Sam_(army_dog)">Open in browser</a>
</div>
</div>
<div class="description">
Sam (died 2000) was an army dog who served with the Royal Army Veterinary Corps Dog Unit. While ...
</div>
</li>
</ul>
<style>
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
border-bottom: 1px solid lightgrey;
--padding-vertical: 6px;
--padding-horizontal: 12px;
padding-left: var(--padding-horizontal);
padding-right: var(--padding-horizontal);
padding-top: var(--padding-vertical);
padding-bottom: var(--padding-vertical);
}
li:hover {
cursor: pointer;
}
.current {
background-color: rgb(213, 233, 253);
}
.header {
display: flex;
justify-content: space-between;
}
.title {
font-weight: bold;
white-space: pre-wrap;
}
.title:hover {
cursor: pointer;
text-decoration: underline;
color: rgb(0, 100, 200);
}
.url {
font-size: 0.75em;
white-space: nowrap;
}
.description {
color: rgb(104, 104, 104);
}
</style>
Then import it and render it in our left panel:
<!-- src/App.svelte -->
<script lang="ts">
import SearchBar from "./components/TopMenu/SearchBar.svelte"
// 1. 👇 Import the component we just created
import SearchResults from "./components/LeftPan/SearchResults.svelte"
</script>
<!-- ... -->
<div id="content">
<div id="search-results">
<!-- 2. 👇 Render it -->
<SearchResults />
</div>
<!-- ... -->
</div>
Our application starts to look similar to our mockup. If you try clicking around you will notice that the link "Open in browser" currently opens the link in a new window of our Electron application. We will change that behaviour later by overriding the default behaviour for external links.
To dynamically render the response from our search request we need to fulful some requirements:
SearchBar
, we need a way to share its result to other component such as SearchResults
SearchBar
and SearchResults
need to handle states of our search request
pending
means that our Promise
object is still awaiting results or processing them, SearchBar
already handles that case by disabling the search form (only one search query can be done at a given time), SearchResults
should display a loading interface, such as a spinner or a loading message.fulfilled
means that the Promise
completed without error, SearchBar
re-enable the search form so that we can do another one, SearchResults
should display the returned values (if any)rejected
means that the Promise
failed somehow, only SearchResults
should handle that case explicitely by displaying information about the error to the user.To do this we will use Svelte's concept of stores. That will require some changes to both our components to read stored values and handle all the cases of our request, and an extra file to define our store and functions to update it.
We add a new file src/stores.ts
, in which we import writable
from the package svelte/store
, define a type for our results, and initialize a new store.
// src/stores.ts
import { writable } from "svelte/store"
export interface SearchResult {
title: string
url: string
description: string
}
export const storeSearchResults = writable<Promise<SearchResult[]>>(null)
The important detail here is writable<Promise<SearchResult[]>>(null)
. We will store the promise in the store, not only the data, that way any component can subscribe to the store and react to changes to the Promise
state.
Another detail is that the store variable should be exported, otherwise we won't be able to subscribe to it from our components.
Then we continue by implementing a function that performs a search on Wikipedia's API then update the store with the results:
// src/stores.ts
...
// 1. Define a type matching format from Wikipedia search API, such as:
// [
// "samuel",
// [
// "Samuel",
// "Samuel L. Jackson",
// "Samuel Eto'o",
// "Samuel Beckett",
// ],
// ["", "", "", "", "", "", "", "", "", ""],
// [
// "https://en.wikipedia.org/wiki/Samuel",
// "https://en.wikipedia.org/wiki/Samuel_L._Jackson",
// "https://en.wikipedia.org/wiki/Samuel_Eto%27o",
// "https://en.wikipedia.org/wiki/Samuel_Beckett",
// ],
// ]
//
type SearchResultJSON = [string, string[], string[], string[]]
// 2. Define our search function, it doesn't return a value and instead will perform an update of our store
export function searchArticles(searchTerms: string) {
// 3. We set the store to a new Promise that will eventually be resolved and contain our search results
storeSearchResults.update(async () => {
// 4. Just for debugging purpose, wait a bit so that we can see our loading messages
const duration = 2000 // equal 2s
await new Promise(resolve => setTimeout(resolve, duration))
// 5. Call the API with our search terms
const response = await fetch(`https://en.wikipedia.org/w/api.php?action=opensearch&search=${searchTerms}`)
// 6. Check for issues, fail the Promise if the request failed somehow
if (!response.ok) {
throw new Error(`failed to fetch search results: ${response.statusText}`)
}
// 7. If everything went well, then we process our response as JSON
const [searched, titles, _, urls]: SearchResultJSON = await response.json()
// 8. Create search results that match our own type. Not that the description isn't returned by the default search, we will have to call another endpoint or find a way to return more data from the API
const searchResults: SearchResult[] = []
for (let i = 0; i < titles.length; i++)
searchResults.push({
title: titles[i],
url: urls[i],
description: "To be defined",
})
// 9. And finally we return our results, which will mark the Promise as fulfilled
return searchResults
})
}
That's it for the store and its search function.
SearchBar
to perform the searchWe can remove the fake request and data from SearchBar
, and instead do the actual request and subscribe to our store.
<!-- src/components/TopMenu/searchBar.svelte -->
<script lang="ts">
// 1. Import our store and search function 👇
import { searchArticles, storeSearchResults } from "../../stores"
let searchTerms: string
function handleClick() {
// 2. On click, do a search 👇
if (searchTerms) searchArticles(searchTerms)
}
</script>
<div class="actions">
<!-- 3. 👇 We now wait on our store -->
{#await $storeSearchResults}
<input disabled type="text" bind:value={searchTerms} />
<button disabled>Let's find out 🕵️♀️</button>
{:then} <!-- 4. 👈 No need for a result anymore -->
<input placeholder="Type your search term" bind:value={searchTerms} />
<button on:click={handleClick}>Let's find out 🕵️♀️</button>
{/await}
</div>
<style>
.actions {
text-align: center;
padding-top: 4px;
padding-bottom: 4px;
background-color: rgb(239, 239, 239);
border-bottom: 1px solid lightgrey;
box-shadow: 0 0 4px #00000038;
}
.actions input,
.actions button {
margin: 0;
}
</style>
SearchResults
In SearchResults
we only need to subscribe to the store, handle its state, and iterate on the result if present.
<!-- src/components/LeftPan/SearchResults.svelte -->
<script lang="ts">
// 1. 👇 Import our store
import { storeSearchResults } from "../../stores"
</script>
<ul>
<!-- 2. 👇 Wait on our store, display loading message -->
{#await $storeSearchResults}
<p>loading...</p>
<!-- 3. 👇 If fulfilled, then declare a variable `result` -->
{:then result}
<!-- 4. 👇 Iterate on the results -->
{#each result || [] as item}
<li>
<div class="header">
<div class="title">{item.title}</div>
<div class="url">
<a target="_blank" href={item.url}>Open in browser</a>
</div>
</div>
<div class="description">{item.description}</div>
</li>
<!-- 5. 👇 Display a message if nothing has been found -->
{:else}
<p>No results</p>
{/each}
{:catch error}
<pre>{error}</pre>
{/await}
</ul>
<!-- ... -->
We are using #await
to handle the promise state, then #if
and #each
to check and iterate on the content. We now have an application that can be used to search for articles on Wikipedia, list them, and let us open them in new windows.
Electron is based on Chrome, a web browser, which means that by default we will face issues with CORS. Cross-Origin Resource Sharing (CORS) is a mechanism enabled in every browser by default that ensure that a website cannot fetch resources from other domains. Without going in details, that means that by default our application won't be able to call Wikipedia's API because the domain of our local server (localhost) doesn't match Wikipedia's domain (wikipedia.org). That makes sense in the case of browsers where a backend is expected to list explicitely which domain should be able to interact, but in the case of an Electron application that is explicitely calling an external API that's problematic.
The simple way to approach this is to disable the web security features when we initialize Electron. Do to this, in src/electron.js
, we should do the following changes:
// src/electron.js
// ...
function createWindow() {
mainWindow = new BrowserWindow({
width: 900,
height: 680,
// 1. 👇 We need to add this configuration to disable CORS
webPreferences: {
webSecurity: false,
}
})
// ...
}
// 2. Also, Electron v9 has an open bug. Because of this we should also add the following:
//
// Required to disable CORS in Electron v9.x.y because of an existing bug.
// See Electron issue: https://github.com/electron/electron/issues/23664
app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')
// ...
Once done, restart the Electron application, and you should now be able to perform searches against Wikipedia's API. Check Electron's DevTools if you're facing issues and check that you don't have logs mentioning CORS.
In this article we created a new component, and implemented a way to communicate between our components. That already results in a usable application, though we still have work to do.
Next time we will fetch article descriptions, and will make it possible to select an article and see its content in our right panel. Stay tuned 😁✌.