From f7701e3edc7c36657fc4fcb74b613eed142ee8f7 Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Wed, 17 Jul 2024 11:50:07 +0200 Subject: [PATCH] 41 implement a tree view to displays the root mandr and its children (#43) * FileTree component and test * connect dashboard tree to the API * update makefile to easily launch api * fix typos found by auguste * Apply suggestions from code review Co-authored-by: Auguste Baum <52001167+augustebaum@users.noreply.github.com> --------- Co-authored-by: Auguste Baum <52001167+augustebaum@users.noreply.github.com> --- Makefile | 2 +- frontend/.eslintrc.cjs | 4 +- frontend/package-lock.json | 2 +- frontend/package.json | 2 +- frontend/src/App.vue | 16 +++- frontend/src/components/FileTree.vue | 27 ++++-- frontend/src/components/FileTreeItem.vue | 58 +++++++++++ frontend/src/components/LoadingBars.vue | 94 ++++++++++++++++++ .../src/components/__tests__/FileTree.spec.ts | 11 --- frontend/src/components/icons/FolderIcon.vue | 11 +++ frontend/src/components/icons/ManderIcon.vue | 8 ++ frontend/{ => src}/env.d.ts | 0 frontend/src/services/api.ts | 21 ++++ frontend/src/services/utils.ts | 3 + frontend/src/views/DashboardView.vue | 38 +++++++- frontend/tests/components/FileTree.spec.ts | 96 +++++++++++++++++++ frontend/tests/services/api.spec.ts | 22 +++++ frontend/tests/test.utils.ts | 27 ++++++ frontend/tests/views/dashboard.spec.ts | 45 +++++++++ frontend/tsconfig.app.json | 12 ++- frontend/tsconfig.node.json | 5 +- frontend/tsconfig.vitest.json | 12 ++- src/mandr/dashboard/webapp.py | 8 ++ 23 files changed, 482 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/FileTreeItem.vue create mode 100644 frontend/src/components/LoadingBars.vue delete mode 100644 frontend/src/components/__tests__/FileTree.spec.ts create mode 100644 frontend/src/components/icons/FolderIcon.vue create mode 100644 frontend/src/components/icons/ManderIcon.vue rename frontend/{ => src}/env.d.ts (100%) create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/utils.ts create mode 100644 frontend/tests/components/FileTree.spec.ts create mode 100644 frontend/tests/services/api.spec.ts create mode 100644 frontend/tests/test.utils.ts create mode 100644 frontend/tests/views/dashboard.spec.ts diff --git a/Makefile b/Makefile index 64ce39bd..0482e0b9 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ check-wip: python -m pytest tests serve-api: - python -m uvicorn mandr.dashboard.webapp:app --reload --reload-dir src --host 0.0.0.0 --timeout-graceful-shutdown 0 + MANDR_ROOT=.datamander MANDR_PATH=probabl-ai python -m uvicorn mandr.dashboard.webapp:app --reload --reload-dir ./src --host 0.0.0.0 --timeout-graceful-shutdown 0 build-frontend: # build the SPA diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 6f40582d..bcdc223b 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -3,9 +3,9 @@ require('@rushstack/eslint-patch/modern-module-resolution') module.exports = { root: true, - 'extends': [ - 'plugin:vue/vue3-essential', + extends: [ 'eslint:recommended', + 'plugin:vue/vue3-essential', '@vue/eslint-config-typescript', '@vue/eslint-config-prettier/skip-formatting' ], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ffe7890..06f0761f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "@vue/tsconfig": "^0.5.1", "autoprefixer": "^10.4.19", "eslint": "^8.57.0", - "eslint-plugin-vue": "^9.23.0", + "eslint-plugin-vue": "^9.27.0", "jsdom": "^24.1.0", "npm-run-all2": "^6.2.0", "postcss-html": "^1.7.0", diff --git a/frontend/package.json b/frontend/package.json index c2353fc3..7a4398b1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,7 +32,7 @@ "@vue/tsconfig": "^0.5.1", "autoprefixer": "^10.4.19", "eslint": "^8.57.0", - "eslint-plugin-vue": "^9.23.0", + "eslint-plugin-vue": "^9.27.0", "jsdom": "^24.1.0", "npm-run-all2": "^6.2.0", "postcss-html": "^1.7.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 44843a03..024bf5fa 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,11 +1,17 @@ diff --git a/frontend/src/components/FileTree.vue b/frontend/src/components/FileTree.vue index ad4b69ec..9625969f 100644 --- a/frontend/src/components/FileTree.vue +++ b/frontend/src/components/FileTree.vue @@ -1,12 +1,25 @@ - + + + diff --git a/frontend/src/components/LoadingBars.vue b/frontend/src/components/LoadingBars.vue new file mode 100644 index 00000000..a55b04fb --- /dev/null +++ b/frontend/src/components/LoadingBars.vue @@ -0,0 +1,94 @@ + diff --git a/frontend/src/components/__tests__/FileTree.spec.ts b/frontend/src/components/__tests__/FileTree.spec.ts deleted file mode 100644 index c8410bcd..00000000 --- a/frontend/src/components/__tests__/FileTree.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { mount } from "@vue/test-utils"; -import FileTree from "../FileTree.vue"; - -describe("FileTree", () => { - it("renders properly", () => { - const wrapper = mount(FileTree); - expect(wrapper.text()).toContain("1"); - }); -}); diff --git a/frontend/src/components/icons/FolderIcon.vue b/frontend/src/components/icons/FolderIcon.vue new file mode 100644 index 00000000..923c8c0b --- /dev/null +++ b/frontend/src/components/icons/FolderIcon.vue @@ -0,0 +1,11 @@ + diff --git a/frontend/src/components/icons/ManderIcon.vue b/frontend/src/components/icons/ManderIcon.vue new file mode 100644 index 00000000..f7eeea30 --- /dev/null +++ b/frontend/src/components/icons/ManderIcon.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/env.d.ts b/frontend/src/env.d.ts similarity index 100% rename from frontend/env.d.ts rename to frontend/src/env.d.ts diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 00000000..c9796005 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,21 @@ +const BASE_URL = "http://localhost:8000/api"; + +function getErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + return String(error); +} + +function reportError(message: string) { + console.error(message); +} + +export async function getAllManderPaths(): Promise { + try { + const r = await fetch(`${BASE_URL}/mandrs`); + const paths = await r.json(); + return paths; + } catch (error) { + reportError(getErrorMessage(error)); + return []; + } +} diff --git a/frontend/src/services/utils.ts b/frontend/src/services/utils.ts new file mode 100644 index 00000000..0f532df4 --- /dev/null +++ b/frontend/src/services/utils.ts @@ -0,0 +1,3 @@ +export function sleep(delay: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delay)); +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 1e8f7cc4..4aa22699 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,10 +1,38 @@ diff --git a/frontend/tests/components/FileTree.spec.ts b/frontend/tests/components/FileTree.spec.ts new file mode 100644 index 00000000..3211071d --- /dev/null +++ b/frontend/tests/components/FileTree.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; + +import { mount } from "@vue/test-utils"; +import FileTree, { type FileTreeNode } from "@/components/FileTree.vue"; + +function countLeaves(nodes: FileTreeNode[]): number { + function countInNode(node: FileTreeNode): number { + if (!node.children?.length) { + return 1; + } + + const countInChildren = node.children.map(countInNode); + return countInChildren.reduce((accumulator, leavesCount) => accumulator + leavesCount); + } + + const allBranches = nodes.map(countInNode); + return allBranches.reduce((accumulator, leavesCount) => accumulator + leavesCount); +} + +describe("FileTree", () => { + it("Renders properly.", () => { + const records: FileTreeNode[] = [ + { + label: "Punk Rock", + children: [ + { + label: "The Clash", + children: [ + { label: "The Clash" }, + { label: "Give 'Em Enough Rope" }, + { label: "London Calling" }, + { label: "Sandinista!" }, + { label: "Combat Rock" }, + { label: "Cut the Crap" }, + ], + }, + { + label: "Ramones", + children: [ + { label: "Ramones" }, + { label: "Leave Home" }, + { label: "Rocket to Russia" }, + { label: "Road to Ruin" }, + { label: "End of the Century" }, + { label: "Pleasant Dreams" }, + { label: "Subterranean Jungle" }, + { label: "Too Tough to Die" }, + { label: "Animal Boy" }, + { label: "Halfway to Sanity" }, + { label: "Brain Drain" }, + { label: "Mondo Bizarro" }, + { label: "Acid Eaters" }, + { label: "¡Adios Amigos!" }, + ], + }, + ], + }, + { + label: "French touch", + children: [ + { + label: "Laurent Garnier", + children: [ + { label: "Shot in the Dark" }, + { label: "Club Traxx EP" }, + { label: "30" }, + { label: "Early Works" }, + { label: "Unreasonable Behaviour" }, + { label: "The Cloud Making Machine" }, + { label: "Retrospective" }, + { label: "Public Outburst" }, + { label: "Tales of a Kleptomaniac" }, + { label: "Suivront Mille Ans De Calme" }, + { label: "Home Box" }, + { label: "Paris Est à Nous" }, + { label: "Le Roi Bâtard" }, + { label: "De Película" }, + { label: "Entre la Vie et la Mort" }, + { label: "33 tours et puis s'en vont" }, + ], + }, + ], + }, + ]; + + const wrapper = mount(FileTree, { + props: { nodes: records }, + }); + + const itemSelector = ".file-tree-item"; + const treeItems = wrapper.findAll(itemSelector); + const leavesCount = countLeaves(records); + const leaves = treeItems.filter((c) => c.findAll(itemSelector).length == 0); + expect(leaves).toHaveLength(leavesCount); + }); +}); diff --git a/frontend/tests/services/api.spec.ts b/frontend/tests/services/api.spec.ts new file mode 100644 index 00000000..78bc6269 --- /dev/null +++ b/frontend/tests/services/api.spec.ts @@ -0,0 +1,22 @@ +import { getAllManderPaths } from "@/services/api"; +import { describe, expect, it } from "vitest"; + +import { createFetchResponse, mockedFetch } from "../test.utils"; + +describe("api", () => { + it("Can fetch the list of manders from the server.", async () => { + const paths = [ + "probabl-ai/demo-usecase/training/0", + "probabl-ai/test-mandr/0", + "probabl-ai/test-mandr/1", + "probabl-ai/test-mandr/2", + "probabl-ai/test-mandr/3", + "probabl-ai/test-mandr/4", + ]; + + mockedFetch.mockResolvedValue(createFetchResponse(paths)); + + const r = await getAllManderPaths(); + expect(r).toStrictEqual(paths); + }); +}); diff --git a/frontend/tests/test.utils.ts b/frontend/tests/test.utils.ts new file mode 100644 index 00000000..6a9642b4 --- /dev/null +++ b/frontend/tests/test.utils.ts @@ -0,0 +1,27 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { vi } from "vitest"; +import { type ComponentPublicInstance, defineComponent, h, Suspense } from "vue"; + +export const mockedFetch = vi.fn(); +global.fetch = mockedFetch; + +export function createFetchResponse(data: object) { + return { json: () => new Promise((resolve) => resolve(data)) }; +} + +export async function mountSuspense( + component: new () => ComponentPublicInstance, + options: any = {} +) { + const suspensedComponent = defineComponent({ + render: () => { + return h(Suspense, null, { + default: h(component), + fallback: h("div", "loading..."), + }); + }, + }); + const wrapper = mount(suspensedComponent, options); + await flushPromises(); + return wrapper; +} diff --git a/frontend/tests/views/dashboard.spec.ts b/frontend/tests/views/dashboard.spec.ts new file mode 100644 index 00000000..04693e9e --- /dev/null +++ b/frontend/tests/views/dashboard.spec.ts @@ -0,0 +1,45 @@ +import FileTree from "@/components/FileTree.vue"; +import DashboardView from "@/views/DashboardView.vue"; +import { describe, expect, it } from "vitest"; +import { createFetchResponse, mockedFetch, mountSuspense } from "../test.utils"; + +describe("DashboardView", () => { + it("Parse the list of fetched list of mander to an array of FileTreeNode.", async () => { + const paths = [ + "probabl-ai/demo-usecase/training/0", + "probabl-ai/test-mandr/0", + "probabl-ai/test-mandr/1", + "probabl-ai/test-mandr/2", + "probabl-ai/test-mandr/3", + "probabl-ai/test-mandr/4", + ]; + + mockedFetch.mockResolvedValue(createFetchResponse(paths)); + + const wrapper = await mountSuspense(DashboardView); + const fileTree = await wrapper.getComponent(FileTree); + expect(fileTree.props()).toEqual({ + nodes: [ + { + label: "probabl-ai", + children: [ + { + label: "demo-usecase", + children: [{ label: "training", children: [{ label: "0" }] }], + }, + { + label: "test-mandr", + children: [ + { label: "0" }, + { label: "1" }, + { label: "2" }, + { label: "3" }, + { label: "4" }, + ], + }, + ], + }, + ], + }); + }); +}); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index e14c754d..e213d1dd 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -1,14 +1,18 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], - "exclude": ["src/**/__tests__/*"], + "include": [ + "src/env.d.ts", + "src/**/*", + "src/**/*.vue" + ], "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } } } diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index f0940630..6fffb82e 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -11,9 +11,10 @@ "composite": true, "noEmit": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "module": "ESNext", "moduleResolution": "Bundler", - "types": ["node"] + "types": [ + "node" + ] } } diff --git a/frontend/tsconfig.vitest.json b/frontend/tsconfig.vitest.json index 571995d1..68d5b1db 100644 --- a/frontend/tsconfig.vitest.json +++ b/frontend/tsconfig.vitest.json @@ -1,11 +1,17 @@ { "extends": "./tsconfig.app.json", - "exclude": [], + "include": [ + "src/**/*", + "src/**/*.vue", + "tests/**/*", + ], "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", - "lib": [], - "types": ["node", "jsdom"] + "types": [ + "node", + "jsdom" + ] } } diff --git a/src/mandr/dashboard/webapp.py b/src/mandr/dashboard/webapp.py index cb561c88..a4b14358 100644 --- a/src/mandr/dashboard/webapp.py +++ b/src/mandr/dashboard/webapp.py @@ -4,6 +4,7 @@ from pathlib import Path from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from mandr import InfoMander @@ -12,6 +13,13 @@ _STATIC_PATH = _DASHBOARD_PATH / "static" app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) @app.get("/api/mandrs")