Forms & Reactivity

Setup

Commit your changes if you want (git commit -am “module 1”) and then jump to the next snapshot with git checkout 2-forms-reactivity.

Bring up your browser and see the new changes. We’ve added some markup for some session filters:

None of these are wired up yet; that’s what we’ll tackle in this module.

Supporting Search

When using the filters, the matching results are a view of the underlying data in the sessions variable. In other words, we can compute the list of matching sessions on demand. It sounds like a perfect use for a Vue computed property!

Tracking text in a textbox

Let's start by adding a variable to keep track of the text the user has entered into the search box.

Add a ref called searchText with a default value of an empty string.

Add the v-model directive to the input field pointing at the searchText in the template. Once you do this, Vue will keep the text in the search box synchronized with the ref value.

  onMounted(() => {
       console.log(data);
     });
 });
+
+const searchText = ref('');
 </script>
 
 <template>
...
     <div class="row">
       <div class="col">
         <div class="form-floating">
-          <input id="search" class="form-control mb-4" type="text" placeholder="Search" aria-label="Search" />
+          <input id="search" class="form-control mb-4" type="text" placeholder="Search" aria-label="Search" v-model="searchText" />
           <label for="search">Search</label>
         </div>
       </div>

At this point, whenever you type in the text box, Vue will update the string stored in the searchText ref. We’ll next add a computed property that takes in the full session list and the search text and calculates the list of sessions that match

Computing the matching sessions

Add computed to the list of functions imported from vueat the top of the file so that we can use it.

  <script setup lang="ts">
- import { onMounted, ref } from 'vue';
+ import { computed, onMounted, ref } from 'vue';
  import { SessionOverview } from '../models.ts';

Now, at the bottom of the <script> tag, add the computed property called filteredSessions with the following logic:

The method will look something like this:

const filteredSessions = computed(() => {
  let currentSessions = sessions.value;

  if (searchText.value.length <= 2) {
    return currentSessions;
  }

  const term = searchText.value.toLowerCase();
  return currentSessions.filter(
    (s) =>
      s.title.toLowerCase().includes(term) ||
      s.excerpt.toLowerCase().includes(term) ||
      s.speakers.some((speaker) => speaker.toLowerCase().includes(term)) ||
      s.tags.some((tag) => tag.toLowerCase().includes(term))
  );
});
Vue is really smart: it notices that inside the body of the computed, sessions.value and currentSessions.value are accessed. Vue then remembers that this computation depends on those inputs, and sets up watchers. Whenever those values change, Vue knows to rerun the calculation and update the computed value

Applying the template

Currently the template uses sessions, which is always the full list of 211 sessions. Wherever we want to reference the sessions matching the user’s filters, we’ll need to replace sessions with filteredSessions:

     <!-- we're going to add filters in a later module that will update these counts -->
-    <p>Showing {{ sessions.length }} of {{ sessions.length }} talks</p>
+    <p>Showing {{ filteredSessions.length }} of {{ sessions.length }} talks</p>

       <!-- introductory and overview -->
-      <div class="card" v-for="session in sessions" :key="session.id">
+      <div class="card" v-for="session in filteredSessions" :key="session.id">
         <div class="card-body">
           <span

     <!-- show this if there are no sessions -->
-    <p v-if="sessions.length === 0">No sessions found</p>
+    <p v-if="filteredSessions.length === 0">No sessions found</p>
   </div>

Now give it a try in the browser!

Notice how whenever you type Vue computes the new list of filtered sessions and automatically updates multiple places in the screen to keep it in sync

Filtering by Track

Now we want to add support for filtering by track:

  1. We’ll set up a ref and a v-model binding to keep track of the current selection
  1. We’ll update the <select> tag to have an <option> from each unique talk in the session list, rather than hard-coding the options
  1. We’ll incorporate the track variable into the filtering logic

V-Model for Select

Like before, we’ll add a ref to hang onto the currently selected track. Create a new ref called selectedTrack that defaults to an empty string.

  const searchText = ref('');
+ const selectedTrack = ref('');

Then we can add the v-model directive to the <select> element so that Vue will syncronize the selected option with your selectedTrack ref.

  <div class="form-floating">
-   <select id="track" class="form-control">
+   <select id="track" class="form-control" v-model="selectedTrack">
      <option value="">All</option>

Now, as you pick different options from the <select> element, Vue will update the selectedTrack ref automatically, and update anything that depends on it.

Computing the list of options

It would also be nice if we could use a loop to generate all the option tags under the select. That will let us skip writing some tedious markup.

It would also be nice if that list could be figured out automatically, rather than having to be maintained by a programmer. Lets create another computed to do that.

Each session has one track, but we’ll have to deduplicate them using javascript’s Set class.

Create a new computed called tracks that looks like this. Put it right before the filteredSessions definition.

  const selectedTrack = ref('');

