byte by byte

Build a movie search app with Dojo

Rene Rubalcava | November 10, 2019

I was recently reading this blog post on building a movie search app with React hooks, and thought it was a pretty good candidate for building a Dojo app.

For this application, we'll be using the OMDb API where you can also sign up for a free API key.

Getting Started

We can start with a basic dojo template app.

dojo create app --name movie-search

Go ahead and remove the routes and the widgets you get by default. This application will contain three distinct elements, a Header, a Search tool, and a Movie card.

Data

First thing is first, since we're working in TypeScript, let's define the data that is going to be used in our application.

The OMDb API will return each movie with the following interface.

// src/Data.ts
export interface Record {
  Poster: string;
  Title: string;
  Year: string;
}

We'll refer to it as a Record. The State of my application will contain an array of Record values and a loading property.

// src/Data.ts
export interface State {
  loading: boolean;
  movies: Record[];
}

Awesome, now that we know what kind of interfaces we'll be working with, we can start on writing some widgets.

The Header widget is only going to display the name of the application.

// src/widgets/Header.tsx
import { create, tsx } from "@dojo/framework/core/vdom";

import * as css from "./styles/Header.m.css";

interface HeaderProperties {
  title: string;
}

const factory = create().properties<HeaderProperties>();

export const Header = factory(function Header({ properties }) {
  const { title } = properties();
  return (
    <header classes={[css.root]}>
      <h2 classes={[css.text]}>{title}</h2>
    </header>
  );
});

This widget contains no internal state, so it will just take a title property and display it.

Movie

The next widget we can make will be the Movie card. The application will display a series of movie cards. We could make an entire widget to encapsulate the movies, but we'll stick with a simple list of cards.

// src/widgets/Movie.tsx
import { create, tsx } from "@dojo/framework/core/vdom";

import * as css from "./styles/Movie.m.css";

import { Record } from "../Data";

const DEFAULT_PLACEHOLDER_IMAGE =
  "image_url";

const factory = create().properties<{ movie: Record }>();

export const Movie = factory(function Movie({ properties }) {
  const { movie } = properties();
  const poster =
    movie.Poster === "N/A" ? DEFAULT_PLACEHOLDER_IMAGE : movie.Poster;
  return (
    <div classes={css.root}>
      <h2>{movie.Title}</h2>
      <div>
        <img
          width="200"
          alt={`The movie titled: ${movie.Title}`}
          src={poster}
        />
      </div>
      <p>({movie.Year})</p>
    </div>
  );
});

Before we start on our Search widget, let's build our search functionality.

Stores and Processes

In Dojo, we'll want to provide our store as middleware in our widgets, so let's make a helper for that.

// src/middleware/store.ts
import createStoreMiddleware from "@dojo/framework/core/middleware/store";

import { State } from "../Data";

export default createStoreMiddleware<State>();

That's pretty simple. The reason we want this middleware store is so our widgets can execute processes to interact with external data sources and thus provide data back to our widgets.

// src/processes/search.ts
import {
  createCommandFactory,
  createProcess,
  ProcessCallback
} from "@dojo/framework/stores/process";
import { add, replace } from "@dojo/framework/stores/state/operations";

import { State } from "../Data";

const API_KEY = "INSERT_KEY_HERE";

const MOVIE_API_URL = `https://www.omdbapi.com/?s=man&apikey=${API_KEY}`;

// handle updating the loading state when
// fetching data
const progress: ProcessCallback = () => ({
  before(payload, { apply, path }) {
    // update the app store before the process is run
    apply([replace(path("loading"), true)], true);
  },
  after(error, { apply, path }) {
    // update the app store when process is finished
    apply([replace(path("loading"), false)], true);
  }
});

const commandFactory = createCommandFactory<State>();

// Fetch some initial movies to populate the application
const fetchInitialMoviesCommand = commandFactory(async ({ path }) => {
  const response = await fetch(MOVIE_API_URL);
  const json = await response.json();
  return [add(path("movies"), json.Search)];
});

