add basic Catalogue page
continuous-integration/drone/push Build is failing
Details
continuous-integration/drone/push Build is failing
Details
This commit is contained in:
parent
a7512bd851
commit
c866aa920c
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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("; ");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export function cloneInstance<T>(instance: T): T {
|
||||
return Object.assign(
|
||||
Object.create(Object.getPrototypeOf(instance)),
|
||||
instance
|
||||
);
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
|
||||
/*
|
||||
|
|
Loading…
Reference in New Issue