Creating an Electron application with Svelte 3 and TypeScript - Part 3

Note This article is a part of a series of tutorials "Creating an Electron application with Svelte 3 and TypeScript".

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?

  • Our application has a top menu (that we already created) and a content section
  • The content section is divided in two panels, the list of articles is on the left side, and article detail on the right side
  • The list is displayed vertically, and is likely to be scrollable
  • Every result is rendered the same way: title, and some extra text

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.

Display our search results

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:

  1. First we wrap the search bar component in a <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));
    /* ... */
}
  1. We then define our two panels using this little CSS trick, a 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.

Static version: layout and styles

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.

Dynamically render search results

To dynamically render the response from our search request we need to fulful some requirements:

  • The request is initiated by SearchBar, we need a way to share its result to other component such as SearchResults
  • Both 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.

Creating our first store

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.

Update SearchBar to perform the search

We 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>

Render our store content in 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.

A note regarding Electron and CORS

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.

Conclusion

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 😁✌.