add basic Catalogue page
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Ohm Patel 2024-02-20 22:06:26 -05:00
parent a7512bd851
commit c866aa920c
13 changed files with 394 additions and 101 deletions

View File

@ -1,85 +1,117 @@
import { DetailedBook } from "@/lib/book";
import { cloneInstance } from "@/lib/utils";
import type { Book } from "@/models/book";
test("Correct book full", () => {
expect(
() =>
new DetailedBook({
id: 1,
title: "The Title",
category: ["fiction", "signed"],
last_updated: new Date(),
deleted: false,
subtitle: "🐄🐮🐄🐮🐄",
authors: "Polish cow",
isbn: "1234567890",
lccn: "1234567890",
edition: "1st",
publisher: "The Publisher",
publish_year: 2021,
publish_month: "January",
publish_location: "Poland",
pages: 100,
weight: "100g",
})
).not.toThrow();
describe("DetailedBook constructor tests", () => {
test("Correct book full", () => {
expect(
() =>
new DetailedBook({
id: 1,
title: "The Title",
category: ["fiction", "signed"],
last_updated: new Date(),
deleted: false,
subtitle: "🐄🐮🐄🐮🐄",
authors: "Polish cow; Tom Scott",
isbn: "1234567890",
lccn: "1234567890",
edition: "1st",
publisher: "The Publisher",
publish_year: 2021,
publish_month: "January",
publish_location: "Poland",
pages: 100,
weight: "100g",
})
).not.toThrow();
});
test("Correct book partial", () => {
expect(
() =>
new DetailedBook({
id: 1,
title: "The Title",
category: ["fiction", "signed"],
isbn: "1234567890",
authors: "Polish cow; Tom Scott",
subtitle: "🐄🐮🐄🐮🐄",
})
).not.toThrow();
});
test("Incorrect book no title", () => {
expect(
() =>
new DetailedBook({
id: 1,
category: ["fiction", "signed"],
last_updated: new Date("2021-01-01T00:00:00Z"),
deleted: false,
subtitle: "🐄🐮🐄🐮🐄",
authors: "Polish cow",
isbn: "1234567890",
lccn: "1234567890",
edition: "1st",
publisher: "The Publisher",
publish_year: 2021,
publish_month: "January",
publish_location: "Poland",
pages: 100,
weight: "100g",
} as Book)
).toThrow();
});
test("Incorrect book bad date", () => {
expect(
() =>
new DetailedBook({
id: 1,
title: "The Title",
category: ["fiction", "signed"],
last_updated: "banana",
deleted: false,
subtitle: "🐄🐮🐄🐮🐄",
authors: "Polish cow",
isbn: "1234567890",
lccn: "1234567890",
edition: "1st",
publisher: "The Publisher",
publish_year: 2021,
publish_month: "January",
publish_location: "Poland",
pages: 100,
weight: "100g",
} as unknown as Book)
).toThrow();
});
});
test("Correct book partial", () => {
expect(
() =>
new DetailedBook({
id: 1,
title: "The Title",
category: ["fiction", "signed"],
last_updated: new Date("2021-01-01T00:00:00Z"),
deleted: false,
})
).not.toThrow();
});
describe("Getters and setters", () => {
const book = new DetailedBook({
id: 1,
title: "The Title",
category: ["fiction", "signed"],
isbn: "1234567890",
authors: "Polish cow; Tom Scott",
subtitle: "🐄🐮🐄🐮🐄",
});
test("Incorrect book no title", () => {
expect(
() =>
new DetailedBook({
id: 1,
category: ["fiction", "signed"],
last_updated: new Date("2021-01-01T00:00:00Z"),
deleted: false,
subtitle: "🐄🐮🐄🐮🐄",
authors: "Polish cow",
isbn: "1234567890",
lccn: "1234567890",
edition: "1st",
publisher: "The Publisher",
publish_year: 2021,
publish_month: "January",
publish_location: "Poland",
pages: 100,
weight: "100g",
} as unknown as DetailedBook)
).toThrow();
});
test("authorsList getter", () => {
const clone = cloneInstance(book);
clone.authors = null;
expect(book.authorsList).toEqual(["Polish cow", "Tom Scott"]);
expect(book.authorsList.find((v) => v.includes(";"))).toBeUndefined();
expect(clone.authorsList).toBeInstanceOf(Array);
expect(clone.authorsList).toHaveLength(0);
});
test("Incorrect book bad date", () => {
expect(
() =>
new DetailedBook({
id: 1,
title: "The Title",
category: ["fiction", "signed"],
last_updated: "banana",
deleted: false,
subtitle: "🐄🐮🐄🐮🐄",
authors: "Polish cow",
isbn: "1234567890",
lccn: "1234567890",
edition: "1st",
publisher: "The Publisher",
publish_year: 2021,
publish_month: "January",
publish_location: "Poland",
pages: 100,
weight: "100g",
} as unknown as DetailedBook)
).toThrow();
test("authorsList setter", () => {
const clone = cloneInstance(book);
clone.authorsList = ["Tom Scott", "Polish cow"];
expect(clone.authors).toEqual("Tom Scott; Polish cow");
});
});

39
__tests__/lib/utils.ts Normal file
View File

@ -0,0 +1,39 @@
import { cloneInstance } from "@/lib/utils";
describe("cloneInstance", () => {
class TestClass {
a: number;
b: number;
c: number;
constructor(a: number, b: number, c: number) {
this.a = a;
this.b = b;
this.c = c;
}
equals(other: TestClass) {
return this.a === other.a && this.b === other.b && this.c === other.c;
}
toString() {
return `TestClass(${this.a}, ${this.b}, ${this.c})`;
}
}
test("Clones an object", () => {
const obj = { a: 1, b: 2, c: 3 };
const clone = cloneInstance(obj);
expect(clone).toEqual(obj);
expect(clone).not.toBe(obj);
});
test("Clones an instance of a class", () => {
const obj = new TestClass(1, 2, 3);
const clone = cloneInstance(obj);
expect(clone.equals(obj)).toBe(true);
expect(clone).not.toBe(obj);
expect(clone).toBeInstanceOf(TestClass);
expect(clone.toString()).toBe(obj.toString());
});
});

View File

@ -6,7 +6,7 @@
"author": "Ohm Patel <ohm.patel@uwaterloo.ca>",
"license": "GPL-3.0-or-later",
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev --turbo -p 3001",
"build": "next build",
"start": "next start",
"lint": "prettier -w ./__tests__ ./src && next lint --fix && eslint ./__tests__ --fix",

View File

@ -1,6 +1,6 @@
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<main className="flex min-h-screen flex-col items-center p-24">
<h2 className="text-4xl font-bold">Admin Page</h2>
</main>
);

View File

@ -1,7 +1,12 @@
"use client";
import { BookTable } from "./table";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<h2 className="text-4xl font-bold">Library page</h2>
<main className="flex min-h-screen flex-col items-center p-24">
<h2 className="pb-14 text-4xl font-bold">Catalogue</h2>
{BookTable()}
</main>
);
}

162
src/app/catalogue/table.tsx Normal file
View File

@ -0,0 +1,162 @@
import { getBooks } from "@/lib/api";
import { DetailedBook } from "@/lib/book";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeadCell,
TableRow,
} from "flowbite-react";
import { useState, useEffect, Dispatch, SetStateAction } from "react";
export function BookTable() {
const [books, setBooks] = useState([] as DetailedBook[]);
const [nameFilter, setNameFilter] = useState(null as string | null);
const [catFilter, setCatFilter] = useState([] as string[]);
const [isLoading, setLoading] = useState(true);
useEffect(() => {
getBooks().then((data) => {
if (!data) {
return setLoading(false);
}
setBooks(data);
setLoading(false);
});
}, []);
if (isLoading) return <p>Loading...</p>;
if (books.length <= 0) return <p>No library data</p>;
return (
<div className="w-full">
{renderOptions(setNameFilter, setCatFilter)}
<div className="overflow-x-auto">
{renderTable(books, nameFilter, catFilter)}
</div>
</div>
);
}
let searchNameTo: NodeJS.Timeout;
function renderOptions(
setNameFilter: Dispatch<SetStateAction<string | null>>,
setCatFilter: Dispatch<SetStateAction<string[]>>
) {
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCatFilter; //TODO: implement category selector
clearTimeout(searchNameTo);
searchNameTo = setTimeout(() => {
setNameFilter(e.target.value || null);
}, 500);
};
return (
<div>
<div className="mx-auto max-w-md">
<label
htmlFor="default-search"
className="sr-only mb-2 text-sm font-medium text-gray-900 dark:text-white"
>
Search
</label>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3">
<svg
className="h-4 w-4 text-gray-500 dark:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 20"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
/>
</svg>
</div>
<input
type="search"
id="default-search"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-4 ps-10 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
placeholder="Search book titles or authors..."
onChange={(e) => handleNameChange(e)}
required
/>
</div>
</div>
</div>
);
}
function renderTable(
books: DetailedBook[],
nameFilter: string | null,
categoryFilter: string[]
) {
console.log("namef", nameFilter);
console.log("catf", categoryFilter);
if (nameFilter || categoryFilter.length > 0) {
books = books.filter((book) => {
let pass = true;
if (categoryFilter.length > 0) {
if (book.category.length <= 0) return false;
for (const category of book.category) {
pass = pass && categoryFilter.includes(category);
}
}
if (nameFilter) {
const namePresent =
book.title.toLowerCase().includes(nameFilter.toLowerCase()) ||
book.authorsList.findIndex((author) =>
author.toLowerCase().includes(nameFilter.toLowerCase())
) >= 0;
pass = pass && namePresent;
}
return pass;
});
}
let tableRows = books.map((book) => (
<TableRow key={book.id} className="hover:bg-zinc-400 hover:text-white">
<TableCell>{book.title}</TableCell>
<TableCell>{book.authors}</TableCell>
<TableCell>{book.category.join(", ")}</TableCell>
<TableCell>{book.isbn}</TableCell>
</TableRow>
));
if (tableRows.length <= 0) {
tableRows = [
<TableRow key="no-results">
<TableCell colSpan={4} className="text-center">
No results
</TableCell>
</TableRow>,
];
}
return (
<Table>
<TableHead className="bg-zinc-400">
<TableHeadCell className="w-2/5 ">Title</TableHeadCell>
<TableHeadCell>Authors</TableHeadCell>
<TableHeadCell>Category</TableHeadCell>
<TableHeadCell>ISBN</TableHeadCell>
</TableHead>
<TableBody>{tableRows}</TableBody>
</Table>
);
}

