diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfa08e88..21edee27 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,7 @@ on: env: node_version: ${{ vars.NODE_VERSION }} + PUBLIC_API_BASE_URL: http://localhost:8080 jobs: build: diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..ea403a61 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..79ee123c --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/debug.xml b/.idea/runConfigurations/debug.xml new file mode 100644 index 00000000..47f28f71 --- /dev/null +++ b/.idea/runConfigurations/debug.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/src/lib/components/composites/project-components/ProjectListEntrySkeleton.svelte b/src/lib/components/composites/project-components/ProjectListEntrySkeleton.svelte new file mode 100644 index 00000000..ab587145 --- /dev/null +++ b/src/lib/components/composites/project-components/ProjectListEntrySkeleton.svelte @@ -0,0 +1,29 @@ + + +
+
+ + +
+
+ + +
+
diff --git a/src/lib/components/composites/utils/NamedList.svelte b/src/lib/components/composites/utils/NamedList.svelte new file mode 100644 index 00000000..b8e18a73 --- /dev/null +++ b/src/lib/components/composites/utils/NamedList.svelte @@ -0,0 +1,82 @@ + + + +
+ {#await items} +

{listName}

+
    + + {#each Array(10) as _} +
  • + {@render listItemSkeleton()} +
  • + {/each} +
+ {:then loadedItems} + {#if showNumberOfListItems} +

{listName} ({numberOfItems < 0 ? loadedItems.length : numberOfItems})

+ {:else} +

{listName}

+ {/if} +
    + {#each loadedItems as item} +
  • + {@render listItemComponent?.(item)} +
  • + {/each} +
+ + {:catch error} +

{listName}

+
+ + {error} +
+ {/await} +
diff --git a/src/lib/components/primitives/avatar/avatar-fallback.svelte b/src/lib/components/primitives/avatar/avatar-fallback.svelte index 7dcf2ee7..487f1da8 100644 --- a/src/lib/components/primitives/avatar/avatar-fallback.svelte +++ b/src/lib/components/primitives/avatar/avatar-fallback.svelte @@ -1,6 +1,6 @@ + + +
+
diff --git a/src/lib/components/primitives/skeleton/index.ts b/src/lib/components/primitives/skeleton/index.ts new file mode 100644 index 00000000..c2d39f64 --- /dev/null +++ b/src/lib/components/primitives/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/src/lib/components/primitives/skeleton/skeleton.svelte b/src/lib/components/primitives/skeleton/skeleton.svelte new file mode 100644 index 00000000..bc76d48d --- /dev/null +++ b/src/lib/components/primitives/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
diff --git a/src/lib/components/primitives/tabs/tabs-content.svelte b/src/lib/components/primitives/tabs/tabs-content.svelte index 981dbdba..cda24219 100644 --- a/src/lib/components/primitives/tabs/tabs-content.svelte +++ b/src/lib/components/primitives/tabs/tabs-content.svelte @@ -1,6 +1,6 @@ -
+ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a626766b..1a92b843 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,8 +1,11 @@ @@ -10,3 +13,17 @@ +
+ +
+

Open Reviews

+
+ + {#snippet listItemComponent(componentData)} + + {/snippet} + {#snippet listItemSkeleton()} + + {/snippet} + +
diff --git a/src/routes/+page.ts b/src/routes/+page.ts new file mode 100644 index 00000000..abb0b53a --- /dev/null +++ b/src/routes/+page.ts @@ -0,0 +1,123 @@ +import type { PageLoad } from "./$types"; +import { BackendController } from "$lib/controller/backend-controller"; +import { + type Paper, + type Project, + type ProjectMetadata, + type StageEntry, + type User, +} from "$lib/model/backend"; +import { calculateStageProgress } from "$lib/utils/statistics-helper"; + +// import type { Project, ProjectMetadata } from "$lib/model/backend"; + +async function requestProjectMetadata(project: Project): Promise { + const members: User[] = await BackendController.getInstance().project(project.id).getMembers(); + const currentStage: number = await BackendController.getInstance() + .project(project.id) + .getStageCount(); + const allPapersInCurrentStage: StageEntry[] = await BackendController.getInstance() + .project(project.id) + .stage(currentStage) + .getPapers(); + + return { + project: project, + members: members, + stage: currentStage, + stageProgress: calculateStageProgress( + allPapersInCurrentStage.map((stageEntry: StageEntry): Paper => stageEntry.paper), + ), + }; +} + +/** + * Loads projects for the user logged in. + * + * Therefore, request the project ids of the projects, the user logged in is member of and use + * these ids to request: + *
    + *
  • the project members
  • + *
  • the current project stage
  • + *
  • the progress of the current stage
  • + *
+ * + * TODO: check, whether this can be handled with a single request, e.g. on route /projects/[id]/projectMetadata/. + */ +export const load: PageLoad = () => { + const projectMetadata = BackendController.getInstance() + .thisUser() + .getAllProjects() + .then(async (projects: Project[]) => { + try { + return await Promise.all( + projects.map((project: Project) => requestProjectMetadata(project)), + ); + } catch { + throw new Error("Could not load project details."); + } + }) + .catch(() => { + throw new Error("Could not load projects."); + }); + + // attach noop-catch to handle promise rejection correctly (see https://svelte.dev/docs/kit/load#Streaming-with-promises) + projectMetadata.catch(() => {}); + + return { projectMetadata }; + + // uncomment the following lines, if you want to test the projects list with a lot of projects + /* const Users = { + johnDoe: { + id: 0, + firstName: "John", + lastName: "Doe", + email: "john.doe@example.com", + isAdmin: true, + status: "active", + }, + janeDoe: { + id: 1, + firstName: "Jane", + lastName: "Doe", + email: "jane.doe@example.com", + isAdmin: false, + status: "active", + }, + }; + + function createProject(project: Partial): Project { + return { + id: 0, + name: "Foo", + reviewDecisionMatrix: { + numberOfReviewers: 2, + patterns: new Map(), + }, + similarityThreshold: 0.7, + paperFetchApis: ["bar"], + ...project, + }; + } + + const projectMetadataExamples: ProjectMetadata[] = Array(20) + .fill(0) + .map((_, i) => { + return { + project: createProject({ id: i, name: `Demo Project ${i}` }), + members: [Users.johnDoe, Users.janeDoe], + stage: 1, + stageProgress: Math.random() * 100, + }; + }); + + const projectMetadata: Promise = new Promise((resolve, reject) => + setTimeout(() => { + // resolve(projectMetadataExamples); + reject(new Error("Bla bla")); + }, 2000), + ); + */ + + // return { projectMetadata }; +}; diff --git a/src/routes/project/[projectId]/+layout.ts b/src/routes/project/[projectId]/+layout.ts index b3a5047d..98afd8b2 100644 --- a/src/routes/project/[projectId]/+layout.ts +++ b/src/routes/project/[projectId]/+layout.ts @@ -15,6 +15,7 @@ export const load: LayoutLoad = async ({ params }) => { }, similarityThreshold: 0, paperFetchApis: [], + archived: false, }; return { project, diff --git a/tests/integration/project-components/project-list-entry-skeleton.test.ts b/tests/integration/project-components/project-list-entry-skeleton.test.ts new file mode 100644 index 00000000..ebc1283e --- /dev/null +++ b/tests/integration/project-components/project-list-entry-skeleton.test.ts @@ -0,0 +1,24 @@ +import { expect, test, describe } from "vitest"; +import ProjectListEntrySkeleton from "$lib/components/composites/project-components/ProjectListEntrySkeleton.svelte"; +import { render, screen } from "@testing-library/svelte"; + +describe("ProjectListEntrySkeletonComponent", () => { + test("When project list entry skeleton is rendered, then no data are displayed but only skeletons.", () => { + render(ProjectListEntrySkeleton); + + // expect skeleton for project name, members and stage, but not progress + screen + .getAllByTestId("skeleton", { exact: false }) + .forEach((skeleton) => expect(skeleton).toBeInTheDocument()); + expect(screen.getAllByTestId("skeleton", { exact: false }).length).toBe(3); + + expect(screen.getByTestId("project-stage-progress")).toBeInTheDocument(); + expect(screen.getByTestId("project-stage-progress")).toHaveValue(0); + }); + + test("When project list entry skeleton is rendered, then the project list entry has no behavior.", () => { + render(ProjectListEntrySkeleton); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/integration/project-components/project-list-entry.test.ts b/tests/integration/project-components/project-list-entry.test.ts new file mode 100644 index 00000000..0f3f8776 --- /dev/null +++ b/tests/integration/project-components/project-list-entry.test.ts @@ -0,0 +1,66 @@ +import { expect, test, describe } from "vitest"; +import ProjectListEntry from "$lib/components/composites/project-components/ProjectListEntry.svelte"; +import { render, screen } from "@testing-library/svelte"; +import { createProject, Users } from "../../model-builder"; + +describe("ProjectListEntryComponent", () => { + test("When all required props are provided, then the project list entry is completely shown.", () => { + render(ProjectListEntry, { + props: { + project: createProject({ + name: "Demo Project", + }), + members: [Users.johnDoe, Users.janeDoe], + stage: 1, + stageProgress: 60, + }, + }); + + // Project information are shown directly + expect(screen.getByText("Demo Project")).toBeInTheDocument(); + expect(screen.getByText("John Doe, Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("Stage 1")).toBeInTheDocument(); + + // Project (stage) progress is visualized using a progress bar + expect(screen.queryByText("60")).not.toBeInTheDocument(); + expect(screen.queryByTestId("stage-progress-bar")).toHaveValue(60); + }); + + test("When all no members are provided, then the list entry shows a hint.", () => { + render(ProjectListEntry, { + props: { + project: createProject({ + name: "Demo Project", + }), + members: [], + stage: 1, + stageProgress: 60, + }, + }); + + expect(screen.getByText("Demo Project")).toBeInTheDocument(); + expect(screen.getByText("no members")).toBeInTheDocument(); + expect(screen.getByText("Stage 1")).toBeInTheDocument(); + expect(screen.queryByText("60")).not.toBeInTheDocument(); + }); + + test("When project is archived, then the list entry is opaque.", () => { + render(ProjectListEntry, { + props: { + project: createProject({ + name: "Demo Project", + archived: true, + }), + members: [], + stage: 1, + stageProgress: 60, + }, + }); + + expect(screen.getByText("Demo Project")).toBeInTheDocument(); + expect(screen.getByText("no members")).toBeInTheDocument(); + expect(screen.getByText("Stage 1")).toBeInTheDocument(); + + expect(screen.getByRole("button")).toHaveClass("opacity-25"); + }); +}); diff --git a/tests/integration/utils/ExampleListItem.svelte b/tests/integration/utils/ExampleListItem.svelte new file mode 100644 index 00000000..dfd564b4 --- /dev/null +++ b/tests/integration/utils/ExampleListItem.svelte @@ -0,0 +1,5 @@ + + +{content} diff --git a/tests/integration/utils/ExampleListItemSkeleton.svelte b/tests/integration/utils/ExampleListItemSkeleton.svelte new file mode 100644 index 00000000..9a93fef7 --- /dev/null +++ b/tests/integration/utils/ExampleListItemSkeleton.svelte @@ -0,0 +1,5 @@ + + + diff --git a/tests/integration/utils/ExampleSnippets.svelte b/tests/integration/utils/ExampleSnippets.svelte new file mode 100644 index 00000000..4f3f2f06 --- /dev/null +++ b/tests/integration/utils/ExampleSnippets.svelte @@ -0,0 +1,14 @@ + + +{#snippet listItemComponent(componentData)} + +{/snippet} + +{#snippet listItemSkeleton()} + +{/snippet} diff --git a/tests/integration/utils/named-list.test.ts b/tests/integration/utils/named-list.test.ts new file mode 100644 index 00000000..2ace161b --- /dev/null +++ b/tests/integration/utils/named-list.test.ts @@ -0,0 +1,120 @@ +import { expect, test, describe } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import NamedList from "$lib/components/composites/utils/NamedList.svelte"; +// @ts-expect-error "Snippets have implicetly type any" +import { listItemComponent, listItemSkeleton } from "./ExampleSnippets.svelte"; + +describe("NamedListComponent", () => { + test("When all required props are provided, then the named list is completely shown.", async () => { + const componentData: Promise = Promise.resolve( + Array.from({ length: 15 }, (_, i) => `Hello world ${i}`), + ); + + render(NamedList, { + props: { + listName: "Test List", + items: componentData, + listItemComponent: listItemComponent, + listItemSkeleton: listItemSkeleton, + }, + }); + + // List title is shown + expect(screen.getByText("Test List")).toBeInTheDocument(); + + setTimeout(() => { + // 15 list items are displayed + expect(screen.getAllByRole("span").length).toBe(15); + + // list has an overflow, as not all items can be displayed + expect( + screen.getByRole("list").scrollHeight > screen.getByRole("list").clientHeight, + ).toBeTruthy(); + }, 250); + }); + + test("When the number of items should be shown, then the name of the list is extended by the number of list items.", async () => { + const componentData: Promise = Promise.resolve( + Array.from({ length: 5 }, (_, i) => `Hello world ${i}`), + ); + + render(NamedList, { + props: { + listName: "Test List", + items: componentData, + listItemComponent: listItemComponent, + listItemSkeleton: listItemSkeleton, + showNumberOfListItems: true, + }, + }); + + setTimeout(() => { + // List title is shown + expect(screen.getByText("Test List (5)")).toBeInTheDocument(); + }, 250); + }); + + test("When the number of items should be shown and is given explicitly, then the name of the list is extended by the given number of list items.", async () => { + const componentData: Promise = Promise.resolve( + Array.from({ length: 5 }, (_, i) => `Hello world ${i}`), + ); + + render(NamedList, { + props: { + listName: "Test List", + items: componentData, + listItemComponent: listItemComponent, + listItemSkeleton: listItemSkeleton, + showNumberOfListItems: true, + numberOfItems: 10, + }, + }); + + setTimeout(() => { + // List title is shown + expect(screen.getByText("Test List (10)")).toBeInTheDocument(); + }, 250); + }); + + test("When the list is loading, then skeleton elements are shown", async () => { + const componentData: Promise = new Promise((resolve) => + setTimeout(() => resolve(Array.from({ length: 5 }, (_, i) => `Hello world ${i}`)), 100), + ); + + render(NamedList, { + props: { + listName: "Test List", + items: componentData, + listItemComponent: listItemComponent, + listItemSkeleton: listItemSkeleton, + }, + }); + + expect(screen.queryAllByRole("span").length).toBe(0); + expect(screen.queryAllByTestId("skeleton").length).toBe(10); + }); + + test("When the list item could not be loaded, then the error message is shown", async () => { + const componentData: Promise = new Promise(() => + setTimeout(() => { + throw new Error("Test Error"); + }, 100), + ); + + render(NamedList, { + props: { + listName: "Test List", + items: componentData, + listItemComponent: listItemComponent, + listItemSkeleton: listItemSkeleton, + }, + }); + + setTimeout(() => { + expect(screen.queryAllByRole("span").length).toBe(0); + expect(screen.queryAllByTestId("skeleton").length).toBe(0); + + expect(screen.getByText("Test Error")); + }, 250); + }); +}); diff --git a/tests/model-builder.ts b/tests/model-builder.ts index af029578..ff3d7c6c 100644 --- a/tests/model-builder.ts +++ b/tests/model-builder.ts @@ -47,6 +47,7 @@ export function createProject(project: Partial): Project { }, similarityThreshold: 0.7, paperFetchApis: ["bar"], + archived: false, ...project, }; } diff --git a/tests/unit/demo.test.ts b/tests/unit/demo.test.ts deleted file mode 100644 index eb835451..00000000 --- a/tests/unit/demo.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from "vitest"; - -describe("sum test", () => { - it("adds 1 + 2 to equal 3", () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/tests/unit/helpers/stage-progress-calculator.test.ts b/tests/unit/helpers/stage-progress-calculator.test.ts new file mode 100644 index 00000000..f2d12d1d --- /dev/null +++ b/tests/unit/helpers/stage-progress-calculator.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { calculateStageProgress } from "$lib/utils/statistics-helper"; +import { type Paper, ReviewDecision } from "$lib/model/backend"; +import { createPaper } from "../../model-builder"; + +describe("StageProgressCalculator", () => { + it("When no paper are provided, then the progress is zero", () => { + const papers: Paper[] = []; + + expect(calculateStageProgress(papers)).toBe(0); + }); + + it("When all paper are not reviewed yet, then the progress is zero", () => { + const papers: Paper[] = Array.from({ length: 4 }, (_, i) => + createPaper({ id: i, reviewData: undefined }), + ); + + expect(calculateStageProgress(papers)).toBe(0); + }); + + it("When one paper is accepted, one declined, one maybe and one unreviewed, then the progress is 50%", () => { + const decisions = [ + undefined, + { finalDecision: ReviewDecision.Maybe, reviews: [] }, + { finalDecision: ReviewDecision.Accepted, reviews: [] }, + { finalDecision: ReviewDecision.Declined, reviews: [] }, + ]; + + const papers: Paper[] = Array.from({ length: 4 }, (_, i) => + createPaper({ id: i, reviewData: decisions[i] }), + ); + + expect(calculateStageProgress(papers)).toBe(50); + }); + + it("When all papers are decided, then the progress is 100%", () => { + const decisions = [ + { finalDecision: ReviewDecision.Declined, reviews: [] }, + { finalDecision: ReviewDecision.Accepted, reviews: [] }, + { finalDecision: ReviewDecision.Declined, reviews: [] }, + ]; + + const papers: Paper[] = Array.from({ length: 3 }, (_, i) => + createPaper({ id: i, reviewData: decisions[i] }), + ); + + expect(calculateStageProgress(papers)).toBe(100); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 6fba265d..63c83cdb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,7 +15,11 @@ export default defineConfig({ setupFiles: ["./tests/setupTest.ts"], coverage: { include: ["src/**"], - exclude: ["src/routes/**/+*.{svelte,ts}", "**/*.d.ts"], + exclude: [ + "src/routes/**/+*.{svelte,ts}", + "**/*.d.ts", + "src/lib/components/primitives/**", + ], provider: "v8", enabled: true, cleanOnRerun: true,