// search for movies
const fetchMoviesCommand = commandFactory(
  async ({ path, payload: { value } }) => {
    const response = await fetch(
      `https://www.omdbapi.com/?s=${value}&apikey=${API_KEY}`
    );
    const json = await response.json();
    return [replace(path("movies"), json.Search)];
  }
);

// initial movies process
export const fetchInitialMovies = createProcess(
  "fetch-initial-movies",
  [fetchInitialMoviesCommand],
  [progress]
);

// search movies process
export const fetchMovies = createProcess(
  "fetch-movies",
  [fetchMoviesCommand],
  [progress]
);

This process is going to search for movies from the OMDb API and then update the results using return [replace(path("movies"), json.Search)]. This will update the movies value of our application state with our search results.

With the store and process complete, we can beging writing our Search widget to perform the important task of actually searching for movies.

The Search widget will have some internal state to manage the search phrases, so we will use the icache middleware.

// src/widgets/Search.tsx
import { create, tsx } from "@dojo/framework/core/vdom";
import icache from "@dojo/framework/core/middleware/icache";
import store from "../middleware/store";
import { fetchMovies } from "../processes/search";

import * as css from "./styles/Search.m.css";

const factory = create({ icache, store });

export const Search = factory(function Search({
  middleware: { icache, store }
}) {
  // get current or default empty value
  const value = icache.getOrSet("value", "");
  return (
    <form classes={css.root}>
      <input
        classes={[css.text]}
        value={value}
        onchange={(evt: Event) => {
          // when input value changes,
          // update internal state value
          const target = evt.target as HTMLInputElement;
          icache.set("value", target.value);
        }}
        type="text"
      />
      <input
        classes={[css.submit]}
        onclick={(evt: Event) => {
          evt.preventDefault();
          const value = icache.get("value");
          // take value of internal state and
          // use the store to execute the search
          store.executor(fetchMovies)({ value });
        }}
        type="submit"
        value="SEARCH"
      />
    </form>
  );
});

The core widgets for our application are now ready and we can bring them together in an App widget.

App

The App widget will load some initial state if needed and display the results.

import { create, tsx } from "@dojo/framework/core/vdom";

import * as css from "./styles/App.m.css";

import { Header } from "./Header";
import { Movie } from "./Movie";
import { Search } from "./Search";

import store from "../middleware/store";
import { fetchInitialMovies } from "../processes/search";

const factory = create({ store });

export const App = factory(function App({ middleware: { store } }) {
  const { get, path } = store;
  const loading = get(path("loading"));
  const movies = get(path("movies"));
  // if no movies currently loaded
  // fetch some movies to display
  if (!movies) {
    store.executor(fetchInitialMovies)({});
  }
  return (
    <div classes={[css.root]}>
      <Header title="Dojo Movie Search" />
      <Search />
      <p>Sharing a few of our favorite movies</p>
      <div classes={[css.movies]}>
        {loading ? (
          <span classes={[css.loader]}>loading...</span>
        ) : movies ? (
          movies.map((movie, index) => (
            <Movie key={`${index}-${movie.Title}`} movie={movie} />
          ))
        ) : (
          <virtual />
        )}
      </div>
    </div>
  );
});

In the App widget, we are going to request movies if needed and then quickly display some loading text if the application is currently fetching results. If we have some movie results, we can map over those results and create a Movie card for each one.

From here, we can render our application in our main file.

// src/main.tsx
import { renderer, tsx } from "@dojo/framework/core/vdom";

import { App } from "./widgets/App";

const r = renderer(() => <App />);
r.mount();

Your completed application should look like this.

Summary

I had a lot of fun putting this little movie search application together. Processes and Stores can be very flexible to fetch and transform data, as well as manage various states while loading data. As usual, keep the actual widgets as simple as possible and we can make some really cool applications!