View File

@ -21,12 +21,13 @@
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
/* background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
rgb(var(--background-start-rgb)); */
background-color: --background-start-rgb;
}
@layer utilities {

View File

@ -1,6 +1,6 @@
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<main className="flex min-h-screen flex-col items-center p-24">
<h2 className="text-4xl font-bold">Modify page</h2>
</main>
);

View File

@ -1,6 +1,6 @@
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<main className="flex min-h-screen flex-col items-center p-24">
<h2 className="text-4xl font-bold">Beep Boop</h2>
</main>
);

46
src/lib/api.ts Normal file
View File

@ -0,0 +1,46 @@
import { DetailedBook } from "./book";
export async function getBooks() {
let bookRes = [];
try {
const res = await (await fetch("http://localhost:3000/books")).json();
if (!res.success) {
throw new Error("Failed to fetch/json parse books");
}
bookRes = res.result;
} catch (err) {
console.error("Failed to fetch books: " + err);
return null;
}
const books: DetailedBook[] = [];
for (const book of bookRes) {
// Create a new DetailedBook object and push it to the books array. Use object.assign to copy the properties of the book object to the new DetailedBook object only if the properties are defined
try {
const newBook = new DetailedBook(
Object.assign(
book,
{
id: book.id,
title: book.title,
category: book.category?.length > 0 ? book.category : [],
subtitle: book.subtitle || "",
authors: book.authors || "",
isbn: book.isbn || "",
},
{
last_updated: book.last_updated
? new Date(book.last_updated)
: undefined,
}
)
);
books.push(newBook);
} catch (err) {
console.error(`Failed to create entry for ${book?.title}: ${err}`);
continue;
}
}
return books;
}

