Appearance
Movie database
4.600 movies from IMDb with nested and array data.
How it works
- Array values:
genres,directorsandactorsare arrays. Without a formatter they are displayed comma separated. The set filter ongenresmatches 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:columnFilterwith 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 withformatters.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>
);
}