DEV Community

CorvusEtiam
CorvusEtiam

Posted on

Building Svelte 3 Budget Poll App [2]

Where we ended

In my last post I covered basics of Svelte3 installation and usage. We created git repository and made multipage form component and panel component.
Now, we will try to design our content panels.

Starting up

Open up our repo in your favourite editor and add few files. We will need component for each panel.

  1. Initial Poll Data component like name and full amount -> PollHeader.svelte
  2. People joining up to the poll -> PollContent.svelte
  3. Final table containing computed amounts for each person -> Balance.svelte

Please, create now those 3 files. We will also need to add 2 new store into globals.js.
We will name them: peopleStore and pollStore.

export const peopleStore = writable({
    people: []
})

export const pollStore = writable({
    name: null,
    amount: 0,
    currency: null
})

Now, we will import and use our first component containing app logic PollHeader.
Inside App.svelte you have to import, just like the last time both new PollHeader component and new store.

Your HTML template withing App.svelte should look like (stripped from script, style, and wrapper elements for brevity)

<FormPanel index={0}>
    <PollHeader />
</FormPanel>    

Now lets design our PollHeader.

PollHeader Component

<script></script>
<style>
    .container {
        display: flex;
        flex-direction: column;
    }

    .group {
        display: flex;
        flex-direction: row;
    }

    .group label {
        width: 30%;
        margin-right: auto;
    }

    .group input {
        flex: 1;
    }

    .group select {
        flex: 1;
    }

    .push-right {
        margin-left: auto;
    }
</style>
<fieldset>
    <legend>Poll General Data</legend>
    <p>Please provide name of the poll and amount</p>
    <div class="container">
        <div class="group">
            <label for="pollName">Poll Name: </label>
            <input type="text" id="pollName" required>
        </div>
        <div class="group">
            <label for="pollAmount">Poll Amount: </label>
            <input type="number" id="pollAmount" required>
            <select id="pollCurrency">
                <option value="" selected disabled>Select Currency</option>
            </select>
        </div>
        <div class="group">
            <button class="push-right" type="button">Save</button>
        </div>
    </div>
</fieldset>

First, we made our basic component template. One thing, you may notice is that, we use again class .container here.
We used it before. This is pretty important. Svelte3 can compile your CSS into modules eg. replace normal class names with hashes to make them easier to work with. What is more important here is that, you don't have to worry about them.
If you want to use some global css in svelte it is also possible. Just define your css in public/global.css and use as any other css files.

Now, I don't want to spend to much time on CSS. You can read about it here:

Now, we need to make sure that:

  1. Our button is disabled, if all fields are not filled in
  2. Find a way to get values from fields into variables

In React land that would involve writing large amount of accessors, functions, and JSX.
Here we will archive it in much more quicker manner.

Svelte follows the way, paved by Vue, with custom, two-way binding. Here it is even easier, thanks to nature of Svelte.

<script>
import { pollState } from "./globals.js";

let name = "";
let amount = 0;
let currency = null;

let changed = false;

$: filled_in = (name !== "") && ( amount > 0 ) && ( currency != null ) && changed;
$: disabled = is_filled_in();

function save() {
    $pollState.poll = {
        name,
        amount,
        currency
    };
}