+ const tracks = computed(() => {
+   const allTracks = sessions.value.map((session) => session.track);
+   const uniqueTracks = new Set(allTracks);
+   return Array.from(uniqueTracks);
+ });
 
 const filteredSessions = computed(() => {

The map method converts each element of an array into another type. Here we convert each SessionOverview object into a string holding its track. Then we use the Set class to deduplicate them: a Set can only hold one copy of each value. Finally we convert the Set back into an array and return it

Using the options to render the select tag

Now we can replace the hard-coded <option> tags in our template with v-for, :key, :value and {{ track }}

    <option value="">All</option>
-   <option value="Architecture">Architecture</option>
-   <option value="Career">Career</option>
-   <option value="Cloud">Cloud</option>
+   <option v-for="track in tracks" :key="track" :value="track">{{ track }}</option>
  </select>

Removing code is the best.

Adding selectedTrack to the filter logic

Finally lets update the filteredSessions computed property to actually respect the selectedTrack, if given.

  const filteredSessions = computed(() => {
    let currentSessions = sessions.value;
 
+   if (selectedTrack.value) {
+     currentSessions = currentSessions.filter((s) => s.track === selectedTrack.value);
+   }
+
    if (searchText.value.length <= 2) {
      return currentSessions;
    }

Give that a shot. You should be able to filter by track and by search term at the same time.

Filter by level

Feature overview

Here’s the requirements for our level filter:

Here’s how we’ll tacking this:

  1. Create a Ref to hold the current level selection
  1. Update the list of buttons to render from a list instead of duplicated HTML markup
  1. Add a function to handle click events on the buttons by updating the level selection
  1. Update the filteredSessions computed property to incorporate the selected level

Typescripting

This time we’re going to do all the typescript code first.

First create another Ref called selectedLevel to hold onto the user’s current level selection.

Then a list of levels to drive the button bar. This time I’m not putting it into a ref. If its never going to change, Vue is happy to work with normal javascript objects and arrays too.

Finally create function to handle the click events. Inside that function we’ll do a little check:

return Array.from(uniqueTracks);
 });
 
+const selectedLevel = ref('');
+
+const levels = ['Introductory and overview', 'Intermediate', 'Advanced'];
+
+function handleSelectLevel(level: string) {
+  if (level == selectedLevel.value) {
+    selectedLevel.value = '';
+  } else {
+    selectedLevel.value = level;
+  }
+}
+
 const filteredSessions = computed(() => {
   let currentSessions = sessions.value;

Then we’ll update the currentSessions computed a lot like we did for selectedTrack: if a selectedLevel exists, we’ll filter the array by level.

     currentSessions = currentSessions.filter((s) => s.track === selectedTrack.value);
   }
 
+  if (selectedLevel.value) {
+    currentSessions = currentSessions.filter((s) => s.level === selectedLevel.value);
+  }
+
   if (searchText.value.length <= 2) {
     return currentSessions;
   }

Now we have all the logic we need, and its time to connect it to the template.

Updating the markup

Firt we’ll update the button group and replace our hard-coded samples with markup driven from the levels array and the v-for directive.

We hit a fun wrinkle here: v-for can only be applied to a single root element, but the markup needed by Bootstrap’s ButtonGroup has two elements: the input and the label. If we introduced an empty div or span on which to apply the v-for we would break the CSS of the button group.

Luckily Vue will let us wrap them in a <template> tag which will be elided at runtime: there will be no element wrapping each input. Create a <template> tag and put the v-for and :key attributes on it as needed.

On the <input> set:

On the <label>:

  <div class="btn-group" role="group" aria-label="Basic radio toggle button group">
-   <input type="radio" class="btn-check" name="btnradio" id="Introductory and overview" autocomplete="off" />
-   <label class="btn btn-outline-primary" for="Introductory and overview">Introductory and overview</label>
-
-   <input type="radio" class="btn-check" name="btnradio" id="Intermediate" autocomplete="off" />
-   <label class="btn btn-outline-primary" for="Intermediate">Intermediate</label>
-
-   <input type="radio" class="btn-check" name="btnradio" id="Advanced" autocomplete="off" />
-   <label class="btn btn-outline-primary" for="Advanced">Advanced</label>
+   <template v-for="level in levels" :key="level">
+     <input
+       type="radio"
+       class="btn-check"
+       name="btnradio"
+       :id="level"
+       autocomplete="off"
+       @click="handleSelectLevel(level)"
+       :checked="level === selectedLevel"
+     />
+     <label class="btn btn-outline-primary" :for="level">{{ level }}</label>
+   </template>
  </div>

That’s it! If everything went well, you can now filter the sessions by skill level as well. Make sure you can activate and deactivate the level filter

Recap

Further Reading

Bonus Exercises