View File

@ -1,15 +1,17 @@
import { Book, BookDetailedSchema } from "@/models/book";
// We do not implement Book or ModifyBook and instead only implement DetailedBook for homogeneity
export class DetailedBook {
id: number;
title: string;
isbn: string | null;
category: string[];
last_updated: Date;
deleted: boolean;
subtitle?: string;
authors?: string;
isbn?: string;
authors: string | null;
subtitle: string | null;
// Optional fields ↓
last_updated?: Date;
deleted?: boolean;
lccn?: string;
edition?: string;
publisher?: string;
@ -23,12 +25,12 @@ export class DetailedBook {
this.id = book.id;
this.title = book.title;
this.category = book.category;
this.deleted = book.deleted;
this.last_updated = book.last_updated;
// Optional fields ↓
this.subtitle = book.subtitle;
this.authors = book.authors;
this.isbn = book.isbn;
// Optional fields ↓
this.deleted = book.deleted;
this.last_updated = book.last_updated;
this.lccn = book.lccn;
this.edition = book.edition;
this.publisher = book.publisher;
@ -51,11 +53,11 @@ export class DetailedBook {
// Getters
get authorsList() {
return this.authors?.split(",") || [];
return this.authors?.split(";")?.map((v) => v.trim()) || [];
}
// Setters
set authorsList(authors: string[]) {
this.authors = authors.join(", ");
this.authors = authors.join("; ");
}
}

View File

@ -0,0 +1,6 @@
export function cloneInstance<T>(instance: T): T {
return Object.assign(
Object.create(Object.getPrototypeOf(instance)),
instance
);
}

View File

@ -5,10 +5,10 @@ export type Book = z.infer<typeof BookDetailedSchema>;
export const BookDetailedSchema = z.object({
id: z.number(),
title: z.string(),
subtitle: z.string().optional(),
authors: z.string().optional(),
subtitle: z.string(),
authors: z.string(),
isbn: z.string(),
category: z.array(z.string()),
isbn: z.string().optional(),
lccn: z.string().optional(),
edition: z.string().optional(),
publisher: z.string().optional(),
@ -17,8 +17,8 @@ export const BookDetailedSchema = z.object({
publish_location: z.string().optional(),
pages: z.number().optional(),
weight: z.string().optional(),
last_updated: z.date(),
deleted: z.boolean(),
last_updated: z.date().optional(),
deleted: z.boolean().optional(),
});
/*