</script>
<!-- parts of html omitted for brewity -->
<input type="text" id="pollName" required bind:value={name}>
<input type="number" id="pollAmount" required bind:value={amount}> 
<select id="pollCurrency" bind:value={currency} on:change={ev => { changed = !changed; }}></select>
{#if filled_in }
<button class="push-right" type="button" on:click={save} {disabled}>Save</button> 
{/if}

We are lacking one thing. Our select has one option, what's more. That option is disabled one with message to the user.
Time to change that.

<select id="pollCurrency" bind:value={currency} on:change={ev => { changed = !changed; }}>
    <option value="" selected disabled>Select Currency</option>
    {#each Object.entries(CURRENCY) as entry }
    <option value={entry[0]}>{entry[1].name}</option>
    {/each} 
<select>

Is it better than React and normal JS functions. Hard to say, here we get pretty simple {#each}{/each} statement.
It gives as basic iteration. Of course, our iterated element, here CURRENCY can be any JS expression.
If you need something different, you can write function for it.

And time to define currencies, which you should next import inside PollHeader script tag.

/// place it in globals.js and import inside PollHeader
export const CURRENCY = {
    "PLN" : { name: "złoty", format: "{} zł" },
    "USD" : { name: "dollar", format: "$ {}" },
    "EUR" : { name: "euro", format: "{} EUR" }
}

Of course, you could always provide them with property.

PollContent component

Time for classic CRUD app. Let me start with how this part will look like.
It should contain few key parts:

  • Table representing our current data
  • Set of controls
  • Block with edit component

The best idea for now, would be to wrap those parts into components. Let us try this approach

Inside PollContent.svelte we will put:

<script>
    import { pollState } from "./globals.js";
</script>
<div>
    <h2>{ $pollState.poll.name }</h2>
    <!-- Our table with data  -->
</div>
<div>
    <button type="button">Add Entry</button>
    <button type="button">Update Entry</button>
    <button type="button">Delete Selected Entries</button>
</div>
<div>
    <!-- Our data input part -->
</div>

Ok, why we need pollState store. I will try to follow this naming convention. If something is in globals and ends with state, you should think about it as store.

To make all of it easier, let me define few more components here. First one, will be PollTable.svelte and PollInput.svelte next.

<script>
    import { pollState } from "./globals.js";
    import PollTable from "./PollTable.svelte";
    import PollInput from "./PollTable.svelte";
</script>
<div>
    <h2>{ $pollState.poll.name }</h2>
    <PollTable />
</div>
<div>
    <button type="button">Add Entry</button>
    <button type="button">Update Entry</button>
    <button type="button">Delete Selected Entries</button>
</div>
<div>
    <PollInput />
</div>

PollTable

Generally it should be understandable enough. Only hard part is here ternary if inside <td>.
You should remember that, you can put any JS expressions inside braces.
This person.amount > 0 expression checks if one person paid money or owe all of it and set class based on that.

Function get_person_by_timestamp do one thing. Iterate over our dataset and find person with matching timestamp.
Why not index?

Question for later: How would you add sorting later on?

<script>
    import { format_currency, pollState } from "./globals.js";

    function get_person_by_timestamp(ts) {
        for ( let i = 0; i < $pollState.people.length; i++ ) {
            if ( $pollState.people[i].timestamp === ts ) {
                return i;
            }
        }
    }

    function select_checkbox(ts) {
        let index = get_person_by_timestamp(ts);
        $pollState.people[index].selected = !$pollState.people[index].selected;
    }
</script>
<style>
    .paid {
        color: green;
    }

    .owe {
        color: red;
    }
</style>
<table>
    <thead>
        <tr>
            <th>-</th>
            <th>No.</th>
            <th>Person</th>
            <th>Paid</th>
        </tr>
    </thead>
    <tbody>
        {#each $pollState.people as person, index }
        <tr>
            <td><input type="checkbox" on:change={ ev => select_checkbox(person.timestamp) } /></td>
            <td>{index + 1}.</td>
            <td>{person.name}</td>
            <td class = "{ person.amount > 0 ? 'paid' : 'owe' }">{ format_currency(person.amount, person.currency) }</td>
        </tr>
        {/each}
    </tbody>
</table>

We want to keep track of which checkbox was selected. It is probably easiest by just adding boolean, selected field into globals.js in each object representing person.

Now lets open our browser finally and use some buttons. Fill values in first panel, click Save and Next later on.
Problem is, if you click previous, you will see everything disappears or rather, there are no value being kept.
Why?

There reason is that, our {#if <smth>} template will remove and re-add parts to the DOM.
How to solve it?

Going back to our FormPanel

We have two solutions here.

First one is to swap our {#if} template with good old css display: none;. It is pretty good, works fine.
Our code may look like this now.

<style>
.multiform-panel {
    display: block;
}

.multiform-panel.hidden {
    display: none;
}
</style>
<div class="multiform-panel { index !== $controllerState.current ? 'hidden' : '' }">
    <slot></slot>
</div>

But let me show you second way and introduce you to onMount lifecycle hook.
Inside our PollHeader.svelte we will do something like this:

<script>
    /* let me import onMount */
    import { onMount } from "svelte";
    import { CURRENCY, pollState } from "./globals.js";

    onMount(() => {
        name = $pollState.poll.name || "";
        amount = $pollState.poll.amount || 0;
        currency = $pollState.poll.currency || "default";

    })

    let name;
    let amount;
    let currency;
    /* rest goes here */
</script>

Lifecycle onMount is runned every time component is... guess what. Mounted into DOM. Which way is better?
I think, the onMount is good a bit cleaner.

PollInput

<script>
    import { onMount, createEventDispatcher } from "svelte";

    import { CURRENCY, pollState } from "./globals.js";

    export let currency; 

    let dispatch = createEventDispatcher();

    let name;
    let amount;
    let timestamp = null;

    let is_update = false;

    function get_person_to_update() {
        for ( let i = 0; i < $pollState.people.length; i++ ) {
            if ( $pollState.people[i].selected ) {
                return $pollState.people[i];
            }
        }

        return null;
    }   

    onMount(() => {
        let updated = get_person_to_update();

        currency = $pollState.poll.currency;

        if ( updated !== null ) {
            timestamp = updated.timestamp;
            name = updated.name;
            amount = updated.amount;
        } else {
            name = "";
            amount = 0;
            timestamp = null;
        }
    });

    function dispatch_save() {
            dispatch('save', { name, amount, currency, timestamp: timestamp });
    }
</script>
<div>
    <div>
        <label for="name">Name: </label>
        <input id="name" bind:value={name}>
    </div>
    <div>
        <label for="amount">Name: </label>
        <input id="amount" bind:value={amount}>
        <span>{ CURRENCY[currency].name }</span>
    </div>
    <div>
        <button type="button" on:click={ ev => { dispatch_save() } }>Save</button> <!-- [3] -->
    </div>
</div>

Ok, what is it going on here? You can see, by now, we are creating custom events. This is another mechanic used to pass state between components. If your component looks like input, custom events are good idea, how to pass data. It resembles normal DOM operation and is pretty easy to use later on. Our code now.

We do that with:

  1. Creating new event dispatcher with createNewEvent, it will give as back function.
  2. We write small helper function, important detail is that, we don't need to write to components for updating and creating items. I will use timestamp inside object as a marker. This object is accessible by event handler by using something like ev => ev.detail
  3. I put our helper and bind it to click event on button.

Now, we need to fill out PollContent.

Going back to PollContent

Rest should be at least understandable. We will update PollInput with few props and add .

First we want our input box show up on click of the button.
We need variable named open inside script block and edit our control buttons in this way;
Logging was added to make it cleaner in console, but can be freely removed.

<div class="group horizontal">
    <button type="button" on:click={ev => { console.log(ev.target.innerText); open = true; }}>Add Entry</button>
    <button type="button" on:click={ev => { console.log("%s -- not implemented yet", ev.target.innerText); }}>Remove Selected</button>
    <button type="button" on:click={ev => { console.log(ev.target.innerText); open = true; }}>Update Selected</button>
</div> 

{#if open }
<PollInput on:save={ ev => { create_or_update(ev.detail) }} currency={$pollState.poll.currency}/>
{/if}

I added on:save event handler into the PollInput tag. This is how you can listen to custom events. In exactly the same way as before.

And that is how I implemented create_or_update function.

function create_or_update(obj) {
        let { name, amount, currency, timestamp } = obj;

        if ( timestamp == null ) {
            let people = $pollState.people;
            people.push(create_person(name, amount));

            $pollState.people = people;  
            open = false;
        } else {
            for ( let i = 0; i < $pollState.people.length; i++ ) {
                if ( $pollState.people[i].timestamp == timestamp ) {
                    $pollState.people[i].name = name;
                    $pollState.people[i].amount = amount;
                    $pollState.people[i].currency = currency;
                    $pollState.people[i].selected = false;
                }
            }
            open = false;
        }
    }

Pretty simple. Those additional assingments are not necessary, but I like to keep them, because in Svelte assignment is special.
Assignment run the whole reactive machinery. This is way, if you modify stores or reactive properties, you want to assign on each change.

Only part left is Remove Selected button, but I would leave as an exercise for a reader.

Again if something isn't clear please ask or read up on svelte.dev. Examples are pretty cool too.

See you soon!

Top comments (0)