Skip to content

Movie database ​

4.600 movies from IMDb with nested and array data.

How it works ​

  • Array values: genres, directors and actors are arrays. Without a formatter they are displayed comma separated. The set filter on genres matches a movie if one of its genres is selected, the quick filter searches in all of them.
  • Cell slots: the rating is rendered as a colored bar, genres as chips.
  • Master / detail: movies with a plot can be expanded (hasDetails) to show plot, directors and actors.
  • Filter presets: the buttons set v-model:columnFilter with typical filter values: a set filter (["Comedy"]), a shorthand number filter (">=7.5") and a filter condition ({ operator: "greaterThan", value: 180 }).
  • Formatting: the runtime (minutes) is formatted as 2h 03m, the release date with formatters.date.
  • Row ids: title and year identify a movie, so the expanded state survives sorting and filtering.
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <input v-model="search" type="search" placeholder="Search title, director, actor…" />
      <button @click="filters = [{ column: 'genres', value: ['Comedy'] }, { column: 'rating', value: '>=7.5' }]">
        Good comedies
      </button>
      <button @click="filters = [{ column: 'runtime', value: { operator: 'greaterThan', value: 180 } }]">
        Longer than 3h
      </button>
      <button @click="filters = []; search = ''">Reset</button>
    </div>
    <Datagrid
      :columns="columns"
      :rows="rows"
      :options="options"
      v-model:globalFilter="search"
      v-model:columnFilter="filters"
    >
      <template #cell-rating="{ value }">
        <span v-if="value != null" class="rating" :style="{ '--rating': value }">
          <span class="rating__bar"></span>
          <span class="rating__value">{{ value.toFixed(1) }}</span>
        </span>
      </template>

      <template #cell-genres="{ value }">
        <span v-for="genre in value" :key="genre" class="genre">{{ genre }}</span>
      </template>

      <template #detail="{ data }">
        <div class="movie-detail">
          <p class="movie-detail__plot">{{ data.plot || "No plot available." }}</p>
          <p><strong>Directed by</strong> {{ data.directors.join(", ") || "–" }}</p>
          <p><strong>Starring</strong> {{ data.actors.join(", ") || "–" }}</p>
        </div>
      </template>
    </Datagrid>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref, shallowRef } from "vue";
import { withBase } from "vitepress";
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";

type Movie = {
  title: string;
  year: number;
  releaseDate: string;
  rating: number | null;
  genres: Array<string>;
  directors: Array<string>;
  actors: Array<string>;
  runtime: number | null;
  plot: string;
  rank: number;
};

const rows = shallowRef<Array<Movie>>();
const search = ref("");
const filters = ref<Array<{ column: string | number; value: unknown }>>([]);

onMounted(async () => {
  rows.value = await (await fetch(withBase("/data/movies.json"))).json();
});

const columns: Array<ColumnConfig<Movie>> = [
  { field: "rank", text: "Rank", type: "number", width: 80, pinned: "left", sort: "asc" },
  { field: "title", text: "Title", width: 260, tooltip: true },
  { field: "year", text: "Year", type: "number", width: 90, filterType: "set" },
  { field: "rating", text: "Rating", type: "number", width: 140 },
  // array values: the set filter matches if one of the genres is selected
  { field: "genres", text: "Genres", width: 260, filterType: "set", sortable: false },
  {
    field: "runtime",
    text: "Runtime",
    type: "number",
    width: 110,
    valueFormatter: ({ value }) => (value ? `${Math.floor(value / 60)}h ${String(value % 60).padStart(2, "0")}m` : ""),
  },
  { field: "releaseDate", text: "Released", type: "date", width: 140, valueFormatter: formatters.date({ dateStyle: "medium" }) },
  // arrays are displayed comma separated
  { field: "directors", text: "Director", width: 180 },
  { field: "actors", text: "Actors", width: 300, tooltip: true },
];

const options: GridOptions<Movie> = {
  rowId: (movie) => `${movie.title}-${movie.year}`,
  // details are only available for movies with a plot
  hasDetails: (row) => !!row.data.plot,
  columnMenu: true,
  defaultColumn: { sortable: true, filterable: true, resizeable: true },
};
</script>

