Refactor page components, reduce flicker, remove orphaned module

Move some business logic out of the page components. They are now
responsible for just filtering out the unsuccessful render paths
and selecting between an error view or a success view passing the
successfully fetched data.
This commit is contained in:
Thiago Chaves 2022-07-16 11:53:23 +03:00
parent b43abafa1c
commit d4b838106a
35 changed files with 344 additions and 313 deletions

View File

@ -1,7 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { UrlObject } from "url";
export interface NavigationItemProps { export interface NavigationItemProps {
text: string; text: string;

View File

@ -5,7 +5,6 @@ import React from "react";
import { ComponentStory, ComponentMeta } from "@storybook/react"; import { ComponentStory, ComponentMeta } from "@storybook/react";
import { Page } from "./Page"; import { Page } from "./Page";
import { Navigation, NavigationItem } from "../Navigation";
export default { export default {
title: "Components/Page", title: "Components/Page",
@ -13,7 +12,7 @@ export default {
} as ComponentMeta<typeof Page>; } as ComponentMeta<typeof Page>;
export const Example: ComponentStory<typeof Page> = (args) => ( export const Example: ComponentStory<typeof Page> = (args) => (
<Page> <Page title="Some title">
<div style={{ display: "flex", height: "100%" }}> <div style={{ display: "flex", height: "100%" }}>
<div style={{ margin: "auto" }}>Content goes here</div> <div style={{ margin: "auto" }}>Content goes here</div>
</div> </div>

View File

@ -1,2 +1,5 @@
export * from "./ExpressionCard"; export * from "./ExpressionCard";
export * from "./ExpressionSetCard";
export * from "./ExpressionSetInfo";
export * from "./Navigation"; export * from "./Navigation";
export * from "./Page";

6
src/hooks/index.ts Normal file
View File

@ -0,0 +1,6 @@
export * from "./useExpressionCategories";
export * from "./useExpressionFilterQueryIds";
export * from "./useExpressionSet";
export * from "./useExpressionSetQueryId";
export * from "./useExpressionSets";
export * from "./useExpressionsInSet";

View File

@ -2,15 +2,13 @@ import { useLiveQuery } from "dexie-react-hooks";
import { database } from "../model"; import { database } from "../model";
export function useExpressionCategories(expression_id: number) { export function useExpressionCategories(expression_id: number) {
return ( return useLiveQuery(() => {
useLiveQuery(() => { return database.expression_to_category
return database.expression_to_category .where({ expression_id })
.where({ expression_id }) .toArray()
.toArray() .then((relationships) => {
.then((relationships) => { const category_ids = relationships.map((item) => item.category_id);
const category_ids = relationships.map((item) => item.category_id); return database.categories.where("id").anyOf(category_ids).toArray();
return database.categories.where("id").anyOf(category_ids).toArray(); });
}); }, [expression_id]);
}, [expression_id]) || []
);
} }

View File

@ -1,6 +1,6 @@
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { database, IndexedExpressionSet } from "../model"; import { database } from "../model";
export function useExpressionSets() { export function useExpressionSets() {
return useLiveQuery(() => database.expression_sets.toArray()) || []; return useLiveQuery(() => database.expression_sets.toArray());
} }

View File

@ -2,20 +2,13 @@ import { useLiveQuery } from "dexie-react-hooks";
import { database } from "../model"; import { database } from "../model";
export function useExpressionsInSet(expression_set_id: number) { export function useExpressionsInSet(expression_set_id: number) {
return ( return useLiveQuery(() => {
useLiveQuery(() => { return database.expression_to_expression_set
return database.expression_to_expression_set .where({ expression_set_id })
.where({ expression_set_id }) .toArray()
.toArray() .then((relationships) => {
.then((relationships) => { const expression_ids = relationships.map((item) => item.expression_id);
const expression_ids = relationships.map( return database.expressions.where("id").anyOf(expression_ids).toArray();
(item) => item.expression_id });
); }, [expression_set_id]);
return database.expressions
.where("id")
.anyOf(expression_ids)
.toArray();
});
}, [expression_set_id]) || []
);
} }

View File

@ -1 +0,0 @@
export * from "./mock-data";

View File

@ -4,7 +4,7 @@ import {
ExpressionSet, ExpressionSet,
ExpressionToCategory, ExpressionToCategory,
ExpressionToExpressionSet, ExpressionToExpressionSet,
} from "../model"; } from "../model/types";
interface RawExpressionDataItem { interface RawExpressionDataItem {
prompt: string; prompt: string;

View File

@ -1,5 +1,5 @@
import { MockData } from "../mock/mock-data";
import Dexie, { Table } from "dexie"; import Dexie, { Table } from "dexie";
import { MockData } from "../mock";
import { import {
Category, Category,
Expression, Expression,

View File

@ -1,2 +1,2 @@
export * from "./types";
export * from "./database"; export * from "./database";
export * from "./types";

View File

@ -1,10 +1,10 @@
import type { NextPage } from "next"; import type { NextPage } from "next";
import { PageWithError } from "../views/PageWithError"; import { ErrorView } from "../views";
const PageTitle = "Flash Card App - 404"; const PageTitle = "Flash Card App - 404";
const Page404: NextPage = () => { const Page404: NextPage = () => {
return <PageWithError title={PageTitle} message="404 - content not found" />; return <ErrorView title={PageTitle} message="404 - content not found" />;
}; };
export default Page404; export default Page404;

View File

@ -3,7 +3,7 @@ import "../styles/components.css";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import { Navigation } from "../components"; import { Navigation } from "../components";
export default function MyApp({ Component, pageProps }: AppProps) { export default function FlashCardApp({ Component, pageProps }: AppProps) {
return ( return (
<div className="page"> <div className="page">
<header> <header>

View File

@ -1,12 +1,13 @@
import type { NextPage } from "next"; import type { NextPage } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { ExpressionSetInfo } from "../../components/ExpressionSetInfo/ExpressionSetInfo"; import { ExpressionSetInfo, Page } from "../../components";
import { Page } from "../../components/Page"; import {
import { useExpressionSet } from "../../hooks/useExpressionSet"; useExpressionSet,
import { useExpressionSetQueryId } from "../../hooks/useExpressionSetQueryId"; useExpressionSetQueryId,
import { useExpressionsInSet } from "../../hooks/useExpressionsInSet"; useExpressionsInSet,
import { PageWithError } from "../../views/PageWithError/PageWithError"; } from "../../hooks";
import { ErrorView, ExpressionSetDetailsView } from "../../views";
function pageTitle(name?: string) { function pageTitle(name?: string) {
return `Flash Card App - ${name || "Sets"}`; return `Flash Card App - ${name || "Sets"}`;
@ -18,53 +19,18 @@ const ExpressionSetDetailsPage: NextPage = () => {
const expressions = useExpressionsInSet(expression_set_id); const expressions = useExpressionsInSet(expression_set_id);
// Fallback for expression set not found // Fallback for expression set not found
if (!expressions) return null;
if (!expression_set) { if (!expression_set) {
return ( return <ErrorView title={pageTitle()} message="Expression set not found" />;
<PageWithError title={pageTitle()} message="Expression set not found" />
);
} }
// Fallback for expression set empty // Fallback for expression set empty
if (expressions.length === 0)
return (
<Page title={pageTitle(expression_set.name)}>
<div className="page-with-padding scroll">
<ExpressionSetInfo
id={expression_set.id!}
name={expression_set.name}
description={expression_set.description}
expression_count={expressions.length}
/>
<p className="text-details">No expressions left in this set.</p>
</div>
</Page>
);
return ( return (
<Page title={pageTitle(expression_set.name)}> <Page title={pageTitle(expression_set.name)}>
<div className="page-with-bottom-navigation"> <ExpressionSetDetailsView
<section className="padding-small scroll"> expression_set={expression_set}
<ExpressionSetInfo expression_count={expressions.length}
id={expression_set.id!} />
name={expression_set.name}
description={expression_set.description}
expression_count={expressions.length}
/>
</section>
<section className="navigation-bottom">
<Link
href={{
pathname: "/expression-sets/practice",
query: { "set-id": expression_set.id },
}}
passHref
>
<a className="navigation-item bottom text-navigation grow">
<span>Practice this set</span>
</a>
</Link>
</section>
</div>
</Page> </Page>
); );
}; };

View File

@ -1,61 +1,25 @@
import type { NextPage } from "next"; import type { NextPage } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link"; import { Page } from "../../components";
import { ExpressionSetCard } from "../../components/ExpressionSetCard"; import { useExpressionSets } from "../../hooks";
import { Page } from "../../components/Page"; import { ErrorView, ExpressionSetListView } from "../../views";
import { useExpressionSets } from "../../hooks/useExpressionSets";
import { useExpressionsInSet } from "../../hooks/useExpressionsInSet";
import { IndexedExpressionSet } from "../../model";
import { PageWithError } from "../../views/PageWithError";
const PageTitle = "Flash Card App - Sets"; const PageTitle = "Flash Card App - Sets";
const ExpressionSetListPage: NextPage = () => { const ExpressionSetListPage: NextPage = () => {
const expression_sets = useExpressionSets(); const expression_sets = useExpressionSets();
if (!expression_sets) return null;
if (!expression_sets?.length) { if (!expression_sets.length) {
return ( return <ErrorView title={PageTitle} message="No expression sets found" />;
<PageWithError title={PageTitle} message="No expression sets found" />
);
} }
return ( return (
<Page title={PageTitle}> <Page title={PageTitle}>
<div className="page-with-padding content-list scroll"> <ExpressionSetListView expression_sets={expression_sets} />
{expression_sets.map(({ id, name, description }) => (
<ExpressionSetLink
key={id}
id={id}
name={name}
description={description}
/>
))}
</div>
</Page> </Page>
); );
}; };
function ExpressionSetLink({ id, description, name }: IndexedExpressionSet) {
const expressions = useExpressionsInSet(id!) || [];
return (
<Link
href={{
pathname: "/expression-sets/details",
query: { "set-id": id },
}}
passHref
>
<a>
<ExpressionSetCard
name={name}
description={description}
expression_count={expressions.length}
/>
</a>
</Link>
);
}
export default dynamic(() => Promise.resolve(ExpressionSetListPage), { export default dynamic(() => Promise.resolve(ExpressionSetListPage), {
ssr: false, ssr: false,
}); });

View File

@ -1,162 +1,44 @@
import type { NextPage } from "next"; import { NextPage } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useRouter } from "next/router"; import { Page } from "../../components";
import { useCallback, useMemo, useState } from "react";
import { ExpressionCard } from "../../components";
import { Page } from "../../components/Page";
import { useExpressionCategories } from "../../hooks/useExpressionCategories";
import { useExpressionFilterQueryIds } from "../../hooks/useExpressionFilterQueryIds";
import { useExpressionSetQueryId } from "../../hooks/useExpressionSetQueryId";
import { useExpressionsInSet } from "../../hooks/useExpressionsInSet";
import { import {
assignExpressionToSet, useExpressionFilterQueryIds,
IndexedCategory, useExpressionSetQueryId,
IndexedExpression, useExpressionsInSet,
} from "../../model"; } from "../../hooks";
import { sample } from "../../util/array-utils"; import { ErrorView } from "../../views";
import { PageWithError } from "../../views/PageWithError/PageWithError"; import { ExpressionPracticeView } from "../../views";
const PageTitle = "Flash Card App - Practice"; const PageTitle = "Flash Card App - Practice";
// Do random selection here so we don't keep flipping states with interaction
const ExpressionPracticePage: NextPage = () => { const ExpressionPracticePage: NextPage = () => {
// Query info
const expression_set_id = useExpressionSetQueryId(); const expression_set_id = useExpressionSetQueryId();
const filter_ids = useExpressionFilterQueryIds(); const filter_ids = useExpressionFilterQueryIds();
const expressions = useExpressionsInSet(expression_set_id);
// Filter out failed expressions and select random expression // Fallback rendering for content not yet fetched
const expressions = useExpressionsInSet(expression_set_id).filter( if (!expressions) return null;
(expression) => !filter_ids.includes(expression.id!) if (!filter_ids) return null;
);
const expression: IndexedExpression | undefined = useMemo(
() => (expressions ? sample(expressions) : undefined),
[expressions]
);
// Fetch categories that the expression relates to
const categories = useExpressionCategories(expression?.id || 0);
// Fallback views for expression set content not found // Fallback views for expression set content not found
if (!expression_set_id) { if (!expression_set_id) {
return ( return <ErrorView title={PageTitle} message="Expression set not found" />;
<PageWithError title={PageTitle} message="Expression set not found" />
);
} }
if (!expression) { const filtered_expressions = expressions.filter(
return ( (expression) => !filter_ids.includes(expression.id!)
<PageWithError
title={PageTitle}
message="No expressions left in this set"
/>
);
}
return (
<ExpressionCardPracticeView
key={expression.id}
expression={expression}
categories={categories}
/>
); );
}; if (!filtered_expressions.length) {
return (
<ErrorView title={PageTitle} message="No expressions left in this set" />
);
}
interface ExpressionCardPracticeViewProps {
expression: IndexedExpression;
categories: IndexedCategory[];
}
// Handle internal state here
function ExpressionCardPracticeView({
expression,
categories,
}: ExpressionCardPracticeViewProps) {
const [revealed, setRevealed] = useState(false);
return ( return (
<Page title={PageTitle}> <Page title={PageTitle}>
<div className="page-with-bottom-navigation"> <ExpressionPracticeView select_from_expressions={filtered_expressions} />
<section className="padding-small scroll">
<ExpressionCard
prompt={expression.prompt}
categories={categories.map((category) => category.name)}
description={expression.description}
show_description={revealed}
/>
</section>
<section className="navigation-bottom">
{revealed ? (
<>
<DemoteExpressionButton expression_id={expression.id!} />
<PromoteExpressionButton expression_id={expression.id!} />
</>
) : (
<button
className="navigation-item bottom text-navigation grow"
onClick={() => setRevealed(true)}
>
Reveal
</button>
)}
</section>
</div>
</Page> </Page>
); );
} };
interface ExpressionIdProps {
expression_id: number;
}
function PromoteExpressionButton({ expression_id }: ExpressionIdProps) {
const expression_set_id = useExpressionSetQueryId();
const handleClick = useCallback(() => {
assignExpressionToSet({
expression_id,
expression_set_id: expression_set_id + 1,
});
}, [expression_id, expression_set_id]);
return (
<button
className="navigation-item bottom text-navigation grow"
onClick={handleClick}
>
<span>Right</span>
</button>
);
}
function DemoteExpressionButton({ expression_id }: ExpressionIdProps) {
const { query, pathname, push } = useRouter();
const expression_set_id = useExpressionSetQueryId();
const handleClick = useCallback(() => {
if (expression_set_id === 1) {
const filter_ids = query["filter-ids"]
? `${query["filter-ids"]} ${expression_id}`
: `${expression_id}`;
push({
pathname,
query: {
...query,
"filter-ids": filter_ids,
},
});
} else {
assignExpressionToSet({
expression_id,
expression_set_id: Math.max(1, expression_set_id - 1),
});
}
}, [expression_id, expression_set_id, pathname, push, query]);
return (
<button
className="navigation-item bottom text-navigation grow"
onClick={handleClick}
>
<span>Wrong</span>
</button>
);
}
export default dynamic(() => Promise.resolve(ExpressionPracticePage), { export default dynamic(() => Promise.resolve(ExpressionPracticePage), {
ssr: false, ssr: false,

View File

@ -1,5 +1,5 @@
import type { NextPage } from "next"; import type { NextPage } from "next";
import { Page } from "../components/Page"; import { Page } from "../components";
const PageTitle = "Flash Card App"; const PageTitle = "Flash Card App";

View File

@ -1,40 +0,0 @@
import {
Category,
Expression,
ExpressionToCategory,
ExpressionToExpressionSet,
} from "../model";
interface GetExpressionsInSetParams {
expression_set_id: number;
expressions: Expression[];
expression_to_expression_set: ExpressionToExpressionSet[];
}
export function getExpressionsInSet({
expression_set_id,
expression_to_expression_set,
expressions,
}: GetExpressionsInSetParams): Expression[] {
const expression_ids = expression_to_expression_set
.filter((item) => item.expression_set_id === expression_set_id)
.map((item) => item.expression_id);
return expressions.filter((item) => expression_ids.includes(item.id));
}
interface GetCategoriesInExpressionParams {
expression_id: number;
categories: Category[];
expression_to_category: ExpressionToCategory[];
}
export function getCategoriesInExpression({
categories,
expression_id,
expression_to_category,
}: GetCategoriesInExpressionParams) {
const category_ids = expression_to_category
.filter((item) => item.expression_id === expression_id)
.map((item) => item.category_id);
return categories.filter((item) => category_ids.includes(item.id));
}

2
src/util/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./array-utils";
export * from "./clamp";

View File

@ -1,11 +1,11 @@
import { Page } from "../../components/Page"; import { Page } from "../../components/Page";
export interface PageWithErrorProps { export interface ErrorViewProps {
title: string; title: string;
message: string; message: string;
} }
export function PageWithError({ title, message }: PageWithErrorProps) { export function ErrorView({ title, message }: ErrorViewProps) {
return ( return (
<Page title={title}> <Page title={title}>
<div className="page-with-padding"> <div className="page-with-padding">

View File

@ -0,0 +1 @@
export * from "./ErrorView";

View File

@ -0,0 +1,45 @@
import { useRouter } from "next/router";
import { useCallback } from "react";
import { useExpressionSetQueryId } from "../../hooks";
import { assignExpressionToSet } from "../../model";
// TODO fix promotion algorithm so it uses a destination expression_set_id
export interface DemoteExpressionButtonProps {
expression_id: number;
}
export function DemoteExpressionButton({
expression_id,
}: DemoteExpressionButtonProps) {
const { query, pathname, push } = useRouter();
const expression_set_id = useExpressionSetQueryId();
const handleClick = useCallback(() => {
if (expression_set_id === 1) {
const filter_ids = query["filter-ids"]
? `${query["filter-ids"]} ${expression_id}`
: `${expression_id}`;
push({
pathname,
query: {
...query,
"filter-ids": filter_ids,
},
});
} else {
assignExpressionToSet({
expression_id,
expression_set_id: Math.max(1, expression_set_id - 1),
});
}
}, [expression_id, expression_set_id, pathname, push, query]);
return (
<button
className="navigation-item bottom text-navigation grow"
onClick={handleClick}
>
<span>Wrong</span>
</button>
);
}

View File

@ -0,0 +1,45 @@
import { useState } from "react";
import { ExpressionCard } from "../../components";
import { IndexedExpression, IndexedCategory } from "../../model";
import { DemoteExpressionButton } from "./DemoteExpressionButton";
import { PromoteExpressionButton } from "./PromoteExpressionButton";
export interface ExpressionPracticeCardViewProps {
expression: IndexedExpression;
categories: IndexedCategory[];
}
// Handle internal state here
export function ExpressionPracticeCardView({
expression,
categories,
}: ExpressionPracticeCardViewProps) {
const [revealed, setRevealed] = useState(false);
return (
<div className="page-with-bottom-navigation">
<section className="padding-small scroll">
<ExpressionCard
prompt={expression.prompt}
categories={categories.map((category) => category.name)}
description={expression.description}
show_description={revealed}
/>
</section>
<section className="navigation-bottom">
{revealed ? (
<>
<DemoteExpressionButton expression_id={expression.id!} />
<PromoteExpressionButton expression_id={expression.id!} />
</>
) : (
<button
className="navigation-item bottom text-navigation grow"
onClick={() => setRevealed(true)}
>
Reveal
</button>
)}
</section>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { useMemo } from "react";
import { useExpressionCategories } from "../../hooks";
import { IndexedExpression } from "../../model";
import { sample } from "../../util";
import { ExpressionPracticeCardView } from "./ExpressionPracticeCardView";
export interface ExpressionPracticeViewProps {
select_from_expressions: IndexedExpression[];
}
export function ExpressionPracticeView({
select_from_expressions: expressions,
}: ExpressionPracticeViewProps) {
const expression = useMemo(() => sample(expressions), [expressions]);
const categories = useExpressionCategories(expression.id!);
if (!categories) return null;
// Delegate internal interaction state to next component so it resets
// on an expression switch
return (
<ExpressionPracticeCardView
key={expression.id}
expression={expression}
categories={categories}
/>
);
}

View File

@ -0,0 +1,30 @@
import { useCallback } from "react";
import { useExpressionSetQueryId } from "../../hooks";
import { assignExpressionToSet } from "../../model";
// TODO fix promotion algorithm so it uses a destination expression_set_id
export interface PromoteExpressionButtonProps {
expression_id: number;
}
export function PromoteExpressionButton({
expression_id,
}: PromoteExpressionButtonProps) {
const expression_set_id = useExpressionSetQueryId();
const handleClick = useCallback(() => {
assignExpressionToSet({
expression_id,
expression_set_id: expression_set_id + 1,
});
}, [expression_id, expression_set_id]);
return (
<button
className="navigation-item bottom text-navigation grow"
onClick={handleClick}
>
<span>Right</span>
</button>
);
}

View File

@ -0,0 +1 @@
export * from "./ExpressionPracticeView";

View File

@ -0,0 +1,53 @@
import Link from "next/link";
import { ExpressionSetInfo } from "../../components";
import { IndexedExpressionSet } from "../../model";
export interface ExpressionSetDetailsViewProps {
expression_set: IndexedExpressionSet;
expression_count: number;
}
export function ExpressionSetDetailsView({
expression_set,
expression_count,
}: ExpressionSetDetailsViewProps) {
if (!expression_count) {
return (
<div className="page-with-padding scroll">
<ExpressionSetInfo
id={expression_set.id!}
name={expression_set.name}
description={expression_set.description}
expression_count={expression_count}
/>
<p className="text-details">No expressions left in this set.</p>
</div>
);
}
return (
<div className="page-with-bottom-navigation">
<section className="padding-small scroll">
<ExpressionSetInfo
id={expression_set.id!}
name={expression_set.name}
description={expression_set.description}
expression_count={expression_count}
/>
</section>
<section className="navigation-bottom">
<Link
href={{
pathname: "/expression-sets/practice",
query: { "set-id": expression_set.id },
}}
passHref
>
<a className="navigation-item bottom text-navigation grow">
<span>Practice this set</span>
</a>
</Link>
</section>
</div>
);
}

View File

@ -0,0 +1 @@
export * from "./ExpressionSetDetailsView";

View File

@ -0,0 +1,30 @@
import Link from "next/link";
import { ExpressionSetCard } from "../../components";
import { useExpressionsInSet } from "../../hooks";
import { IndexedExpressionSet } from "../../model";
export function ExpressionSetLink({
id,
description,
name,
}: IndexedExpressionSet) {
const expressions = useExpressionsInSet(id!) || [];
return (
<Link
href={{
pathname: "/expression-sets/details",
query: { "set-id": id },
}}
passHref
>
<a>
<ExpressionSetCard
name={name}
description={description}
expression_count={expressions.length}
/>
</a>
</Link>
);
}

View File

@ -0,0 +1,23 @@
import { IndexedExpressionSet } from "../../model";
import { ExpressionSetLink } from "./ExpressionSetLink";
export interface ExpressionSetListViewProps {
expression_sets: IndexedExpressionSet[];
}
export function ExpressionSetListView({
expression_sets,
}: ExpressionSetListViewProps) {
return (
<div className="page-with-padding content-list scroll">
{expression_sets.map(({ id, name, description }) => (
<ExpressionSetLink
key={id}
id={id}
name={name}
description={description}
/>
))}
</div>
);
}

View File

@ -0,0 +1 @@
export * from "./ExpressionSetListView";

View File

@ -1 +0,0 @@
export * from "./PageWithError";

4
src/views/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./ErrorView";
export * from "./ExpressionPracticeView";
export * from "./ExpressionSetDetailsView";
export * from "./ExpressionSetListView";