<style>
.rating {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
  height: 18px;
  border-radius: 9px;
  background: var(--ut-row-border-color);
  overflow: hidden;
}
.rating__bar {
  position: absolute;
  inset: 0 auto 0 0;
  width: calc(var(--rating) * 10%);
  background: color-mix(in srgb, #d4a72c calc(var(--rating) * 10%), #cf222e);
  opacity: 0.35;
}
.rating__value {
  position: relative;
  margin-left: auto;
  padding-right: 6px;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}
.genre {
  padding: 0 6px;
  border-radius: 999px;
  font-size: 12px;
  background: color-mix(in srgb, var(--ut-accent-color) 15%, transparent);
}
.movie-detail {
  max-width: 800px;
  padding: 4px 16px;
  line-height: 1.5;
}
.movie-detail p {
  margin: 4px 0;
}
.movie-detail__plot {
  font-style: italic;
}
</style>
tsx
import { useEffect, useState, type CSSProperties } from "react";
import { withBase } from "vitepress";
import {
  Datagrid,
  formatters,
  type ColumnConfig,
  type DatagridProps,
  type FilterModel,
  type GridOptions,
} from "@datagrid/react-ui";

type Movie = {
  title: string;
  year: number;
  releaseDate: string;
  rating: number | null;
  genres: Array<string>;
  directors: Array<string>;
  actors: Array<string>;
  runtime: number | null;
  plot: string;
  rank: number;
};

const columns: Array<ColumnConfig<Movie>> = [
  { field: "rank", text: "Rank", type: "number", width: 80, pinned: "left", sort: "asc" },
  { field: "title", text: "Title", width: 260, tooltip: true },
  { field: "year", text: "Year", type: "number", width: 90, filterType: "set" },
  { field: "rating", text: "Rating", type: "number", width: 140 },
  // array values: the set filter matches if one of the genres is selected
  { field: "genres", text: "Genres", width: 260, filterType: "set", sortable: false },
  {
    field: "runtime",
    text: "Runtime",
    type: "number",
    width: 110,
    valueFormatter: ({ value }) => (value ? `${Math.floor(value / 60)}h ${String(value % 60).padStart(2, "0")}m` : ""),
  },
  { field: "releaseDate", text: "Released", type: "date", width: 140, valueFormatter: formatters.date({ dateStyle: "medium" }) },
  // arrays are displayed comma separated
  { field: "directors", text: "Director", width: 180 },
  { field: "actors", text: "Actors", width: 300, tooltip: true },
];

const options: GridOptions<Movie> = {
  rowId: (movie) => `${movie.title}-${movie.year}`,
  // details are only available for movies with a plot
  hasDetails: (row) => !!row.data.plot,
  columnMenu: true,
  defaultColumn: { sortable: true, filterable: true, resizeable: true },
};

// custom cells by column id, defined outside of the component so they stay stable
const cellRenderers: DatagridProps<Movie>["cellRenderers"] = {
  rating: ({ value }) =>
    value != null && (
      <span className="rating" style={{ "--rating": value } as CSSProperties}>
        <span className="rating__bar"></span>
        <span className="rating__value">{value.toFixed(1)}</span>
      </span>
    ),
  genres: ({ value }) =>
    (value as Array<string> | undefined)?.map((genre) => (
      <span key={genre} className="genre">
        {genre}
      </span>
    )),
};

const renderDetail: DatagridProps<Movie>["renderDetail"] = ({ data }) => (
  <div className="movie-detail">
    <p className="movie-detail__plot">{data.plot || "No plot available."}</p>
    <p>
      <strong>Directed by</strong> {data.directors.join(", ") || "–"}
    </p>
    <p>
      <strong>Starring</strong> {data.actors.join(", ") || "–"}
    </p>
  </div>
);

export default function ShowcaseMovies() {
  // undefined shows the loading indicator
  const [rows, setRows] = useState<Array<Movie>>();
  const [search, setSearch] = useState<unknown>("");
  const [filters, setFilters] = useState<FilterModel>([]);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      const data: Array<Movie> = await (await fetch(withBase("/data/movies.json"))).json();
      if (!cancelled) setRows(data);
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <input
          type="search"
          placeholder="Search title, director, actor…"
          value={String(search ?? "")}
          onChange={(e) => setSearch(e.target.value)}
        />
        <button onClick={() => setFilters([{ column: "genres", value: ["Comedy"] }, { column: "rating", value: ">=7.5" }])}>
          Good comedies
        </button>
        <button onClick={() => setFilters([{ column: "runtime", value: { operator: "greaterThan", value: 180 } }])}>
          Longer than 3h
        </button>
        <button
          onClick={() => {
            setFilters([]);
            setSearch("");
          }}
        >
          Reset
        </button>
      </div>
      <Datagrid
        columns={columns}
        rows={rows}
        options={options}
        globalFilter={search}
        onGlobalFilterChange={setSearch}
        columnFilter={filters}
        onColumnFilterChange={setFilters}
        cellRenderers={cellRenderers}
        renderDetail={renderDetail}
      />
      <style>{`
        .rating {
          position: relative;
          display: flex;
          align-items: center;
          width: 100%;
          height: 18px;
          border-radius: 9px;
          background: var(--ut-row-border-color);
          overflow: hidden;
        }
        .rating__bar {
          position: absolute;
          inset: 0 auto 0 0;
          width: calc(var(--rating) * 10%);
          background: color-mix(in srgb, #d4a72c calc(var(--rating) * 10%), #cf222e);
          opacity: 0.35;
        }
        .rating__value {
          position: relative;
          margin-left: auto;
          padding-right: 6px;
          font-variant-numeric: tabular-nums;
          font-weight: 600;
        }
        .genre {
          padding: 0 6px;
          border-radius: 999px;
          font-size: 12px;
          background: color-mix(in srgb, var(--ut-accent-color) 15%, transparent);
        }
        .movie-detail {
          max-width: 800px;
          padding: 4px 16px;
          line-height: 1.5;
        }
        .movie-detail p {
          margin: 4px 0;
        }
        .movie-detail__plot {
          font-style: italic;
        }
      `}</style>
    </div>
  );
}

Released under the ISC License.