From 218c5ac709be215fef4d668b1d06a67d8d7e9fd0 Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Thu, 19 Sep 2024 14:00:52 +0200 Subject: [PATCH 01/45] domain name endpoint --- src/lib/scripts/entities/Fields.ts | 6 +- .../server/controllers/DomainController.ts | 1 - src/lib/server/controllers/index.ts | 1 - src/lib/server/helpers/DomainHelper.ts | 125 ++++++++++-------- src/lib/server/helpers/GraphHelper.ts | 2 +- src/routes/api/domain/[id=number]/+server.ts | 7 - src/routes/api/domain/[id]/+server.ts | 9 ++ .../api/domain/[id]/name/[name]/+server.ts | 10 ++ 8 files changed, 91 insertions(+), 70 deletions(-) delete mode 100644 src/lib/server/controllers/DomainController.ts delete mode 100644 src/routes/api/domain/[id=number]/+server.ts create mode 100644 src/routes/api/domain/[id]/+server.ts create mode 100644 src/routes/api/domain/[id]/name/[name]/+server.ts diff --git a/src/lib/scripts/entities/Fields.ts b/src/lib/scripts/entities/Fields.ts index d93b9d6..c007c47 100644 --- a/src/lib/scripts/entities/Fields.ts +++ b/src/lib/scripts/entities/Fields.ts @@ -23,8 +23,8 @@ type SerializedDomain = { id: ID, x: number, y: number, - style: string, - name: string, + name?: string, + style?: string, parents: ID[], children: ID[] } @@ -33,8 +33,8 @@ type SerializedSubject = { id: ID, x: number, y: number, + name?: string, domain?: ID, - name: string, parents: ID[], children: ID[] } diff --git a/src/lib/server/controllers/DomainController.ts b/src/lib/server/controllers/DomainController.ts deleted file mode 100644 index ff8b4c5..0000000 --- a/src/lib/server/controllers/DomainController.ts +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/src/lib/server/controllers/index.ts b/src/lib/server/controllers/index.ts index 85bacfb..32b6f58 100644 --- a/src/lib/server/controllers/index.ts +++ b/src/lib/server/controllers/index.ts @@ -1,5 +1,4 @@ export * as CourseController from './CourseController'; -export * as DomainController from './DomainController'; export * as GraphController from './GraphController'; export * as LectureController from './LectureController'; export * as SubjectController from './SubjectController'; diff --git a/src/lib/server/helpers/DomainHelper.ts b/src/lib/server/helpers/DomainHelper.ts index 640790c..715a375 100644 --- a/src/lib/server/helpers/DomainHelper.ts +++ b/src/lib/server/helpers/DomainHelper.ts @@ -1,10 +1,22 @@ -import prisma from '$lib/server/prisma'; -import type { SerializedDomain } from '$scripts/entities'; -import type { Domain } from '@prisma/client'; +import prisma from '$lib/server/prisma' +import type { Domain as PrismaDomain } from '@prisma/client' +import type { SerializedDomain } from '$scripts/entities' -export async function create(graphId: number): Promise { +export { create, remove, makeDTO, setName } + + +// ----------------> Helper functions <---------------- + + +/** + * Creates a Domain object in the database. + * @param graphId id of the Graph object to which the Domain object belongs + * @returns id of the created Domain object + */ + +async function create(graphId: number): Promise { const domain = await prisma.domain.create({ data: { graph: { @@ -13,19 +25,60 @@ export async function create(graphId: number): Promise { } } } - }); - return domain.id; + }) + + return domain.id } +/** + * Removes a Domain object from the database. + * @param domainId id of the Domain object to remove + * @returns void + */ -export async function remove(domainId: number): Promise { +async function remove(domainId: number): Promise { await prisma.domain.delete({ where: { id: domainId } - }); + }) } +/** + * Converts a Domain object to a SerializedDomain object, which can be sent to the client. + * @param domain plain Domain object + * @returns SerializedDomain object + */ + +async function makeDTO(domain: PrismaDomain): Promise { + return { + id: domain.id, + x: domain.x, + y: domain.y, + name: domain.name || undefined, + style: domain.style || undefined, + children: await getChildIds(domain), + parents: await getParentIds(domain) + } +} + +/** + * Updates the name of a Domain object. + * @param domainId id of the Domain object to update + * @param name new name of the Domain object + * @returns void + */ + +async function setName(domainId: number, name: string): Promise { + await prisma.domain.update({ + where: { + id: domainId + }, + data: { + name + } + }) +} /** * Plain Domain objects dont have a children (many-to-many) field, @@ -33,7 +86,8 @@ export async function remove(domainId: number): Promise { * @param domain plain Domain object * @returns the ids of the children of the domain */ -async function getChildIds(domain: Domain): Promise { + +async function getChildIds(domain: PrismaDomain): Promise { return (await prisma.domain.findMany({ where: { parentDomains: { @@ -45,17 +99,17 @@ async function getChildIds(domain: Domain): Promise { orderBy: { id: 'asc' } - })).map(d => d.id); + })).map(d => d.id) } - /** * Plain Domain objects dont have a parents (many-to-many) field, * this method makes them available. * @param domain plain Domain object * @returns the ids of the parents of the domain */ -async function getParentIds(domain: Domain): Promise { + +async function getParentIds(domain: PrismaDomain): Promise { return (await prisma.domain.findMany({ where: { childDomains: { @@ -67,48 +121,5 @@ async function getParentIds(domain: Domain): Promise { orderBy: { id: 'asc' } - })).map(d => d.id); -} - - -/** - * Converts a Domain object to a SerializedDomain object, which can be sent to the client. - * @param domain plain Domain object - * @returns SerializedDomain object - */ -export async function toDTO(domain: Domain): Promise { - return { - id: domain.id, - name: domain.name!, - x: domain.x, - y: domain.y, - style: domain.style!, - children: await getChildIds(domain), - parents: await getParentIds(domain) - } -} - - -/** - * Updates a Domain object in the database to a SerializedDomain object sent by the client. - * @param dto SerializedDomain object - */ -export async function updateFromDTO(dto: SerializedDomain): Promise { - await prisma.domain.update({ - where: { - id: dto.id - }, - data: { - name: dto.name, - x: dto.x, - y: dto.y, - style: dto.style, - childDomains: { - connect: dto.children.map(id => ({ id })) - }, - parentDomains: { - connect: dto.parents.map(id => ({ id })) - } - } - }); -} + })).map(d => d.id) +} \ No newline at end of file diff --git a/src/lib/server/helpers/GraphHelper.ts b/src/lib/server/helpers/GraphHelper.ts index 9e3245a..767bfec 100644 --- a/src/lib/server/helpers/GraphHelper.ts +++ b/src/lib/server/helpers/GraphHelper.ts @@ -57,7 +57,7 @@ async function getLectures(graph: Graph): Promise { export async function toDTO(graph: Graph): Promise { - const domains = await Promise.all((await getDomains(graph)).map(d => DomainHelper.toDTO(d))); + const domains = await Promise.all((await getDomains(graph)).map(d => DomainHelper.makeDTO(d))); const subjects = await Promise.all((await getSubjects(graph)).map(s => SubjectHelper.toDTO(s))); const lectures = await Promise.all((await getLectures(graph)).map(l => LectureHelper.toDTO(l))); diff --git a/src/routes/api/domain/[id=number]/+server.ts b/src/routes/api/domain/[id=number]/+server.ts deleted file mode 100644 index 9d74097..0000000 --- a/src/routes/api/domain/[id=number]/+server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DomainHelper } from '$lib/server/helpers'; - -export async function DELETE({ params }) { - const id = Number(params.id); - await DomainHelper.remove(id); - return new Response(null, { status: 200 }); -} diff --git a/src/routes/api/domain/[id]/+server.ts b/src/routes/api/domain/[id]/+server.ts new file mode 100644 index 0000000..dd1278a --- /dev/null +++ b/src/routes/api/domain/[id]/+server.ts @@ -0,0 +1,9 @@ + +import { DomainHelper } from '$lib/server/helpers' + +export async function DELETE({ params }) { + const id = Number(params.id) + + await DomainHelper.remove(id) + return new Response(null, { status: 200 }) +} \ No newline at end of file diff --git a/src/routes/api/domain/[id]/name/[name]/+server.ts b/src/routes/api/domain/[id]/name/[name]/+server.ts new file mode 100644 index 0000000..ea1ccc2 --- /dev/null +++ b/src/routes/api/domain/[id]/name/[name]/+server.ts @@ -0,0 +1,10 @@ + +import { DomainHelper } from '$lib/server/helpers' + +export async function PATCH({ params }) { + const id = Number(params.id) + const name = String(params.name) + + await DomainHelper.setName(id, name) + return new Response(null, { status: 200 }) +} \ No newline at end of file From 93c6ba0c8e18375389797e66d0c390ff1681a3a1 Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Thu, 19 Sep 2024 15:05:35 +0200 Subject: [PATCH 02/45] all domain endpoints --- src/lib/server/helpers/DomainHelper.ts | 208 ++++++++++++++++-- src/routes/api/domain/[id]/+server.ts | 7 +- .../api/domain/[id]/name/[name]/+server.ts | 9 +- .../domain/[id]/position/[x]/[y]/+server.ts | 14 ++ .../[id]/relation/[other_id]/+server.ts | 13 ++ .../api/domain/[id]/style/[style]/+server.ts | 13 ++ .../graph/{[id=number] => [id]}/+server.ts | 0 src/routes/api/graph/[id]/domain/+server.ts | 9 + 8 files changed, 248 insertions(+), 25 deletions(-) create mode 100644 src/routes/api/domain/[id]/position/[x]/[y]/+server.ts create mode 100644 src/routes/api/domain/[id]/relation/[other_id]/+server.ts create mode 100644 src/routes/api/domain/[id]/style/[style]/+server.ts rename src/routes/api/graph/{[id=number] => [id]}/+server.ts (100%) create mode 100644 src/routes/api/graph/[id]/domain/+server.ts diff --git a/src/lib/server/helpers/DomainHelper.ts b/src/lib/server/helpers/DomainHelper.ts index 715a375..dc4389c 100644 --- a/src/lib/server/helpers/DomainHelper.ts +++ b/src/lib/server/helpers/DomainHelper.ts @@ -4,7 +4,7 @@ import prisma from '$lib/server/prisma' import type { Domain as PrismaDomain } from '@prisma/client' import type { SerializedDomain } from '$scripts/entities' -export { create, remove, makeDTO, setName } +export { create, remove, setName, setStyle, toggleRelation, setPosition } // ----------------> Helper functions <---------------- @@ -13,10 +13,10 @@ export { create, remove, makeDTO, setName } /** * Creates a Domain object in the database. * @param graphId id of the Graph object to which the Domain object belongs - * @returns id of the created Domain object + * @returns DTO of the created Domain object */ -async function create(graphId: number): Promise { +async function create(graphId: number): Promise { const domain = await prisma.domain.create({ data: { graph: { @@ -27,7 +27,7 @@ async function create(graphId: number): Promise { } }) - return domain.id + return makeDTO(domain) } /** @@ -45,41 +45,209 @@ async function remove(domainId: number): Promise { } /** - * Converts a Domain object to a SerializedDomain object, which can be sent to the client. - * @param domain plain Domain object - * @returns SerializedDomain object + * Updates the name of a Domain object. + * @param domainId id of the Domain object to update + * @param name new name of the Domain object + * @returns void + * @throws 'Domain not found' if the Domain object is not found */ -async function makeDTO(domain: PrismaDomain): Promise { - return { - id: domain.id, - x: domain.x, - y: domain.y, - name: domain.name || undefined, - style: domain.style || undefined, - children: await getChildIds(domain), - parents: await getParentIds(domain) +async function setName(domainId: number, name: string): Promise { + + // Check if the Domain object exists + const domain = await prisma.domain.findUnique({ + where: { + id: domainId + } + }) + + if (!domain) return Promise.reject('Domain not found') + + // Update + await prisma.domain.update({ + where: { + id: domainId + }, + data: { + name + } + }) +} + +/** + * Updates the style of a Domain object. + * @param domainId id of the Domain object to update + * @param style new style of the Domain object + * @returns void + * @throws 'Domain not found' if the Domain object is not found + */ + +async function setStyle(domainId: number, style: string): Promise { + + // Check if the Domain object exists + const domain = await prisma.domain.findUnique({ + where: { + id: domainId + } + }) + + if (!domain) return Promise.reject('Domain not found') + + // Update + await prisma.domain.update({ + where: { + id: domainId + }, + data: { + style + } + }) +} + +/** + * Toggles the relation between two Domain objects. + * @param domainId id of the first Domain object + * @param otherId id of the second Domain object + * @returns void + * @throws 'Domain not found' if either of the Domain objects is not found + */ + +async function toggleRelation(domainId: number, otherId: number): Promise { + + // Fetch the Domain objects + const domain = await prisma.domain.findUnique({ + where: { + id: domainId + }, + include: { + childDomains: true + } + }) + + const other = await prisma.domain.findUnique({ + where: { + id: otherId + }, + include: { + parentDomains: true + } + }) + + if (!domain || !other) { + return Promise.reject('Domain not found') + } + + // Toggle the relation + if (domain.childDomains.some(d => d.id === otherId)) { + await prisma.domain.update({ + where: { + id: domainId + }, + data: { + childDomains: { + disconnect: { + id: otherId + } + } + } + }) + + await prisma.domain.update({ + where: { + id: otherId + }, + data: { + parentDomains: { + disconnect: { + id: domainId + } + } + } + }) + } + + else { + await prisma.domain.update({ + where: { + id: domainId + }, + data: { + childDomains: { + connect: { + id: otherId + } + } + } + }) + + await prisma.domain.update({ + where: { + id: otherId + }, + data: { + parentDomains: { + connect: { + id: domainId + } + } + } + }) } } /** - * Updates the name of a Domain object. + * Sets the position of a Domain object. * @param domainId id of the Domain object to update - * @param name new name of the Domain object + * @param x new x-coordinate of the Domain object + * @param y new y-coordinate of the Domain object * @returns void + * @throws 'Domain not found' if the Domain object is not found */ -async function setName(domainId: number, name: string): Promise { +async function setPosition(domainId: number, x: number, y: number): Promise { + + // Check if the Domain object exists + const domain = await prisma.domain.findUnique({ + where: { + id: domainId + } + }) + + if (!domain) return Promise.reject('Domain not found') + + // Update await prisma.domain.update({ where: { id: domainId }, data: { - name + x, y } }) } + + + + +/** + * Converts a Domain object to a SerializedDomain object, which can be sent to the client. + * @param domain plain Domain object + * @returns SerializedDomain object + */ + +async function makeDTO(domain: PrismaDomain): Promise { + return { + id: domain.id, + x: domain.x, + y: domain.y, + name: domain.name || undefined, + style: domain.style || undefined, + children: await getChildIds(domain), + parents: await getParentIds(domain) + } +} + /** * Plain Domain objects dont have a children (many-to-many) field, * this method makes them available. diff --git a/src/routes/api/domain/[id]/+server.ts b/src/routes/api/domain/[id]/+server.ts index dd1278a..29c2fad 100644 --- a/src/routes/api/domain/[id]/+server.ts +++ b/src/routes/api/domain/[id]/+server.ts @@ -4,6 +4,9 @@ import { DomainHelper } from '$lib/server/helpers' export async function DELETE({ params }) { const id = Number(params.id) - await DomainHelper.remove(id) - return new Response(null, { status: 200 }) + return await DomainHelper.remove(id) + .then( + () => new Response(null, { status: 200 }), + (error) => new Response(error, { status: 400 }) + ) } \ No newline at end of file diff --git a/src/routes/api/domain/[id]/name/[name]/+server.ts b/src/routes/api/domain/[id]/name/[name]/+server.ts index ea1ccc2..231b5c0 100644 --- a/src/routes/api/domain/[id]/name/[name]/+server.ts +++ b/src/routes/api/domain/[id]/name/[name]/+server.ts @@ -3,8 +3,11 @@ import { DomainHelper } from '$lib/server/helpers' export async function PATCH({ params }) { const id = Number(params.id) - const name = String(params.name) + const name = String(params.name) - await DomainHelper.setName(id, name) - return new Response(null, { status: 200 }) + return await DomainHelper.setName(id, name) + .then( + () => new Response(null, { status: 200 }), + (error) => new Response(error, { status: 400 }) + ) } \ No newline at end of file diff --git a/src/routes/api/domain/[id]/position/[x]/[y]/+server.ts b/src/routes/api/domain/[id]/position/[x]/[y]/+server.ts new file mode 100644 index 0000000..529abad --- /dev/null +++ b/src/routes/api/domain/[id]/position/[x]/[y]/+server.ts @@ -0,0 +1,14 @@ + +import { DomainHelper } from '$lib/server/helpers' + +export async function PATCH({ params }) { + const id = Number(params.id) + const x = Number(params.x) + const y = Number(params.y) + + return await DomainHelper.setPosition(id, x, y) + .then( + () => new Response(null, { status: 200 }), + (error) => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/domain/[id]/relation/[other_id]/+server.ts b/src/routes/api/domain/[id]/relation/[other_id]/+server.ts new file mode 100644 index 0000000..69e7403 --- /dev/null +++ b/src/routes/api/domain/[id]/relation/[other_id]/+server.ts @@ -0,0 +1,13 @@ + +import { DomainHelper } from '$lib/server/helpers' + +export async function PATCH({ params }) { + const id = Number(params.id) + const other_id = Number(params.other_id) + + return await DomainHelper.toggleRelation(id, other_id) + .then( + () => new Response(null, { status: 200 }), + (error) => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/domain/[id]/style/[style]/+server.ts b/src/routes/api/domain/[id]/style/[style]/+server.ts new file mode 100644 index 0000000..bef2758 --- /dev/null +++ b/src/routes/api/domain/[id]/style/[style]/+server.ts @@ -0,0 +1,13 @@ + +import { DomainHelper } from '$lib/server/helpers' + +export async function PATCH({ params }) { + const id = Number(params.id) + const style = String(params.style) + + return await DomainHelper.setStyle(id, style) + .then( + () => new Response(null, { status: 200 }), + (error) => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/graph/[id=number]/+server.ts b/src/routes/api/graph/[id]/+server.ts similarity index 100% rename from src/routes/api/graph/[id=number]/+server.ts rename to src/routes/api/graph/[id]/+server.ts diff --git a/src/routes/api/graph/[id]/domain/+server.ts b/src/routes/api/graph/[id]/domain/+server.ts new file mode 100644 index 0000000..c195599 --- /dev/null +++ b/src/routes/api/graph/[id]/domain/+server.ts @@ -0,0 +1,9 @@ + +import { DomainHelper } from '$lib/server/helpers' + +export async function POST({ params }) { + const id = Number(params.id) + const dto = await DomainHelper.create(id) + + return new Response(JSON.stringify(dto), { status: 201 }) +} \ No newline at end of file From c3a2a3b006fd25d395493178b8ffab6b39566085 Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Thu, 19 Sep 2024 15:07:49 +0200 Subject: [PATCH 03/45] allow unset domain name/style --- src/lib/server/helpers/DomainHelper.ts | 72 +++++++++---------- src/routes/api/graph/[id]/+server.ts | 29 -------- src/routes/api/lecture/[id=number]/+server.ts | 7 -- src/routes/api/subject/[id=number]/+server.ts | 7 -- 4 files changed, 34 insertions(+), 81 deletions(-) delete mode 100644 src/routes/api/graph/[id]/+server.ts delete mode 100644 src/routes/api/lecture/[id=number]/+server.ts delete mode 100644 src/routes/api/subject/[id=number]/+server.ts diff --git a/src/lib/server/helpers/DomainHelper.ts b/src/lib/server/helpers/DomainHelper.ts index dc4389c..9ec8e08 100644 --- a/src/lib/server/helpers/DomainHelper.ts +++ b/src/lib/server/helpers/DomainHelper.ts @@ -4,7 +4,7 @@ import prisma from '$lib/server/prisma' import type { Domain as PrismaDomain } from '@prisma/client' import type { SerializedDomain } from '$scripts/entities' -export { create, remove, setName, setStyle, toggleRelation, setPosition } +export { create, remove, setPosition, setName, setStyle, toggleRelation } // ----------------> Helper functions <---------------- @@ -44,6 +44,37 @@ async function remove(domainId: number): Promise { }) } +/** + * Sets the position of a Domain object. + * @param domainId id of the Domain object to update + * @param x new x-coordinate of the Domain object + * @param y new y-coordinate of the Domain object + * @returns void + * @throws 'Domain not found' if the Domain object is not found + */ + +async function setPosition(domainId: number, x: number, y: number): Promise { + + // Check if the Domain object exists + const domain = await prisma.domain.findUnique({ + where: { + id: domainId + } + }) + + if (!domain) return Promise.reject('Domain not found') + + // Update + await prisma.domain.update({ + where: { + id: domainId + }, + data: { + x, y + } + }) +} + /** * Updates the name of a Domain object. * @param domainId id of the Domain object to update @@ -52,7 +83,7 @@ async function remove(domainId: number): Promise { * @throws 'Domain not found' if the Domain object is not found */ -async function setName(domainId: number, name: string): Promise { +async function setName(domainId: number, name?: string): Promise { // Check if the Domain object exists const domain = await prisma.domain.findUnique({ @@ -82,7 +113,7 @@ async function setName(domainId: number, name: string): Promise { * @throws 'Domain not found' if the Domain object is not found */ -async function setStyle(domainId: number, style: string): Promise { +async function setStyle(domainId: number, style?: string): Promise { // Check if the Domain object exists const domain = await prisma.domain.findUnique({ @@ -195,41 +226,6 @@ async function toggleRelation(domainId: number, otherId: number): Promise } } -/** - * Sets the position of a Domain object. - * @param domainId id of the Domain object to update - * @param x new x-coordinate of the Domain object - * @param y new y-coordinate of the Domain object - * @returns void - * @throws 'Domain not found' if the Domain object is not found - */ - -async function setPosition(domainId: number, x: number, y: number): Promise { - - // Check if the Domain object exists - const domain = await prisma.domain.findUnique({ - where: { - id: domainId - } - }) - - if (!domain) return Promise.reject('Domain not found') - - // Update - await prisma.domain.update({ - where: { - id: domainId - }, - data: { - x, y - } - }) -} - - - - - /** * Converts a Domain object to a SerializedDomain object, which can be sent to the client. * @param domain plain Domain object diff --git a/src/routes/api/graph/[id]/+server.ts b/src/routes/api/graph/[id]/+server.ts deleted file mode 100644 index 0d3382b..0000000 --- a/src/routes/api/graph/[id]/+server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { json, error } from '@sveltejs/kit'; - -import { GraphHelper } from '$lib/server/helpers'; -import { getGraphById } from '$lib/server/controllers/GraphController'; -import { type SerializedGraph } from '$scripts/entities'; - - -/** - * Fetches a graph by its ID and returns it as a DTO - */ -export async function GET({ params }): Promise { - const id = Number(params.id); - const graph = await getGraphById(id); - return json(graph); -} - - -/** - * Updates a graph from a DTO - */ -export async function PUT({ request, params }): Promise { - const id = Number(params.id); - const dto: SerializedGraph = await request.json(); - - if (id !== dto.id) error(400, 'ID mismatch'); - - await GraphHelper.updateFromDTO(dto); - return new Response(null, { status: 201 }); -} diff --git a/src/routes/api/lecture/[id=number]/+server.ts b/src/routes/api/lecture/[id=number]/+server.ts deleted file mode 100644 index 100e3b9..0000000 --- a/src/routes/api/lecture/[id=number]/+server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { LectureHelper } from '$lib/server/helpers'; - -export async function DELETE({ params }) { - const id = Number(params.id); - await LectureHelper.remove(id); - return new Response(null, { status: 200 }); -} diff --git a/src/routes/api/subject/[id=number]/+server.ts b/src/routes/api/subject/[id=number]/+server.ts deleted file mode 100644 index afdec83..0000000 --- a/src/routes/api/subject/[id=number]/+server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SubjectHelper } from '$lib/server/helpers'; - -export async function DELETE({ params }) { - const id = Number(params.id); - await SubjectHelper.remove(id); - return new Response(null, { status: 200 }); -} From 56ae72abe433346a024ea973bfc685b4d52ec607 Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Thu, 19 Sep 2024 15:43:05 +0200 Subject: [PATCH 04/45] awooga api interaction --- src/lib/scripts/entities/Fields.ts | 21 ++++++++++++------- src/lib/server/helpers/DomainHelper.ts | 2 +- .../graph/[graph]/settings/+page.server.ts | 10 --------- .../graph/[graph]/settings/+page.svelte | 1 - .../[graph]/settings/DomainSettings.svelte | 18 +--------------- 5 files changed, 16 insertions(+), 36 deletions(-) diff --git a/src/lib/scripts/entities/Fields.ts b/src/lib/scripts/entities/Fields.ts index c007c47..bb8c846 100644 --- a/src/lib/scripts/entities/Fields.ts +++ b/src/lib/scripts/entities/Fields.ts @@ -158,14 +158,21 @@ class Domain extends Field { return options } - static create(graph: Graph, id: ID): Domain { + static async create(graph: Graph): Promise { /* Create a new domain */ + // Call API to create domain + const res = await fetch(`/api/graph/${graph.id}/domain`, { method: 'POST' }) + if (!res.ok) throw new Error('Failed to create domain') + + // Parse response + const data = await res.json() const domain = new Domain( - graph, graph.domains.length, id, - 0, 0, // TODO find non-overlapping x and y - graph.nextDomainStyle() + graph, + graph.domains.length, + data.id ) + graph.domains.push(domain) return domain } @@ -300,9 +307,9 @@ class Domain extends Field { } } - // Call API to delete domain (should cascade automatically?) - // TODO: check if this is the case - const res = await fetch(`/api/domain/${this.id}`, { method: 'DELETE' }); + // Call API to delete domain + const res = await fetch(`/api/domain/${this.id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Failed to create domain') // Remove this domain from the graph this.graph.domains = this.graph.domains.filter(domain => domain !== this) diff --git a/src/lib/server/helpers/DomainHelper.ts b/src/lib/server/helpers/DomainHelper.ts index 9ec8e08..7f9a55b 100644 --- a/src/lib/server/helpers/DomainHelper.ts +++ b/src/lib/server/helpers/DomainHelper.ts @@ -4,7 +4,7 @@ import prisma from '$lib/server/prisma' import type { Domain as PrismaDomain } from '@prisma/client' import type { SerializedDomain } from '$scripts/entities' -export { create, remove, setPosition, setName, setStyle, toggleRelation } +export { create, remove, setPosition, setName, setStyle, toggleRelation, makeDTO } // ----------------> Helper functions <---------------- diff --git a/src/routes/app/course/[course]/graph/[graph]/settings/+page.server.ts b/src/routes/app/course/[course]/graph/[graph]/settings/+page.server.ts index c49bb96..78c105f 100644 --- a/src/routes/app/course/[course]/graph/[graph]/settings/+page.server.ts +++ b/src/routes/app/course/[course]/graph/[graph]/settings/+page.server.ts @@ -10,16 +10,6 @@ import { CourseHelper, GraphHelper, DomainHelper, SubjectHelper, LectureHelper } // Actions export const actions = { - newDomain: async ({ params, request }): Promise => { - const data = await request.formData() - const graphId = Number(data.get('graph')) - - if (!graphId) return fail(400, { graphId, missing: true }) - - return await DomainHelper.create(graphId) - }, - - newSubject: async ({ params, request }): Promise => { const data = await request.formData() const graphId = Number(data.get('graph')) diff --git a/src/routes/app/course/[course]/graph/[graph]/settings/+page.svelte b/src/routes/app/course/[course]/graph/[graph]/settings/+page.svelte index d151386..83c5a33 100644 --- a/src/routes/app/course/[course]/graph/[graph]/settings/+page.svelte +++ b/src/routes/app/course/[course]/graph/[graph]/settings/+page.svelte @@ -5,7 +5,6 @@ import { goto } from '$app/navigation' // Internal imports - import { Severity } from '$scripts/entities' import { course, graph } from '$stores' import * as settings from '$scripts/settings' diff --git a/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte b/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte index ab40789..69caa22 100644 --- a/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte +++ b/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte @@ -23,22 +23,6 @@ import trashIcon from '$assets/trash-icon.svg' // Functions - async function createDomain() { - /* Creates a new domain */ - - let body = new FormData() - body.append('graph', $graph.id.toString()) - const response = await fetch('?/newDomain', { method: 'POST', body }) - if (!response.ok) { - console.error('Failed to create domain') - return - } - - const id = Number(JSON.parse((await response.json()).data)[0]) - Domain.create($graph, id) - update() - } - async function createDomainRelation() { /* Creates a new domain relation */ @@ -115,7 +99,7 @@
-
From 2061e62f7e4aadbf008bf287a1ac06532129f8b4 Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Mon, 23 Sep 2024 12:47:25 +0200 Subject: [PATCH 05/45] domain api poc --- src/lib/components/Dropdown.svelte | 21 +++++-- src/lib/components/Textfield.svelte | 2 +- src/lib/scripts/entities/Fields.ts | 19 ++++++ src/lib/server/helpers/DomainHelper.ts | 62 ++++++++++++++++++- src/routes/api/domain/[id]/+server.ts | 30 +++++++++ .../api/domain/[id]/name/[name]/+server.ts | 13 ---- .../domain/[id]/position/[x]/[y]/+server.ts | 14 ----- .../[id]/relation/[other_id]/+server.ts | 13 ---- .../api/domain/[id]/style/[style]/+server.ts | 13 ---- .../[graph]/settings/DomainSettings.svelte | 4 +- 10 files changed, 128 insertions(+), 63 deletions(-) delete mode 100644 src/routes/api/domain/[id]/name/[name]/+server.ts delete mode 100644 src/routes/api/domain/[id]/position/[x]/[y]/+server.ts delete mode 100644 src/routes/api/domain/[id]/relation/[other_id]/+server.ts delete mode 100644 src/routes/api/domain/[id]/style/[style]/+server.ts diff --git a/src/lib/components/Dropdown.svelte b/src/lib/components/Dropdown.svelte index 686f19c..72152c0 100644 --- a/src/lib/components/Dropdown.svelte +++ b/src/lib/components/Dropdown.svelte @@ -1,6 +1,9 @@ - - - - -
- - - - -
-
- - - - - -
-
- - {#if active_tab === 0} - - {:else if active_tab === 1} - - {:else if active_tab === 2} - - {:else if active_tab === 3} - - {/if} -
- +{#await loading} {:then} + + + + + + +
+ + + + +
+
+ + + + + +
+
+ + {#if active_tab === 0} + + {:else if active_tab === 1} + + {:else if active_tab === 2} + + {:else if active_tab === 3} + + {/if} +
+ +{/await} diff --git a/src/routes/app/course/[course]/graph/[graph]/settings/+page.ts b/src/routes/app/course/[course]/graph/[graph]/settings/+page.ts index cbb040b..b48d7aa 100644 --- a/src/routes/app/course/[course]/graph/[graph]/settings/+page.ts +++ b/src/routes/app/course/[course]/graph/[graph]/settings/+page.ts @@ -4,7 +4,7 @@ import { Course, Graph } from '$lib/scripts/entities' import { course, graph } from '$stores' // Load -export const load = ({ data }) => { - course.set(Course.revive(data.course)) - graph.set(Graph.revive(data.graph)) +export const load = async ({ data }) => { + course.set(await Course.revive(data.course)) + graph.set(await Graph.revive(data.graph)) } \ No newline at end of file diff --git a/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte b/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte index 975e215..6516ed0 100644 --- a/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte +++ b/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte @@ -111,7 +111,9 @@ on:click={() => { domain_style_sort = undefined domain_name_sort = !domain_name_sort - $graph.sort(SortOption.domains | SortOption.name, domain_name_sort) + + const options = domain_name_sort ? SortOption.descending : SortOption.ascending + $graph.sort(options | SortOption.domains | SortOption.name) update() }} /> @@ -125,7 +127,9 @@ on:click={() => { domain_name_sort = undefined domain_style_sort = !domain_style_sort - $graph.sort(SortOption.domains | SortOption.style, domain_style_sort) + + const options = domain_style_sort ? SortOption.descending : SortOption.ascending + $graph.sort(options | SortOption.domains | SortOption.style) update() }} /> @@ -204,7 +208,9 @@ on:click={() => { relation_child_sort = undefined relation_parent_sort = !relation_parent_sort - $graph.sort(SortOption.relations | SortOption.domains | SortOption.parent, relation_parent_sort) + + const options = relation_parent_sort ? SortOption.descending : SortOption.ascending + $graph.sort(options | SortOption.relations | SortOption.domains | SortOption.parent) update() }} /> @@ -218,7 +224,9 @@ on:click={() => { relation_parent_sort = undefined relation_child_sort = !relation_child_sort - $graph.sort(SortOption.relations | SortOption.domains | SortOption.child, relation_child_sort) + + const options = relation_child_sort ? SortOption.descending : SortOption.ascending + $graph.sort(options | SortOption.relations | SortOption.domains | SortOption.child) update() }} /> diff --git a/src/routes/app/course/[course]/graph/[graph]/settings/SubjectSettings.svelte b/src/routes/app/course/[course]/graph/[graph]/settings/SubjectSettings.svelte index 6cc501c..4c8fedf 100644 --- a/src/routes/app/course/[course]/graph/[graph]/settings/SubjectSettings.svelte +++ b/src/routes/app/course/[course]/graph/[graph]/settings/SubjectSettings.svelte @@ -21,6 +21,7 @@ import neutralSortIcon from '$assets/neutral-sort-icon.svg' import ascendingSortIcon from '$assets/ascending-sort-icon.svg' import descedingSortIcon from '$assets/descending-sort-icon.svg' + import { descending } from 'd3'; // Functions function subjectMatchesQuery(query: string, subject: Subject): boolean { @@ -111,7 +112,9 @@ on:click={() => { subject_domain_sort = undefined subject_name_sort = !subject_name_sort - $graph.sort(SortOption.subjects | SortOption.name, subject_name_sort) + + const options = subject_name_sort ? SortOption.descending : SortOption.ascending + $graph.sort(options | SortOption.subjects | SortOption.name) update() }} /> @@ -125,7 +128,9 @@ on:click={() => { subject_name_sort = undefined subject_domain_sort = !subject_domain_sort - $graph.sort(SortOption.subjects | SortOption.domain, subject_domain_sort) + + const options = subject_domain_sort ? SortOption.descending : SortOption.ascending + $graph.sort(options | SortOption.subjects | SortOption.domain) update() }} /> @@ -204,7 +209,9 @@ on:click={() => { relation_child_sort = undefined relation_parent_sort = !relation_parent_sort - $graph.sort(SortOption.relations | SortOption.subjects | SortOption.parent, relation_parent_sort) + + const options = relation_parent_sort ? SortOption.descending : SortOption.ascending + $graph.sort(options | SortOption.relations | SortOption.subjects | SortOption.parent) update() }} /> @@ -218,7 +225,9 @@ on:click={() => { relation_parent_sort = undefined relation_child_sort = !relation_child_sort - $graph.sort(SortOption.relations | SortOption.subjects | SortOption.child, relation_child_sort) + + const options = relation_child_sort ? SortOption.descending : SortOption.ascending + $graph.sort(options | SortOption.relations | SortOption.subjects | SortOption.child) update() }} /> diff --git a/src/routes/app/course/[course]/settings/+page.svelte b/src/routes/app/course/[course]/settings/+page.svelte index a8b7d35..bfeac83 100644 --- a/src/routes/app/course/[course]/settings/+page.svelte +++ b/src/routes/app/course/[course]/settings/+page.svelte @@ -2,6 +2,7 @@ @@ -60,9 +64,9 @@ ]} > - +
- From 5c57a39511e8600bb04af95bee651e0cb157062f Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Mon, 30 Sep 2024 20:00:53 +0200 Subject: [PATCH 22/45] better errors from the API --- src/lib/scripts/entities/Course.ts | 26 ++++----- src/lib/scripts/entities/Fields.ts | 76 +++++++++++++------------ src/lib/scripts/entities/Graph.ts | 63 +++++++++----------- src/lib/scripts/entities/Lecture.ts | 32 ++++++----- src/lib/scripts/entities/Relations.ts | 16 +++--- src/lib/server/helpers/CourseHelper.ts | 32 ++++++----- src/lib/server/helpers/DomainHelper.ts | 36 ++++++------ src/lib/server/helpers/GraphHelper.ts | 31 +++++----- src/lib/server/helpers/LectureHelper.ts | 34 +++++------ src/lib/server/helpers/SubjectHelper.ts | 36 ++++++------ 10 files changed, 194 insertions(+), 188 deletions(-) diff --git a/src/lib/scripts/entities/Course.ts b/src/lib/scripts/entities/Course.ts index 002540a..d35481e 100644 --- a/src/lib/scripts/entities/Course.ts +++ b/src/lib/scripts/entities/Course.ts @@ -54,9 +54,7 @@ class Course { // Call the API const response = await fetch(`/api/courses/${this.id}/graphs`, { method: 'GET' }) - - // Check the response - if (!response.ok) throw new Error('Failed to delete graph') + .catch(error => { throw new Error(`Failed to load course: ${error}`) }) // Parse the response const data: SerializedGraph[] = await response.json() @@ -71,7 +69,7 @@ class Course { /* Save the course to the database */ // Call the API - const response = await fetch(`/api/course`, { + await fetch(`/api/course`, { method: 'PUT', headers: { 'Content-Type': 'application/json' @@ -80,29 +78,27 @@ class Course { }) // Check the response - if (!response.ok) throw new Error('Failed to save course') + .catch(error => { + throw new Error(`Failed to save course: ${error}`) + }) } async delete(): Promise { /* Delete the graph from the database */ // Call the API - const response = await fetch(`/api/course/${this.id}`, { - method: 'DELETE' - }) - - // Check the response - if (!response.ok) throw new Error('Failed to delete course') + await fetch(`/api/course/${this.id}`, { method: 'DELETE' }) + .catch(error => { throw new Error(`Failed to delete course: ${error}`) }) } validate(): ValidationData { /* Validate the course */ - const response = new ValidationData() + const result = new ValidationData() // Check if the course code is valid if (this.code === '') { - response.add({ + result.add({ severity: Severity.error, short: 'Course has no code', tab: 0, @@ -112,7 +108,7 @@ class Course { // Check if the course name is valid if (this.name === '') { - response.add({ + result.add({ severity: Severity.error, short: 'Course has no name', tab: 0, @@ -120,7 +116,7 @@ class Course { }) } - return response + return result } reduce(): SerializedCourse { diff --git a/src/lib/scripts/entities/Fields.ts b/src/lib/scripts/entities/Fields.ts index 9db6427..5b34f6f 100644 --- a/src/lib/scripts/entities/Fields.ts +++ b/src/lib/scripts/entities/Fields.ts @@ -165,12 +165,12 @@ class Domain extends Field { static async create(graph: Graph): Promise { /* Create a new domain */ - // Call API to create domain - const result = await fetch(`/api/graph/${graph.id}/domain`, { method: 'POST' }) - if (!result.ok) throw new Error('Failed to create domain') + // Call the API + const response = await fetch(`/api/graph/${graph.id}/domain`, { method: 'POST' }) + .catch(error => { throw new Error(`Failed to create domain: ${error}`) }) // Parse response - const data = await result.json() + const data = await response.json() // Create domain object const domain = new Domain(graph, graph.domains.length, data.id) @@ -195,11 +195,11 @@ class Domain extends Field { validate(): ValidationData { /* Validate this domain */ - const response = new ValidationData() + const result = new ValidationData() // Check if the domain has a name if (!this.hasName(this)) { - response.add({ + result.add({ severity: Severity.error, short: 'Domain has no name', tab: 1, @@ -212,7 +212,7 @@ class Domain extends Field { // Check if the domain has a unique name const first = this.findOriginal(this.graph.domains, this, domain => domain.name) if (first !== -1) { - response.add({ + result.add({ severity: Severity.error, short: 'Domain has duplicate name', long: `Name first used by Domain nr. ${first + 1}`, @@ -223,7 +223,7 @@ class Domain extends Field { // Check if the name is too long if (this.name.length > settings.FIELD_MAX_CHARS) { - response.add({ + result.add({ severity: Severity.error, short: 'Domain name too long', long: `Name exceeds ${settings.FIELD_MAX_CHARS} characters`, @@ -235,7 +235,7 @@ class Domain extends Field { // Check if the domain has a style if (!this.hasStyle()) { - response.add({ + result.add({ severity: Severity.error, short: 'Domain has no style', tab: 1, @@ -248,7 +248,7 @@ class Domain extends Field { // Check if the domain has a unique style const first = this.findOriginal(this.graph.domains, this, domain => domain.style) if (first !== -1) { - response.add({ + result.add({ severity: Severity.warning, short: 'Domain has duplicate style', long: `Style first used by Domain nr. ${first + 1}`, @@ -260,7 +260,7 @@ class Domain extends Field { // Check if the domain has subjects if (!this.hasSubjects()) { - response.add({ + result.add({ severity: Severity.warning, short: 'Domain has no subjects', tab: 1, @@ -268,7 +268,7 @@ class Domain extends Field { }) } - return response + return result } reduce(): SerializedDomain { @@ -291,15 +291,17 @@ class Domain extends Field { // Serialize const data = this.reduce() - // Call API to update domain - const result = await fetch(`/api/domain`, { + // Call the API + await fetch(`/api/domain`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) - // Check if the request was successful - if (!result.ok) throw new Error('Failed to save domain') + // Check the response + .catch(error => { + throw new Error(`Failed to save course: ${error}`) + }) } async delete(): Promise { @@ -326,9 +328,9 @@ class Domain extends Field { } } - // Call API to delete domain - const result = await fetch(`/api/domain/${this.id}`, { method: 'DELETE' }) - if (!result.ok) throw new Error('Failed to delete domain') + // Call the API + await fetch(`/api/domain/${this.id}`, { method: 'DELETE' }) + .catch(error => { throw new Error(`Failed to delete domain: ${error}`) }) // Remove this domain from the graph this.graph.domains = this.graph.domains.filter(domain => domain !== this) @@ -393,12 +395,12 @@ class Subject extends Field { static async create(graph: Graph): Promise { /* Create a new domain */ - // Call API to create domain - const result = await fetch(`/api/graph/${graph.id}/subject`, { method: 'POST' }) - if (!result.ok) throw new Error('Failed to create domain') + // Call the API + const response = await fetch(`/api/graph/${graph.id}/subject`, { method: 'POST' }) + .catch(error => { throw new Error(`Failed to create subject: ${error}`) }) // Parse response - const data = await result.json() + const data = await response.json() // Create subject object const subject = new Subject(graph, graph.subjects.length, data.id) @@ -410,11 +412,11 @@ class Subject extends Field { validate(): ValidationData { /* Validate this subject */ - const response = new ValidationData() + const result = new ValidationData() // Check if the subject has a name if (!this.hasName(this)) { - response.add({ + result.add({ severity: Severity.error, short: 'Subject has no name', tab: 2, @@ -427,7 +429,7 @@ class Subject extends Field { // Check if the name is unique const first = this.findOriginal(this.graph.subjects, this, subject => subject.name) if (first !== -1) { - response.add({ + result.add({ severity: Severity.error, short: 'Subject has duplicate name', long: `Name first used by Subject nr. ${first + 1}`, @@ -438,7 +440,7 @@ class Subject extends Field { // Check if the name is too long if (this.name.length > settings.FIELD_MAX_CHARS) { - response.add({ + result.add({ severity: Severity.error, short: 'Subject name too long', long: `Name exceeds ${settings.FIELD_MAX_CHARS} characters`, @@ -450,7 +452,7 @@ class Subject extends Field { // Check if the subject has a domain if (!this.hasDomain()) { - response.add({ + result.add({ severity: Severity.error, short: 'Subject has no domain', tab: 2, @@ -458,7 +460,7 @@ class Subject extends Field { }) } - return response + return result } reduce(): SerializedSubject { @@ -481,15 +483,17 @@ class Subject extends Field { // Serialize const data = this.reduce() - // Call API to update domain - const result = await fetch(`/api/subject`, { + // Call the API + await fetch(`/api/subject`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) - // Check if the request was successful - if (!result.ok) throw new Error('Failed to save subject') + // Check the response + .catch(error => { + throw new Error(`Failed to save subject: ${error}`) + }) } async delete(): Promise { @@ -514,9 +518,9 @@ class Subject extends Field { lecture.lecture_subjects = lecture.lecture_subjects.filter(ls => ls.subject !== this) } - // Call API to delete domain - const result = await fetch(`/api/subject/${this.id}`, { method: 'DELETE' }) - if (!result.ok) throw new Error('Failed to delete subject') + // Call the API + await fetch(`/api/subject/${this.id}`, { method: 'DELETE' }) + .catch(error => { throw new Error(`Failed to delete subject: ${error}`) }) // Remove this subject from the graph this.graph.subjects = this.graph.subjects.filter(subject => subject !== this) diff --git a/src/lib/scripts/entities/Graph.ts b/src/lib/scripts/entities/Graph.ts index b7c44eb..329cd07 100644 --- a/src/lib/scripts/entities/Graph.ts +++ b/src/lib/scripts/entities/Graph.ts @@ -65,7 +65,7 @@ class Graph { /* Return the options of the lecture */ // Check if the graph is lazy - if (this._lazy) throw new Error('Graph is lazy') + if (this._lazy) throw new Error('Failed to get lecture options: graph is lazy') // Find lecture options const options = [] @@ -94,28 +94,21 @@ class Graph { if (!this._lazy) return // Call the API - const responses = await Promise.all([ - fetch(`/api/graph/${this.id}/domain`, { method: 'GET' }), - fetch(`/api/graph/${this.id}/subject`, { method: 'GET' }), - fetch(`/api/graph/${this.id}/lecture`, { method: 'GET' }) - ]) - - // Check the responses - if (!responses.every(response => response.ok)) { - console.error(responses) - throw new Error(`Failed to load Graph: Bad response`) - } - - // Parse the responses - const json = await Promise.all(responses.map(response => response.json())) - const data = { - domains: json[0] as SerializedDomain[], - subjects: json[1] as SerializedSubject[], - lectures: json[2] as SerializedLecture[] - } + const urls = [ + `/api/graph/${this.id}/domain`, + `/api/graph/${this.id}/subject`, + `/api/graph/${this.id}/lecture` + ] + + const [domains, subjects, lectures] = await Promise.all( + urls.map(url => fetch(url, { method: 'GET' }) + .then(response => response.json()) + .catch(error => { throw new Error(`Failed to load graph: ${error}`) }) + ) + ) // Define domains - for (const domain_data of data.domains) { + for (const domain_data of domains) { this.domains.push( new Domain( this, @@ -130,7 +123,7 @@ class Graph { } // Find domain references - for (const parent_data of data.domains) { + for (const parent_data of domains) { const parent = this.domains.find(domain => domain.id === parent_data.id) for (const child_id of parent_data.children) { const child = this.domains.find(domain => domain.id === child_id) @@ -141,7 +134,7 @@ class Graph { } // Define subjects - for (const subject_data of data.subjects) { + for (const subject_data of subjects) { const domain = this.domains.find(domain => domain.id === subject_data.domain) this.subjects.push( @@ -158,7 +151,7 @@ class Graph { } // Find subject references - for (const parent_data of data.subjects) { + for (const parent_data of subjects) { const parent = this.subjects.find(subject => subject.id === parent_data.id) for (const child_id of parent_data.children) { const child = this.subjects.find(subject => subject.id === child_id) @@ -169,7 +162,7 @@ class Graph { } // Define lectures - for (const lecture_data of data.lectures) { + for (const lecture_data of lectures) { const lecture = new Lecture( this, this.lectures.length, @@ -194,7 +187,7 @@ class Graph { /* Save the graph to the database */ // Call the API - const response = await fetch(`/api/graph`, { + await fetch(`/api/graph`, { method: 'PUT', headers: { 'Content-Type': 'application/json' @@ -203,26 +196,24 @@ class Graph { }) // Check the response - if (!response.ok) throw new Error('Failed to save graph') + .catch(error => { + throw new Error(`Failed to save graph: ${error}`) + }) } async delete() { /* Delete the graph from the database */ // Call the API - const response = await fetch(`/api/graph/${this.id}`, { - method: 'DELETE' - }) - - // Check the response - if (!response.ok) throw new Error('Failed to delete graph') + await fetch(`/api/graph/${this.id}`, { method: 'DELETE' }) + .catch(error => { throw new Error(`Failed to delete graph: ${error}`) }) } validate(): ValidationData { /* Validate the graph */ // Check if the graph is lazy - if (this._lazy) throw new Error('Graph is lazy') + if (this._lazy) throw new Error('Failed to validate graph: graph is lazy') // Create response const validation = new ValidationData() @@ -257,7 +248,7 @@ class Graph { /* Sort the graph */ // Check if the graph is lazy - if (this._lazy) throw new Error('Graph is lazy') + if (this._lazy) throw new Error('Failed to sort graph: graph is lazy') // Define key function let key: (item: any) => string @@ -299,7 +290,7 @@ class Graph { /* Return the next available domain style */ // Check if the graph is lazy - if (this._lazy) throw new Error('Graph is lazy') + if (this._lazy) throw new Error('Failed to get next domain style: graph is lazy') // Find used styles const used_styles = this.domains.map(domain => domain.style) diff --git a/src/lib/scripts/entities/Lecture.ts b/src/lib/scripts/entities/Lecture.ts index 0ed5a7d..3267f12 100644 --- a/src/lib/scripts/entities/Lecture.ts +++ b/src/lib/scripts/entities/Lecture.ts @@ -95,11 +95,11 @@ class Lecture { /* Create a new lecture */ // Call API to create lecture - const res = await fetch(`/api/graph/${graph.id}/lecture`, { method: 'POST' }) - if (!res.ok) throw new Error('Failed to create lecture') + const response = await fetch(`/api/graph/${graph.id}/lecture`, { method: 'POST' }) + .catch(error => { throw new Error(`Failed to create lecture: ${error}`) }) // Parse response - const data = await res.json() + const data = await response.json() // Create lecture object const lecture = new Lecture(graph, graph.lectures.length, data.id) @@ -217,11 +217,11 @@ class Lecture { validate(): ValidationData { /* Validate the lecture */ - const response = new ValidationData() + const result = new ValidationData() // Check if the lecture has a name if (!this.hasName()){ - response.add({ + result.add({ severity: Severity.error, short: 'Lecture has no name', tab: 3, @@ -233,7 +233,7 @@ class Lecture { else { const first = this.findOriginal(this.graph.lectures, this, lecture => lecture.name) if (first !== -1) { - response.add({ + result.add({ severity: Severity.error, short: 'Duplicate lecture name', long: `Name first used by Lecture nr. ${first + 1}`, @@ -245,7 +245,7 @@ class Lecture { // Check if the lecture has subjects if (!this.hasSubjects()) { - response.add({ + result.add({ severity: Severity.error, short: 'Lecture has no subjects', tab: 3, @@ -256,7 +256,7 @@ class Lecture { // TODO maybe just save defined subjects and remove this error // Check if the lecture has undefined subjects else if (!this.isDefined()) { - response.add({ + result.add({ severity: Severity.error, short: 'Lecture has undefined subjects', long: 'Make sure all subjects are defined', @@ -265,7 +265,7 @@ class Lecture { }) } - return response + return result } reduce(): SerializedLecture { @@ -284,15 +284,17 @@ class Lecture { // Serialize const data = this.reduce() - // Call API to update domain - const result = await fetch(`/api/lecture`, { + // Call the API + await fetch(`/api/lecture`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) - // Check if the request was successful - if (!result.ok) throw new Error('Failed to save lecture') + // Check the response + .catch(error => { + throw new Error(`Failed to save lecture: ${error}`) + }) } async delete() { @@ -305,8 +307,8 @@ class Lecture { } // Call API to delete lecture - const result = await fetch(`/api/lecture/${this.id}`, { method: 'DELETE' }) - if (!result.ok) throw new Error('Failed to delete lecture') + await fetch(`/api/lecture/${this.id}`, { method: 'DELETE' }) + .catch(error => { throw new Error(`Failed to delete lecture: ${error}`) }) // Remove this lecture from the graph this.graph.lectures = this.graph.lectures.filter(lecture => lecture !== this) diff --git a/src/lib/scripts/entities/Relations.ts b/src/lib/scripts/entities/Relations.ts index a54f0fe..3fc1810 100644 --- a/src/lib/scripts/entities/Relations.ts +++ b/src/lib/scripts/entities/Relations.ts @@ -217,11 +217,11 @@ class DomainRelation extends Relation { validate(): ValidationData { /* Validate this domain relation */ - const response = new ValidationData() + const result = new ValidationData() // Check if the relation is defined if (!this.isDefined(this.parent, this.child)) { - response.add({ + result.add({ severity: Severity.error, short: 'Domain relation is not fully defined', long: 'Both the parent and child domains must be selected', @@ -232,7 +232,7 @@ class DomainRelation extends Relation { // Check if the relation is consistent if (this.isInconsistent(this.parent, this.child)) { - response.add({ + result.add({ severity: Severity.warning, short: 'Domain relation is inconsistent', long: 'The subjects of these domains are not related', @@ -241,7 +241,7 @@ class DomainRelation extends Relation { }) } - return response + return result } delete(): void { @@ -361,11 +361,11 @@ class SubjectRelation extends Relation { validate(): ValidationData { /* Validate this subject relation */ - const response = new ValidationData() + const result = new ValidationData() // Check if the relation is defined if (!this.isDefined(this.parent, this.child)) { - response.add({ + result.add({ severity: Severity.error, short: 'Subject relation is not fully defined', long: 'Both the parent and child subjects must be selected', @@ -376,7 +376,7 @@ class SubjectRelation extends Relation { // Check if the relation is consistent if (this.isInconsistent(this.parent, this.child)) { - response.add({ + result.add({ severity: Severity.warning, short: 'Subject relation is inconsistent', long: 'The domains of these subjects are not related', @@ -385,7 +385,7 @@ class SubjectRelation extends Relation { }) } - return response + return result } delete(): void { diff --git a/src/lib/server/helpers/CourseHelper.ts b/src/lib/server/helpers/CourseHelper.ts index 2534060..59d3d84 100644 --- a/src/lib/server/helpers/CourseHelper.ts +++ b/src/lib/server/helpers/CourseHelper.ts @@ -2,12 +2,14 @@ import prisma from '$lib/server/prisma' import type { SerializedCourse } from '$scripts/entities' -import type { - Course as PrismaCourse -} from '@prisma/client' +import type { Course as PrismaCourse } from '@prisma/client' export { create, remove, update, reduce, getById } + +// --------------------> Helper Functions <-------------------- // + + /** * Retrieves a Course by its ID. * @param course_id @@ -39,20 +41,20 @@ async function getById(course_id: number): Promise { async function create(program_id: number, code: string, name: string): Promise { try { var course = await prisma.course.create({ - data: { - code, - name, - program: { - connect: { - id: program_id - } - } - } - }) + data: { + code, + name, + program: { + connect: { + id: program_id + } + } + } + }) } catch (error) { return Promise.reject(error) } - + return await reduce(course) } @@ -103,7 +105,7 @@ async function update(data: SerializedCourse): Promise { async function reduce(course: PrismaCourse): Promise { return { id: course.id, - code: course.code, + code: course.code, name: course.name } } \ No newline at end of file diff --git a/src/lib/server/helpers/DomainHelper.ts b/src/lib/server/helpers/DomainHelper.ts index 0cd2591..a0f24c9 100644 --- a/src/lib/server/helpers/DomainHelper.ts +++ b/src/lib/server/helpers/DomainHelper.ts @@ -6,6 +6,10 @@ import type { SerializedDomain } from '$scripts/entities' export { create, remove, update, reduce, getByGraphId } + +// --------------------> Helper Functions <-------------------- // + + /** * Retrieves all Domain objects associated with a Graph. * @param graph_id ID of the Graph @@ -13,13 +17,17 @@ export { create, remove, update, reduce, getByGraphId } */ async function getByGraphId(graph_id: number): Promise { - const domains = await prisma.domain.findMany({ - where: { - graph: { - id: graph_id + try { + var domains = await prisma.domain.findMany({ + where: { + graph: { + id: graph_id + } } - } - }) + }) + } catch (error) { + return Promise.reject(error) + } return await Promise.all(domains.map(reduce)) } @@ -28,7 +36,6 @@ async function getByGraphId(graph_id: number): Promise { * Creates a Domain object in the database. * @param graph_id ID of the Graph to which the Domain belongs * @returns SerializedDomain object - * @throws 'Failed to create domain' if the Domain could not be created */ async function create(graph_id: number): Promise { @@ -43,7 +50,7 @@ async function create(graph_id: number): Promise { } }) } catch (error) { - return Promise.reject('Failed to create domain') + return Promise.reject(error) } return await reduce(domain) @@ -52,7 +59,6 @@ async function create(graph_id: number): Promise { /** * Removes a Domain from the database. * @param domain_id ID of the Domain to remove - * @throws 'Failed to remove domain' if the Domain could not be removed */ async function remove(domain_id: number): Promise { @@ -63,21 +69,20 @@ async function remove(domain_id: number): Promise { } }) } catch (error) { - return Promise.reject('Failed to remove domain') + return Promise.reject(error) } } /** * Updates a Domain in the database. * @param data SerializedDomain object - * @throws 'Domain not found' if the Domain could not be found - * @throws 'Failed to update domain' if the Domain could not be updated */ async function update(data: SerializedDomain): Promise { // Get current relations const { children, parents } = await getRelations(data.id) + .catch(error => Promise.reject(error)) // Find changes in relations const new_parents = data.parents.filter((parent) => !parents.some((domain) => domain.id === parent)) @@ -109,7 +114,7 @@ async function update(data: SerializedDomain): Promise { } }) } catch (error) { - return Promise.reject('Failed to update domain') + return Promise.reject(error) } } @@ -117,11 +122,11 @@ async function update(data: SerializedDomain): Promise { * Reduces a PrismaDomain to a SerializedDomain. * @param domain PrismaDomain object * @returns SerializedDomain object - * @throws 'Domain not found' if the Domain could not be found */ async function reduce(domain: PrismaDomain): Promise { const { children, parents } = await getRelations(domain.id) + .catch(error => Promise.reject(error)) return { id: domain.id, @@ -138,7 +143,6 @@ async function reduce(domain: PrismaDomain): Promise { * Retrieves the children and parents of a Domain. * @param domain_id ID of the Domain * @returns Object containing the children and parents - * @throws 'Domain not found' if the Domain could not be found */ async function getRelations(domain_id: number): Promise<{ children: PrismaDomain[], parents: PrismaDomain[]}> { @@ -153,7 +157,7 @@ async function getRelations(domain_id: number): Promise<{ children: PrismaDomain } }) } catch (error) { - return Promise.reject('Domain not found') + return Promise.reject(error) } return { diff --git a/src/lib/server/helpers/GraphHelper.ts b/src/lib/server/helpers/GraphHelper.ts index 3d554b8..75e99cd 100644 --- a/src/lib/server/helpers/GraphHelper.ts +++ b/src/lib/server/helpers/GraphHelper.ts @@ -1,18 +1,15 @@ import prisma from '$lib/server/prisma' -import { DomainHelper, SubjectHelper, LectureHelper } from '.' - import type { SerializedGraph } from '$scripts/entities' -import type { - Graph as PrismaGraph, - Domain as PrismaDomain, - Subject as PrismaSubject, - Lecture as PrismaLecture -} from '@prisma/client' +import type { Graph as PrismaGraph } from '@prisma/client' export { create, remove, update, reduce, getByCourseId, getById } + +// --------------------> Helper Functions <-------------------- // + + /** * Retrieves all Graphs associated with a Course. * @param course_id @@ -20,13 +17,17 @@ export { create, remove, update, reduce, getByCourseId, getById } */ async function getByCourseId(course_id: number): Promise { - const graphs = await prisma.graph.findMany({ - where: { - course: { - id: course_id + try { + var graphs = await prisma.graph.findMany({ + where: { + course: { + id: course_id + } } - } - }) + }) + } catch (error) { + return Promise.reject(error) + } return await Promise.all(graphs.map(reduce)) } @@ -73,7 +74,7 @@ async function create(course_code: string, name: string): Promise Helper Functions <-------------------- // + + /** * Retrieves all Lecture objects associated with a Graph. * @param graph_id ID of the Graph * @returns Array of Serialized Lecture objects - */ + */ async function getByGraphId(graph_id: number): Promise { - const lectures = await prisma.lecture.findMany({ - where: { - graph: { - id: graph_id + try { + var lectures = await prisma.lecture.findMany({ + where: { + graph: { + id: graph_id + } } - } - }) + }) + } catch (error) { + return Promise.reject(error) + } return await Promise.all(lectures.map(reduce)) } @@ -33,7 +39,6 @@ async function getByGraphId(graph_id: number): Promise { * Creates a Lecture object in the database. * @param graph_id ID of the Graph to which the Lecture belongs * @returns SerializedLecture object - * @throws 'Failed to create lecture' if the Lecture could not be created */ async function create(graph_id: number): Promise { @@ -57,7 +62,6 @@ async function create(graph_id: number): Promise { /** * Removes a Lecture from the database. * @param lecture_id ID of the Lecture to remove - * @throws 'Failed to remove lecture' if the Lecture could not be removed */ async function remove(lecture_id: number): Promise { @@ -75,14 +79,13 @@ async function remove(lecture_id: number): Promise { /** * Updates a Lecture in the database. * @param data SerializedLecture object - * @throws 'Lecture not found' if the Lecture could not be found - * @throws 'Failed to update lecture' if the Lecture could not be updated */ async function update(data: SerializedLecture): Promise { // Get current subjects const subjects = await getSubjects(data.id) + .catch(error => Promise.reject(error)) // Find changes in subjects const new_subjects = data.subjects.filter(id => !subjects.some(subject => subject.id === id)) @@ -111,11 +114,11 @@ async function update(data: SerializedLecture): Promise { * Reduces a PrismaLecture to a SerializedLecture. * @param lecture PrismaLecture object * @returns SerializedLecture object - * @throws 'Lecture not found' if the Lecture could not be found */ async function reduce(lecture: PrismaLecture): Promise { const subjects = await getSubjects(lecture.id) + .catch(error => Promise.reject(error)) return { id: lecture.id, @@ -128,7 +131,6 @@ async function reduce(lecture: PrismaLecture): Promise { * Gets the subjects of a Lecture. * @param lecture_id ID of the Lecture * @returns Array of PrismaSubjects - * @throws 'Lecture not found' if the Lecture could not be found */ async function getSubjects(lecture_id: number): Promise { @@ -142,7 +144,7 @@ async function getSubjects(lecture_id: number): Promise { } }) } catch (error) { - return Promise.reject('Lecture not found') + return Promise.reject(error) } return lecture.subjects diff --git a/src/lib/server/helpers/SubjectHelper.ts b/src/lib/server/helpers/SubjectHelper.ts index 38bdd7c..dcd7448 100644 --- a/src/lib/server/helpers/SubjectHelper.ts +++ b/src/lib/server/helpers/SubjectHelper.ts @@ -6,6 +6,10 @@ import type { SerializedSubject } from '$scripts/entities' export { create, remove, update, reduce, getByGraphId } + +// --------------------> Helper Functions <-------------------- // + + /** * Retrieves all Subject objects associated with a Graph. * @param graph_id ID of the Graph @@ -13,13 +17,17 @@ export { create, remove, update, reduce, getByGraphId } */ async function getByGraphId(graph_id: number): Promise { - const subjects = await prisma.subject.findMany({ - where: { - graph: { - id: graph_id + try { + var subjects = await prisma.subject.findMany({ + where: { + graph: { + id: graph_id + } } - } - }) + }) + } catch (error) { + return Promise.reject(error) + } return await Promise.all(subjects.map(reduce)) } @@ -28,7 +36,6 @@ async function getByGraphId(graph_id: number): Promise { * Creates a Subject object in the database. * @param graph_id ID of the Graph to which the Subject belongs * @returns SerializedSubject object - * @throws 'Failed to create subject' if the Subject could not be created */ async function create(graph_id: number): Promise { @@ -43,7 +50,7 @@ async function create(graph_id: number): Promise { } }) } catch (error) { - return Promise.reject('Failed to create subject') + return Promise.reject(error) } return await reduce(subject) @@ -52,7 +59,6 @@ async function create(graph_id: number): Promise { /** * Removes a Subject from the database. * @param subject_id ID of the Subject to remove - * @throws 'Failed to remove subject' if the Subject could not be removed */ async function remove(subject_id: number): Promise { @@ -63,21 +69,20 @@ async function remove(subject_id: number): Promise { } }) } catch (error) { - return Promise.reject('Failed to remove subject') + return Promise.reject(error) } } /** * Updates a Subject in the database. * @param data SerializedSubject object - * @throws 'Subject not found' if the Subject could not be found - * @throws 'Failed to update subject' if the Subject could not be updated */ async function update(data: SerializedSubject): Promise { // Get current relations const { children, parents } = await getRelations(data.id) + .catch(error => Promise.reject(error)) // Find changes in relations const new_parents = data.parents.filter((parent) => !parents.some((subject) => subject.id === parent)) @@ -109,7 +114,7 @@ async function update(data: SerializedSubject): Promise { } }) } catch (error) { - return Promise.reject('Failed to update subject') + return Promise.reject(error) } } @@ -117,11 +122,11 @@ async function update(data: SerializedSubject): Promise { * Reduces a PrismaSubject to a SerializedSubject. * @param subject PrismaSubject object * @returns SerializedSubject object - * @throws 'Subject not found' if the Subject could not be found */ async function reduce(subject: PrismaSubject): Promise { const { children, parents } = await getRelations(subject.id) + .catch(error => Promise.reject(error)) return { id: subject.id, @@ -138,7 +143,6 @@ async function reduce(subject: PrismaSubject): Promise { * Retrieves the children and parents of a Subject. * @param subject_id ID of the Subject * @returns Object containing the children and parents - * @throws 'Subject not found' if the Subject could not be found */ async function getRelations(subject_id: number): Promise<{ children: PrismaSubject[], parents: PrismaSubject[]}> { @@ -153,7 +157,7 @@ async function getRelations(subject_id: number): Promise<{ children: PrismaSubje } }) } catch (error) { - return Promise.reject('Subject not found') + return Promise.reject(error) } return { From 5a20151e28900ab0f2640163c26b4fc294f3f648 Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Mon, 30 Sep 2024 21:43:45 +0200 Subject: [PATCH 23/45] the great rename --- src/lib/components/Dropdown.svelte | 2 +- src/lib/components/Validation.svelte | 2 +- src/lib/scripts/{entities => }/BaseModal.ts | 12 +- .../FieldSVGController.ts} | 70 ++++---- .../GraphSVGController.ts} | 155 ++++++++++-------- .../OverlaySVGController.ts} | 28 ++-- .../RelationSVGController.ts} | 24 +-- src/lib/scripts/SVGControllers/index.ts | 5 + .../CourseController.ts} | 32 ++-- .../FieldsController.ts} | 81 ++++----- .../GraphController.ts} | 60 +++---- .../LectureController.ts} | 54 +++--- .../RelationsController.ts} | 59 ++++--- .../User.ts => controllers/UserController.ts} | 26 +-- src/lib/scripts/controllers/index.ts | 7 + src/lib/scripts/d3/index.ts | 5 - src/lib/scripts/entities/index.ts | 14 -- .../helpers/CourseHelper.ts | 9 +- .../helpers/DomainHelper.ts | 9 +- .../helpers/GraphHelper.ts | 9 +- .../helpers/LectureHelper.ts | 9 +- .../helpers/SubjectHelper.ts | 9 +- src/lib/{server => scripts}/helpers/index.ts | 0 src/lib/scripts/types/SerializedTypes.ts | 53 ++++++ src/lib/scripts/types/index.ts | 2 + .../{entities/Validation.ts => validation.ts} | 0 src/lib/stores/course.ts | 4 +- src/lib/stores/graph.ts | 4 +- src/routes/[course]/[link]/+page.server.ts | 2 +- src/routes/api/course/+server.ts | 4 +- src/routes/api/course/[id]/+server.ts | 4 +- src/routes/api/course/[id]/graph/+server.ts | 2 +- src/routes/api/domain/+server.ts | 22 +-- src/routes/api/domain/[id]/+server.ts | 4 +- src/routes/api/graph/+server.ts | 4 +- src/routes/api/graph/[id]/+server.ts | 6 +- src/routes/api/graph/[id]/domain/+server.ts | 4 +- src/routes/api/graph/[id]/lecture/+server.ts | 2 +- src/routes/api/graph/[id]/subject/+server.ts | 2 +- src/routes/api/lecture/+server.ts | 22 +-- src/routes/api/lecture/[id]/+server.ts | 4 +- src/routes/api/subject/+server.ts | 22 +-- src/routes/api/subject/[id]/+server.ts | 4 +- .../graph/[graph]/layout/+page.server.ts | 2 +- .../graph/[graph]/settings/+page.server.ts | 6 +- .../[course]/graph/[graph]/settings/+page.ts | 6 +- .../[graph]/settings/DomainSettings.svelte | 10 +- .../[graph]/settings/LectureSettings.svelte | 6 +- .../[graph]/settings/SubjectSettings.svelte | 10 +- .../course/[course]/overview/+page.server.ts | 2 +- .../app/course/[course]/overview/+page.svelte | 4 +- src/routes/app/dashboard/+page.server.ts | 2 +- 52 files changed, 457 insertions(+), 443 deletions(-) rename src/lib/scripts/{entities => }/BaseModal.ts (79%) rename src/lib/scripts/{d3/FieldSVG.ts => SVGControllers/FieldSVGController.ts} (71%) rename src/lib/scripts/{d3/GraphSVG.ts => SVGControllers/GraphSVGController.ts} (85%) rename src/lib/scripts/{d3/OverlaySVG.ts => SVGControllers/OverlaySVGController.ts} (88%) rename src/lib/scripts/{d3/RelationSVG.ts => SVGControllers/RelationSVGController.ts} (88%) create mode 100644 src/lib/scripts/SVGControllers/index.ts rename src/lib/scripts/{entities/Course.ts => controllers/CourseController.ts} (79%) rename src/lib/scripts/{entities/Fields.ts => controllers/FieldsController.ts} (87%) rename src/lib/scripts/{entities/Graph.ts => controllers/GraphController.ts} (87%) rename src/lib/scripts/{entities/Lecture.ts => controllers/LectureController.ts} (85%) rename src/lib/scripts/{entities/Relations.ts => controllers/RelationsController.ts} (81%) rename src/lib/scripts/{entities/User.ts => controllers/UserController.ts} (65%) create mode 100644 src/lib/scripts/controllers/index.ts delete mode 100644 src/lib/scripts/d3/index.ts delete mode 100644 src/lib/scripts/entities/index.ts rename src/lib/{server => scripts}/helpers/CourseHelper.ts (93%) rename src/lib/{server => scripts}/helpers/DomainHelper.ts (96%) rename src/lib/{server => scripts}/helpers/GraphHelper.ts (93%) rename src/lib/{server => scripts}/helpers/LectureHelper.ts (95%) rename src/lib/{server => scripts}/helpers/SubjectHelper.ts (96%) rename src/lib/{server => scripts}/helpers/index.ts (100%) create mode 100644 src/lib/scripts/types/SerializedTypes.ts create mode 100644 src/lib/scripts/types/index.ts rename src/lib/scripts/{entities/Validation.ts => validation.ts} (100%) diff --git a/src/lib/components/Dropdown.svelte b/src/lib/components/Dropdown.svelte index 72152c0..7605955 100644 --- a/src/lib/components/Dropdown.svelte +++ b/src/lib/components/Dropdown.svelte @@ -5,7 +5,7 @@ import { createEventDispatcher } from "svelte" // Internal imports - import { ValidationData, Severity } from '$scripts/entities' + import { ValidationData, Severity } from '$scripts/validation' import { clickoutside } from '$scripts/clickoutside' import { scrollintoview } from '$scripts/scrollintoview' import { focusfirst, focusonhover, losefocus } from '$scripts/hocusfocus' diff --git a/src/lib/components/Validation.svelte b/src/lib/components/Validation.svelte index 3bf35d6..e5c3d48 100644 --- a/src/lib/components/Validation.svelte +++ b/src/lib/components/Validation.svelte @@ -2,7 +2,7 @@ @@ -108,116 +72,128 @@ +{#await load() then} - - - - - - -
- - Course settings - - -

Create Graph

- Add a new graph to this course. Graphs are visual representations of the course content. They are intended to help students understand the course structure. - -
graph.hide()}> - - - -
- - -
- -
- - -

Create Link

- Add a new link to this course. This will link to a graph in this course, and can be provided to students, or embedded into course material. - -
link.hide()}> - - - - - - -
- - -
- -
- - - -

Graphs

- - - {#if graphs.length === 0} -

There's nothing here.

- {/if} - - {#each graphs as graph} - - {#if graph.hasLinks()} - Link icon - {/if} - {graph.name} - -
- - - - - - - - - - {/each} - - - - -

Links

- - - {#if true} -

There's nothing here.

- {/if} + + + + + + +
+ + Course settings + + +

Create Graph

+ Add a new graph to this course. Graphs are visual representations of the course content. They are intended to help students understand the course structure. + +
+ + + +
+ + +
+ +
+ + - - + + +

Graphs

+ + + {#if $course.graphs.length === 0} +

There's nothing here.

+ {/if} + + {#each $course.graphs as graph} + + {#if graph.hasLinks()} + Link icon + {/if} + {graph.name} + +
+ + + + + + + + + + {/each} + + + + +

Links

+ + + {#if true} +

There's nothing here.

+ {/if} +
+
+ + +{/await} diff --git a/src/routes/app/course/[course]/overview/+page.ts b/src/routes/app/course/[course]/overview/+page.ts new file mode 100644 index 0000000..745bc5d --- /dev/null +++ b/src/routes/app/course/[course]/overview/+page.ts @@ -0,0 +1,9 @@ + +// Internal imports +import { CourseController } from '$scripts/controllers' + +// Load +export async function load({ data }) { + const course = await CourseController.revive(data.course) + return { course } +} \ No newline at end of file diff --git a/src/routes/app/dashboard/+page.server.ts b/src/routes/app/dashboard/+page.server.ts index 367d3f1..d2b905d 100644 --- a/src/routes/app/dashboard/+page.server.ts +++ b/src/routes/app/dashboard/+page.server.ts @@ -1,10 +1,9 @@ +// Internal imports import { ProgramHelper } from '$scripts/helpers' +// Load export async function load() { const programs = await ProgramHelper.getAll() - - return { - programs - } + return { programs } } diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte index b2840c0..2cb3e39 100644 --- a/src/routes/app/dashboard/+page.svelte +++ b/src/routes/app/dashboard/+page.svelte @@ -271,9 +271,9 @@ {:else}
- {#each courses as { code, name }} + {#each courses as { id, code, name }} {#if courseMatchesQuery(query, { code, name })} - {code} {name} + {code} {name} {/if} {/each}
@@ -282,7 +282,7 @@ - {#each programs as { name, courses }} + {#each programs as { id, name, courses }}

{name}

@@ -296,7 +296,7 @@ scale /> - Program settings + Program settings

Program Coordinators

@@ -318,9 +318,9 @@ {:else}
- {#each courses as { code, name }} + {#each courses as { id, code, name }} {#if courseMatchesQuery(query, { code, name })} - {code} {name} + {code} {name} {/if} {/each}
diff --git a/src/routes/app/dashboard/+page.ts b/src/routes/app/dashboard/+page.ts index 4334f7d..b6c1a00 100644 --- a/src/routes/app/dashboard/+page.ts +++ b/src/routes/app/dashboard/+page.ts @@ -3,8 +3,7 @@ import { ProgramController } from '$scripts/controllers' // Load -export const load = async ({ data }) => { - return { - programs: await Promise.all(data.programs.map(program => ProgramController.revive(program))) - } +export async function load({ data }) { + const programs = await Promise.all(data.programs.map(program => ProgramController.revive(program))) + return { programs } } \ No newline at end of file From 3b3410064bf0b30012c6bf8aa918f2fc4daab82e Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Wed, 9 Oct 2024 22:31:09 +0200 Subject: [PATCH 26/45] wip reimagining controllers --- src/lib/components/Validation.svelte | 12 +- .../SVGControllers/FieldSVGController.ts | 2 +- .../SVGControllers/GraphSVGController.ts | 2 +- .../controllers/ControllerEnvironment.ts | 183 ++++ .../scripts/controllers/CourseController.ts | 599 +++++++++-- .../scripts/controllers/FieldsController.ts | 968 +++++++++++++----- .../scripts/controllers/GraphController.ts | 618 ++++++----- .../scripts/controllers/LectureController.ts | 419 ++++---- .../scripts/controllers/ProgramController.ts | 467 ++++++++- .../controllers/RelationsController.ts | 8 +- src/lib/scripts/controllers/UserController.ts | 369 ++++++- src/lib/scripts/controllers/index.ts | 5 +- src/lib/scripts/helpers/CourseHelper.ts | 193 ++-- src/lib/scripts/helpers/GraphHelper.ts | 213 +++- src/lib/scripts/helpers/ProgramHelper.ts | 184 +++- src/lib/scripts/types/Dropdown.ts | 16 + src/lib/scripts/types/Serialized.ts | 112 ++ src/lib/scripts/types/SerializedTypes.ts | 58 -- src/lib/scripts/types/index.ts | 5 +- src/lib/scripts/validation.ts | 10 +- src/routes/api/course/+server.ts | 46 +- src/routes/api/course/[id]/+server.ts | 41 +- src/routes/api/course/[id]/graph/+server.ts | 43 - src/routes/api/domain/+server.ts | 46 +- src/routes/api/domain/[id]/+server.ts | 16 - src/routes/api/graph/+server.ts | 70 ++ src/routes/api/graph/[id]/+server.ts | 18 - src/routes/api/graph/[id]/domain/+server.ts | 36 - src/routes/api/graph/[id]/lecture/+server.ts | 36 - src/routes/api/graph/[id]/subject/+server.ts | 37 - src/routes/api/program/+server.ts | 49 +- src/routes/api/program/[id]/+server.ts | 51 + src/routes/api/program/[id]/course/+server.ts | 42 - .../api/program/[id]/courses/+server.ts | 27 + src/routes/api/subject/+server.ts | 48 +- src/routes/api/subject/[id]/+server.ts | 16 - .../[graph]/settings/DomainSettings.svelte | 2 +- .../[graph]/settings/SubjectSettings.svelte | 2 +- .../course/[course]/overview/+page.server.ts | 17 +- .../app/course/[course]/overview/+page.svelte | 13 +- .../app/course/[course]/overview/+page.ts | 9 - src/routes/app/dashboard/+page.server.ts | 9 - src/routes/app/dashboard/+page.svelte | 143 ++- src/routes/app/dashboard/+page.ts | 9 - 44 files changed, 3714 insertions(+), 1555 deletions(-) create mode 100644 src/lib/scripts/controllers/ControllerEnvironment.ts create mode 100644 src/lib/scripts/types/Dropdown.ts create mode 100644 src/lib/scripts/types/Serialized.ts delete mode 100644 src/lib/scripts/types/SerializedTypes.ts delete mode 100644 src/routes/api/course/[id]/graph/+server.ts delete mode 100644 src/routes/api/domain/[id]/+server.ts delete mode 100644 src/routes/api/graph/[id]/+server.ts delete mode 100644 src/routes/api/graph/[id]/domain/+server.ts delete mode 100644 src/routes/api/graph/[id]/lecture/+server.ts delete mode 100644 src/routes/api/graph/[id]/subject/+server.ts create mode 100644 src/routes/api/program/[id]/+server.ts delete mode 100644 src/routes/api/program/[id]/course/+server.ts create mode 100644 src/routes/api/program/[id]/courses/+server.ts delete mode 100644 src/routes/api/subject/[id]/+server.ts delete mode 100644 src/routes/app/course/[course]/overview/+page.ts delete mode 100644 src/routes/app/dashboard/+page.server.ts delete mode 100644 src/routes/app/dashboard/+page.ts diff --git a/src/lib/components/Validation.svelte b/src/lib/components/Validation.svelte index e5c3d48..3cc9dac 100644 --- a/src/lib/components/Validation.svelte +++ b/src/lib/components/Validation.svelte @@ -136,12 +136,12 @@ {error.short} - {#if error.tab !== undefined && error.anchor !== undefined} + {#if error.tab !== undefined && error.uuid !== undefined} () {/if} @@ -176,12 +176,12 @@ {warning.short} - {#if warning.tab !== undefined && warning.anchor !== undefined} + {#if warning.tab !== undefined && warning.uuid !== undefined} () {/if} diff --git a/src/lib/scripts/SVGControllers/FieldSVGController.ts b/src/lib/scripts/SVGControllers/FieldSVGController.ts index 6440bc7..369e14b 100644 --- a/src/lib/scripts/SVGControllers/FieldSVGController.ts +++ b/src/lib/scripts/SVGControllers/FieldSVGController.ts @@ -32,7 +32,7 @@ class FieldSVGController { // Field attributes selection - .attr('id', field => field.anchor) + .attr('id', field => field.uuid) .attr('class', 'field fixed') .attr('transform', field => `translate( ${field.x * settings.GRID_UNIT}, diff --git a/src/lib/scripts/SVGControllers/GraphSVGController.ts b/src/lib/scripts/SVGControllers/GraphSVGController.ts index d38ff59..de4a639 100644 --- a/src/lib/scripts/SVGControllers/GraphSVGController.ts +++ b/src/lib/scripts/SVGControllers/GraphSVGController.ts @@ -605,7 +605,7 @@ class GraphSVGController { // Update Fields content.selectAll>('.field') - .data(fields, field => field.anchor) + .data(fields, field => field.uuid) .join( function(enter) { return enter diff --git a/src/lib/scripts/controllers/ControllerEnvironment.ts b/src/lib/scripts/controllers/ControllerEnvironment.ts new file mode 100644 index 0000000..9a95bd1 --- /dev/null +++ b/src/lib/scripts/controllers/ControllerEnvironment.ts @@ -0,0 +1,183 @@ + +// Internal dependencies +import { + UserController, + ProgramController, + CourseController, + GraphController, + DomainController, + SubjectController, + LectureController +} from '$scripts/controllers' + +import { + instanceOfSerializedUser, + instanceOfSerializedProgram, + instanceOfSerializedCourse, + instanceOfSerializedGraph, + instanceOfSerializedDomain, + instanceOfSerializedSubject, + instanceOfSerializedLecture +} from '$scripts/types' + +import type { + SerializedUser, + SerializedProgram, + SerializedCourse, + SerializedDomain, + SerializedGraph, + SerializedLecture, + SerializedSubject +} from '$scripts/types' + +// Exports +export { ControllerEnvironment } + + +// --------------------> Environment + + +class ControllerEnvironment { + users: UserController[] = [] + programs: ProgramController[] = [] + courses: CourseController[] = [] + graphs: GraphController[] = [] + domains: DomainController[] = [] + subjects: SubjectController[] = [] + lectures: LectureController[] = [] + + /** + * Overloaded get method to get objects from the environment. If it doesn't exist, it will revive it + * @param data The data to get from the environment + * @returns The object from the environment + * @throws If the object type is invalid + */ + + get(data: SerializedUser): UserController + get(data: SerializedProgram): ProgramController + get(data: SerializedCourse): CourseController + get(data: SerializedGraph): GraphController + get(data: SerializedDomain): DomainController + get(data: SerializedSubject): SubjectController + get(data: SerializedLecture): LectureController + get(data: any): any { + if (instanceOfSerializedUser(data)) { + let found = this.users.find(user => user.id === data.id) + return found ? found : UserController.revive(this, data) + } else if (instanceOfSerializedProgram(data)) { + let found = this.programs.find(program => program.id === data.id) + return found ? found : ProgramController.revive(this, data) + } else if (instanceOfSerializedCourse(data)) { + let found = this.courses.find(course => course.id === data.id) + return found ? found : CourseController.revive(this, data) + } else if (instanceOfSerializedGraph(data)) { + let found = this.graphs.find(graph => graph.id === data.id) + return found ? found : GraphController.revive(this, data) + } else if (instanceOfSerializedDomain(data)) { + let found = this.domains.find(domain => domain.id === data.id) + return found ? found : DomainController.revive(this, data) + } else if (instanceOfSerializedSubject(data)) { + let found = this.subjects.find(subject => subject.id === data.id) + return found ? found : SubjectController.revive(this, data) + } else if (instanceOfSerializedLecture(data)) { + let found = this.lectures.find(lecture => lecture.id === data.id) + return found ? found : LectureController.revive(this, data) + } else { + throw new Error(`EnvironmentError: Invalid object type: ${data}`) + } + } + + /** + * Overloaded add method to add objects to the environment + * @param object The object to add to the environment + * @throws If the object already exists in the environment or if the object type is invalid + */ + + add(object: UserController): void + add(object: ProgramController): void + add(object: CourseController): void + add(object: GraphController): void + add(object: DomainController): void + add(object: SubjectController): void + add(object: LectureController): void + add(object: any): void { + if (object instanceof UserController) { + if (this.users.some(user => user.id === object.id)) + throw new Error(`EnvironmentError: User with ID ${object.id} already exists`) + this.users.push(object) + } else if (object instanceof ProgramController) { + if (this.programs.some(program => program.id === object.id)) + throw new Error(`EnvironmentError: Program with ID ${object.id} already exists`) + this.programs.push(object) + } else if (object instanceof CourseController) { + if (this.courses.some(course => course.id === object.id)) + throw new Error(`EnvironmentError: Course with ID ${object.id} already exists`) + this.courses.push(object) + } else if (object instanceof GraphController) { + if (this.graphs.some(graph => graph.id === object.id)) + throw new Error(`EnvironmentError: Graph with ID ${object.id} already exists`) + this.graphs.push(object) + } else if (object instanceof DomainController) { + if (this.domains.some(domain => domain.id === object.id)) + throw new Error(`EnvironmentError: Domain with ID ${object.id} already exists`) + this.domains.push(object) + } else if (object instanceof SubjectController) { + if (this.subjects.some(subject => subject.id === object.id)) + throw new Error(`EnvironmentError: Subject with ID ${object.id} already exists`) + this.subjects.push(object) + } else if (object instanceof LectureController) { + if (this.lectures.some(lecture => lecture.id === object.id)) + throw new Error(`EnvironmentError: Lecture with ID ${object.id} already exists`) + this.lectures.push(object) + } else { + throw new Error(`EnvironmentError: Invalid object type: ${object}`) + } + } + + /** + * Overloaded remove method to remove objects from the environment + * @param object The object to remove from the environment + * @throws If the object does not exist in the environment or if the object type is invalid + */ + + remove(object: UserController): void + remove(object: ProgramController): void + remove(object: CourseController): void + remove(object: GraphController): void + remove(object: DomainController): void + remove(object: SubjectController): void + remove(object: LectureController): void + remove(object: any): void { + if (object instanceof UserController) { + if (!this.users.some(user => user.id === object.id)) + throw new Error(`EnvironmentError: User with ID ${object.id} does not exist`) + this.users = this.users.filter(user => user.id !== object.id) + } else if (object instanceof ProgramController) { + if (!this.programs.some(program => program.id === object.id)) + throw new Error(`EnvironmentError: Program with ID ${object.id} does not exist`) + this.programs = this.programs.filter(program => program.id !== object.id) + } else if (object instanceof CourseController) { + if (!this.courses.some(course => course.id === object.id)) + throw new Error(`EnvironmentError: Course with ID ${object.id} does not exist`) + this.courses = this.courses.filter(course => course.id !== object.id) + } else if (object instanceof GraphController) { + if (!this.graphs.some(graph => graph.id === object.id)) + throw new Error(`EnvironmentError: Graph with ID ${object.id} does not exist`) + this.graphs = this.graphs.filter(graph => graph.id !== object.id) + } else if (object instanceof DomainController) { + if (!this.domains.some(domain => domain.id === object.id)) + throw new Error(`EnvironmentError: Domain with ID ${object.id} does not exist`) + this.domains = this.domains.filter(domain => domain.id !== object.id) + } else if (object instanceof SubjectController) { + if (!this.subjects.some(subject => subject.id === object.id)) + throw new Error(`EnvironmentError: Subject with ID ${object.id} does not exist`) + this.subjects = this.subjects.filter(subject => subject.id !== object.id) + } else if (object instanceof LectureController) { + if (!this.lectures.some(lecture => lecture.id === object.id)) + throw new Error(`EnvironmentError: Lecture with ID ${object.id} does not exist`) + this.lectures = this.lectures.filter(lecture => lecture.id !== object.id) + } else { + throw new Error(`EnvironmentError: Invalid object type: ${object}`) + } + } +} \ No newline at end of file diff --git a/src/lib/scripts/controllers/CourseController.ts b/src/lib/scripts/controllers/CourseController.ts index ee488b3..a23d993 100644 --- a/src/lib/scripts/controllers/CourseController.ts +++ b/src/lib/scripts/controllers/CourseController.ts @@ -1,63 +1,244 @@ -// Internal imports -import { GraphController, ProgramController } from '$scripts/controllers' +// Internal dependencies +import { + ControllerEnvironment, + UserController, + ProgramController, + GraphController +} from '$scripts/controllers' + import { ValidationData, Severity } from '$scripts/validation' -import type { SerializedCourse, SerializedGraph } from '$scripts/types' + +import type { SerializedCourse, SerializedGraph, SerializedProgram, SerializedUser } from '$scripts/types' // Exports export { CourseController } -// --------------------> Classes +// --------------------> Controller class CourseController { - constructor( + private _graphs?: GraphController[] + private _admins?: UserController[] + private _editors?: UserController[] + private _programs?: ProgramController[] + + private constructor( + public environment: ControllerEnvironment, public id: number, public code: string, public name: string, - private _graphs: GraphController[] = [], - private _compact: boolean = true - // public links: Link[] = [], - // public contributors: User[] = [], - // public programs: ProgramController[]= [], - ) { } + public archived: boolean, + private _graph_ids: number[], + private _editor_ids: number[], + private _admin_ids: number[], + private _program_ids: number[] + ) { + this.environment.add(this) + } + + get graphs(): Promise { + + // Check if graphs are already loaded + if (this._graphs) { + return Promise.resolve(this._graphs) + } + + // Call API to get the courses + return fetch(`/api/course/${this.id}/graphs`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/course/${this.id}/graphs GET): ${error}`) } + ) + + // Parse the data + .then(data => { - get compact() { - return this._compact + // Revive courses + this._graphs = data.map(graph => { + const existing = this.environment.graphs.find(existing => existing.id === graph.id) + return existing ? existing : GraphController.revive(this.environment, graph) + }) + + // Check if graphs are in sync + const client = JSON.stringify(this._graph_ids.concat().sort()) + const server = JSON.stringify(this._graphs.map(graph => graph.id).sort()) + if (client !== server) { + throw new Error('CourseError: Graphs are not in sync') + } + + return this._graphs + }) } - private set compact(value: boolean) { - this._compact = value + get admins(): Promise { + + // Check if admins are already loaded + if (this._admins) { + return Promise.resolve(this._admins) + } + + // Call API to get the courses + return fetch(`/api/course/${this.id}/admins`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/course/${this.id}/admins GET): ${error}`) } + ) + + // Parse the data + .then(data => { + + // Revive courses + this._admins = data.map(admin => { + const existing = this.environment.users.find(existing => existing.id === admin.id) + return existing ? existing : UserController.revive(this.environment, admin) + }) + + // Check if admins are in sync + const client = JSON.stringify(this._admin_ids.concat().sort()) + const server = JSON.stringify(this._admins.map(admin => admin.id).sort()) + if (client !== server) { + throw new Error('CourseError: Admins are not in sync') + } + + return this._admins + }) } - get expanded() { - return !this._compact + get editors(): Promise { + + // Check if editors are already loaded + if (this._editors) { + return Promise.resolve(this._editors) + } + + // Call API to get the courses + return fetch(`/api/course/${this.id}/editors`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/course/${this.id}/editors GET): ${error}`) } + ) + + // Parse the data + .then(data => { + + // Revive courses + this._editors = data.map(editor => { + const existing = this.environment.users.find(existing => existing.id === editor.id) + return existing ? existing : UserController.revive(this.environment, editor) + }) + + // Check if editors are in sync + const client = JSON.stringify(this._editor_ids.concat().sort()) + const server = JSON.stringify(this._editors.map(editor => editor.id).sort()) + if (client !== server) { + throw new Error('CourseError: Editors are not in sync') + } + + return this._editors + }) } - private set expanded(value: boolean) { - this._compact = !value + get programs(): Promise { + + // Check if programs are already loaded + if (this._programs) { + return Promise.resolve(this._programs) + } + + // Call API to get the courses + return fetch(`/api/course/${this.id}/programs`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/course/${this.id}/programs GET): ${error}`) } + ) + + // Parse the data + .then(data => { + + // Revive courses + this._programs = data.map(program => { + const existing = this.environment.programs.find(existing => existing.id === program.id) + return existing ? existing : ProgramController.revive(this.environment, program) + }) + + // Check if programs are in sync + const client = JSON.stringify(this._program_ids.concat().sort()) + const server = JSON.stringify(this._programs.map(program => program.id).sort()) + if (client !== server) { + throw new Error('CourseError: Programs are not in sync') + } + + return this._programs + }) } - get graphs() { + /** + * Get a course + * @param environment Environment to get the course from + * @param id ID of the course to get + * @returns `Promise` The requested CourseController + * @throws `APIError` if the API call fails + */ + + static async get(environment: ControllerEnvironment, id: number): Promise { - // Check if the graphs are expanded - if (this.compact) throw new Error('Failed to get graphs: Course is too compact') - return this._graphs + // Check if the course is already loaded + const existing = environment.courses.find(existing => existing.id === data.id) + if (existing) return existing + + // Call API to get the course + const response = await fetch(`/api/course/${id}`, { method: 'GET' }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/course/${id} GET): ${error}`) + }) + + // Revive the course + const data = await response.json() as SerializedCourse + return CourseController.revive(environment, data) } - set graphs(value: GraphController[]) { - this._graphs = value + /** + * Get all courses + * @param environment Environment to get the courses from + * @returns `Promise` All CourseControllers + * @throws `APIError` if the API call fails + */ + + static async getAll(environment: ControllerEnvironment): Promise { + + // Call API to get the courses + const response = await fetch(`/api/course`, { method: 'GET' }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/course GET): ${error}`) + }) + + // Parse the response + const data = await response.json() as SerializedCourse[] + return data.map(course => { + const existing = environment.courses.find(existing => existing.id === course.id) + return existing ? existing : CourseController.revive(environment, course) + }) } - static async create(code: string, name: string, program?: ProgramController, depth: number = 0): Promise { - /* Create a new course */ + /** + * Create a new course + * @param environment Environment to create the course in + * @param code Course code + * @param name Course name + * @returns `Promise` The newly created CourseController + * @throws `APIError` if the API call fails + */ - // TODO for now we assume that the program is always provided - if (!program) throw new Error('Failed to create course: Program is required') + static async create(environment: ControllerEnvironment, code: string, name: string): Promise { - // Call the API - const response = await fetch(`/api/program/${program.id}/course`, { + // Call API to create a new course + const response = await fetch(`/api/course`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name }) @@ -65,113 +246,335 @@ class CourseController { // Check the response .catch(error => { - throw new Error(`Failed to create course: ${error}`) + throw new Error(`APIError (/api/course POST): ${error}`) }) // Revive the course - const data: SerializedCourse = await response.json() - const course = await CourseController.revive(data, depth) - program.courses.push(course) - - return course + const data = await response.json() + return CourseController.revive(environment, data) } - static async revive(data: SerializedCourse, depth: number = 0): Promise { - /* Load the course from a POGO */ + /** + * Revive a course from serialized data + * @param environment Environment to revive the course in + * @param data Serialized data to revive + * @returns `CourseController` The revived Course + */ - const course = new CourseController(data.id, data.code, data.name) - await course.expand(depth) - return course + static revive(environment: ControllerEnvironment, data: SerializedCourse): CourseController { + return new CourseController(environment, data.id, data.code, data.name, data.archived, data.graphs, data.editors, data.admins, data.programs) } - async expand(depth: number = 1): Promise { - /* Expand the program */ + /** + * Validate the course + * @returns `Promise` Validation data + */ - // Check if expansion depth is reached - if (depth < 1) return this + async validate(): Promise { + const validation = new ValidationData() - // Check if the program is already expanded - if (this.expanded) { - await Promise.all(this.graphs.map(graph => graph.expand(depth - 1))) + if (!this.hasName()) { + validation.add({ + severity: Severity.error, + short: 'Course has no name', + long: 'Please provide a name for the course' + }) } - else { - - this.expanded = true + if (!this.hasCode()) { + validation.add({ + severity: Severity.error, + short: 'Course has no code', + long: 'Please provide a code for the course' + }) + } - // Call the API - const response = await fetch(`/api/course/${this.id}/graph`, { method: 'GET' }) - .catch(error => { throw new Error(`Failed to load course: ${error}`) }) - const data: SerializedGraph[] = await response.json() + else if (await this.hasDuplicateCode()) { + validation.add({ + severity: Severity.error, + short: 'Course code is already in use', + long: 'Please provide a unique code for the course' + }) + } - // Revive the courses - this.graphs = await Promise.all(data.map(graph => GraphController.revive(graph, depth - 1))) + if (!this.hasAdmins()) { + validation.add({ + severity: Severity.warning, + short: 'Course has no admins', + long: 'Please assign at least one admin to the course' + }) } - return this + return validation + } + + /** + * Serialize the course + * @returns `SerializedCourse` Serialized course + */ + + reduce(): SerializedCourse { + return { + id: this.id, + code: this.code, + name: this.name, + archived: this.archived, + graphs: this._graph_ids, + admins: this._admin_ids, + editors: this._editor_ids, + programs: this._program_ids + } } - async save() { - /* Save the course to the database */ + /** + * Save the course + * @throws `APIError` if the API call fails + */ + + async save(): Promise { - // Call the API + // Call API to save the course await fetch(`/api/course`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.reduce()) }) // Check the response - .catch(error => { - throw new Error(`Failed to save course: ${error}`) + .catch(error => { + throw new Error(`APIError (/api/course PUT): ${error}`) }) } + /** + * Delete the course, and all related graphs + * @throws `APIError` if the API call fails + */ + async delete(): Promise { - /* Delete the graph from the database */ + + // Call API to delete the course + await fetch(`/api/course`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: this.id }) + }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/course DELETE): ${error}`) + }) + + // Delete all related graphs + const graphs = await this.graphs + await Promise.all(graphs.map(graph => graph.delete())) + + // Unassign everywhere (mirroring is not necessary, as this object will be deleted) + this.environment.users + .filter(user => this._admin_ids.includes(user.id)) + .forEach(user => user.resignAsCourseAdmin(this, false)) + + this.environment.users + .filter(user => this._editor_ids.includes(user.id)) + .forEach(user => user.resignAsCourseEditor(this, false)) + + this.environment.programs + .filter(program => this._program_ids.includes(program.id)) + .forEach(program => program.unassignCourse(this, false)) - // Call the API - await fetch(`/api/course/${this.id}`, { method: 'DELETE' }) - .catch(error => { throw new Error(`Failed to delete course: ${error}`) }) + // Remove from environment + this.environment.remove } - validate(): ValidationData { - /* Validate the course */ + /** + * Check if the course has a name + * @returns `boolean` Whether the course has a name + */ - const result = new ValidationData() + private hasName(): boolean { + return this.name.trim() !== '' + } - // Check if the course code is valid - if (this.code === '') { - result.add({ - severity: Severity.error, - short: 'Course has no code', - tab: 0, - anchor: 'code' - }) + /** + * Check if the course has a code + * @returns `boolean` Whether the course has a code + */ + + private hasCode(): boolean { + return this.code.trim() !== '' + } + + /** + * Check if the course has a duplicate code + * @returns `Promise` Whether the course has a duplicate code + */ + + private async hasDuplicateCode(): Promise { + const courses = await CourseController.getAll(this.environment) + return courses.some(course => course.id !== this.id && course.code === this.code) + } + + /** + * Check if the course has admins + * @returns `boolean` Whether the course has admins + */ + + private hasAdmins(): boolean { + return this._admin_ids.length > 0 + } + + /** + * Get the index of a graph in the course + * @param graph Graph to get the index of + * @returns `number` Index of the graph + * @throws `CourseError` if the graph is not assigned to the course + */ + + graphIndex(graph: GraphController): number { + const index = this._graph_ids.indexOf(graph.id) + if (index === -1) throw new Error(`CourseError: Graph with ID ${graph.id} is not assigned to Course`) + return index + } + + /** + * Assign the course to a program + * @param program Program to assign the course to + * @param mirror Whether to mirror the assignment + * @throws `CourseError` if the course is already assigned to the program + */ + + assignToProgram(program: ProgramController, mirror: boolean = true): void { + if (this._program_ids.includes(program.id)) + throw new Error(`CourseError: Course is already assigned to Program with ID ${program.id}`) + this._program_ids.push(program.id) + this._programs?.push(program) + + if (mirror) { + program.assignCourse(this, false) } + } - // Check if the course name is valid - if (this.name === '') { - result.add({ - severity: Severity.error, - short: 'Course has no name', - tab: 0, - anchor: 'name' - }) + /** + * Assign a graph to the course + * @param graph Graph to assign to the course + * @throws `CourseError` if the graph is already assigned to the course + */ + + assignGraph(graph: GraphController): void { + if (this._graph_ids.includes(graph.id)) + throw new Error(`CourseError: Course is already assigned to Graph with ID ${graph.id}`) + this._graph_ids.push(graph.id) + this._graphs?.push(graph) + } + + /** + * Assign a user as an admin of the course. Unassigns the user as an editor if they are one + * @param user User to assign as an admin + * @param mirror Whether to mirror the assignment + * @throws `CourseError` if the user is already an admin of the course + */ + + assignAdmin(user: UserController, mirror: boolean = true): void { + if (this._admin_ids.includes(user.id)) + throw new Error(`CourseError: User with ID ${user.id} is already an admin of Course with ID ${this.id}`) + this._admin_ids.push(user.id) + this._admins?.push(user) + + if (this._editor_ids.includes(user.id)) { + this.unassignEditor(user) } - return result + if (mirror) { + user.becomeCourseAdmin(this, false) + } } - reduce(): SerializedCourse { - /* Reduce the course to a POJO */ + /** + * Assign a user as an editor of the course. Unassigns the user as an admin if they are one + * @param user User to assign as an editor + * @param mirror Whether to mirror the assignment + * @throws `CourseError` if the user is already an editor of the course + */ + + assignEditor(user: UserController, mirror: boolean = true): void { + if (this._editor_ids.includes(user.id)) + throw new Error(`CourseError: User with ID ${user.id} is already an editor of Course with ID ${this.id}`) + this._editor_ids.push(user.id) + this._editors?.push(user) + + if (this._admin_ids.includes(user.id)) { + this.unassignAdmin(user) + } - return { - id: this.id, - code: this.code, - name: this.name + if (mirror) { + user.becomeCourseEditor(this, false) + } + } + + /** + * Unassign the course from a program + * @param program Program to unassign the course from + * @param mirror Whether to mirror the unassignment + * @throws `CourseError` if the course is not assigned to the program + */ + + unassignFromProgram(program: ProgramController, mirror: boolean = true): void { + if (!this._program_ids.includes(program.id)) + throw new Error(`CourseError: Course is not assigned to Program with ID ${program.id}`) + this._program_ids = this._program_ids.filter(id => id !== program.id) + this._programs = this._programs?.filter(program => program.id !== program.id) + + if (mirror) { + program.unassignCourse(this, false) + } + } + + /** + * Unassign a graph from the course + * @param graph Graph to unassign from the course + * @throws `CourseError` if the graph is not assigned to the course + */ + + unassignGraph(graph: GraphController): void { + if (!this._graph_ids.includes(graph.id)) + throw new Error(`CourseError: Course is not assigned to Graph with ID ${graph.id}`) + this._graph_ids = this._graph_ids.filter(id => id !== graph.id) + this._graphs = this._graphs?.filter(graph => graph.id !== graph.id) + } + + /** + * Unassign an admin from the course + * @param user User to unassign as an admin + * @param mirror Whether to mirror the unassignment + * @throws `CourseError` if the user is not an admin of the course + */ + + unassignAdmin(user: UserController, mirror: boolean = true): void { + if (!this._admin_ids.includes(user.id)) + throw new Error(`CourseError: User with ID ${user.id} is not an admin of Course with ID ${this.id}`) + this._admin_ids = this._admin_ids.filter(id => id !== user.id) + this._admins = this._admins?.filter(admin => admin.id !== user.id) + + if (mirror) { + user.resignAsCourseAdmin(this, false) + } + } + + /** + * Unassign an editor from the course + * @param user User to unassign as an editor + * @param mirror Whether to mirror the unassignment + * @throws `CourseError` if the user is not an editor of the course + */ + + unassignEditor(user: UserController, mirror: boolean = true): void { + if (!this._editor_ids.includes(user.id)) + throw new Error(`CourseError: User with ID ${user.id} is not an editor of Course with ID ${this.id}`) + this._editor_ids = this._editor_ids.filter(id => id !== user.id) + this._editors = this._editors?.filter(editor => editor.id !== user.id) + + if (mirror) { + user.resignAsCourseEditor(this, false) } } -} +} \ No newline at end of file diff --git a/src/lib/scripts/controllers/FieldsController.ts b/src/lib/scripts/controllers/FieldsController.ts index e3fd7c4..57b6a9b 100644 --- a/src/lib/scripts/controllers/FieldsController.ts +++ b/src/lib/scripts/controllers/FieldsController.ts @@ -1,500 +1,902 @@ -// External imports -import * as uuid from 'uuid' - -// Internal imports -import { GraphController } from '$scripts/controllers' -import { ValidationData, Severity } from '$scripts/validation' +// Internal dependencies +import { + ControllerEnvironment, + GraphController, + LectureController +} from '$scripts/controllers' -import * as settings from '$scripts/settings' import { styles } from '$scripts/settings' +import * as settings from '$scripts/settings' +import { ValidationData, Severity } from '$scripts/validation' import type { SerializedDomain, SerializedSubject } from '$scripts/types' +// External dependencies +import * as uuid from 'uuid' + // Exports export { FieldController, DomainController, SubjectController } -// --------------------> Classes +// --------------------> Controllers abstract class FieldController { - fx?: number // The locked x-coordinate of this field - fy?: number // The locked y-coordinate of this field + protected _graph?: GraphController + protected _parents?: T[] + protected _children?: T[] + + uuid: string + fx?: number + fy?: number constructor( - public graph: GraphController, // The graph this field belongs to - public anchor: string, // The anchor of this field, unique for every DOM element, used for finding errors and d3 selections - public index: number, // The index of this field in the list of its type, based on creation order, consistent after sorting, deleting etc - public id: number, // The ID of this field in the database, unique among its type, NOT among all fields - public x: number, // The current x-coordinate of this field - public y: number, // The current y-coordinate of this field - public name: string, // The name of this field - public parents: T[], // The parents of this field - public children: T[], // The children of this field + public environment: ControllerEnvironment, + public id: number, + public x: number, + public y: number, + public name: string, + protected _graph_id: number, + protected _parent_ids: number[], + protected _child_ids: number[] ) { - /* Create a new field */ + this.uuid = uuid.v4() + } - this.fx = x - this.fy = y + get graph(): Promise { + return (async () => { + if (this._graph) return this._graph + this._graph = await this.environment.getGraph(this._graph_id) as GraphController + return this._graph + })() } - protected hasName(field: DomainController | SubjectController): boolean { - /* Check if the name of a field is undefined */ + /** + * Check if the field has a name + * @returns `boolean` Whether the field has a name + */ - return field.name !== '' + protected hasName(): boolean { + return this.name.trim() !== '' } - protected findOriginal(list: S[], value: S, key: (item: S) => T): number { - /* Find the original item in a list - * Returns -1 if value doesn't exist, or isnt a duplicate - * Returns the index of the first duplicate otherwise - */ + /** + * Check if the field name is too long + * @returns `boolean` Whether the field name is too long + */ - const first = list.findIndex(item => key(item) === key(value)) - const index = list.indexOf(value, first + 1) - return index === -1 ? -1 : first + protected hasLongName(): boolean{ + return this.name.length > settings.FIELD_MAX_CHARS } - abstract get style(): string | undefined - abstract get color(): string - abstract validate(): ValidationData - abstract delete(): Promise - abstract save(): Promise + abstract get parents(): Promise + abstract get children(): Promise + abstract get style(): Promise + abstract get color(): Promise + + abstract assignParent(parent: T, mirror: boolean): void + abstract assignChild(child: T, mirror: boolean): void + abstract unassignParent(parent: T, mirror: boolean): void + abstract unassignChild(child: T, mirror: boolean): void } class DomainController extends FieldController { - private _style?: string + private _subjects?: SubjectController[] constructor( - graph: GraphController, - index: number, + environment: ControllerEnvironment, id: number, - x: number = 0, - y: number = 0, - name: string = '', - style?: string, - parents: DomainController[] = [], - children: DomainController[] = [] + x: number, + y: number, + name: string, + private _style: string | null, + _graph_id: number, + _parent_ids: number[], + _child_ids: number[], + private _subject_ids: number[] ) { - super(graph, uuid.v4(), index, id, x, y, name, parents, children) - this.style = style + super(environment, id, x, y, name, _graph_id, _parent_ids, _child_ids) + this.environment.add(this) } - get subjects(): SubjectController[] { - /* Return the subjects of this domain */ - - const subjects = [] - for (const subject of this.graph.subjects) { - if (subject.domain === this) { - subjects.push(subject) - } - } - - return subjects + get parents(): Promise { + return (async () => { + if (this._parents) return this._parents + this._parents = await this.environment.getDomains(this._parent_ids) + return this._parents + })() } - get color(): string { - /* Return the preview color of this domain */ - - return this.style ? styles[this.style].stroke : 'transparent' + get children(): Promise { + return (async () => { + if (this._children) return this._children + this._children = await this.environment.getDomains(this._child_ids) + return this._children + })() } - - get style(): string | undefined { - /* Return the style of this domain */ - - return this._style + + get subjects(): Promise { + return (async () => { + if (this._subjects) return this._subjects + this._subjects = await this.environment.getSubjects(this._subject_ids) + return this._subjects + })() } - set style(style: string | undefined) { - /* Set the style of this domain */ - - this._style = style + get index(): Promise { + return this.graph.then(graph => graph.domainIndex(this)) } - get style_options() { - /* Return the style options of this domain */ + get style(): Promise { + return Promise.resolve( + this._style + ) + } - const options = [] - for (const [style, value] of Object.entries(styles)) { - const validation = new ValidationData() + get color(): Promise { + return (async () => { + const style = await this.style + return style ? styles[style].fill : 'transparent' + })() + } - // Check if the style is already used - if (this.graph.domains.some(domain => domain.style === style && domain !== this)) { - validation.add({ severity: Severity.warning, short:'Duplicate style' }) - } + /** + * Create a new domain + * @param environment Environment to create the domain in + * @param graph Graph to assign the domain to + * @returns `Promise` The newly created DomainController + * @throws `APIError` if the API call fails + */ - options.push({ - name: value.display_name, - value: style, - validation - }) - } + static async create(environment: ControllerEnvironment, graph: GraphController): Promise { - return options - } + // Call API to create a new domain + const response = await fetch(`/api/domain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ graph: graph.id }) + }) - static async create(graph: GraphController): Promise { - /* Create a new domain */ + // Check the response + .catch(error => { + throw new Error(`APIError (/api/domain POST): ${error}`) + }) - // Call the API - const response = await fetch(`/api/graph/${graph.id}/domain`, { method: 'POST' }) - .catch(error => { throw new Error(`Failed to create domain: ${error}`) }) - - // Parse response + // Revive the domain const data = await response.json() - - // Create domain object - const domain = new DomainController(graph, graph.domains.length, data.id) - graph.domains.push(domain) + const domain = DomainController.revive(environment, data) + graph.assignDomain(domain) return domain } - private hasStyle(): boolean { - /* Check if the style of a domain is undefined */ + /** + * Revive a domain from serialized data + * @param environment Environment to revive the domain in + * @param data Serialized data to revive + * @returns `DomainController` The revived Domain + */ - return this.style !== undefined + static revive(environment: ControllerEnvironment, data: SerializedDomain): DomainController { + return new DomainController(environment, data.id, data.x, data.y, data.name, data.style, data.graph, data.parents, data.children, data.subjects) } - private hasSubjects(): boolean { - /* Check if the domain has subjects */ + /** + * Validate the domain + * @returns `Promise` Validation data + */ - return this.subjects.length > 0 + async validate(): Promise { + const validation = new ValidationData() - } - - validate(): ValidationData { - /* Validate this domain */ - - const result = new ValidationData() - - // Check if the domain has a name - if (!this.hasName(this)) { - result.add({ + if (!this.hasName()) { + validation.add({ severity: Severity.error, short: 'Domain has no name', tab: 1, - anchor: this.anchor + uuid: this.uuid }) } else { - - // Check if the domain has a unique name - const first = this.findOriginal(this.graph.domains, this, domain => domain.name) - if (first !== -1) { - result.add({ - severity: Severity.error, - short: 'Domain has duplicate name', - long: `Name first used by Domain nr. ${first + 1}`, + const original = await this.findOriginalName() + if (original !== -1) { + validation.add({ + severity: Severity.warning, + short: 'Domain name is already in use', + long: `Name first used by Domain nr. ${original + 1}`, tab: 1, - anchor: this.anchor + uuid: this.uuid }) } - // Check if the name is too long - if (this.name.length > settings.FIELD_MAX_CHARS) { - result.add({ + if (this.hasLongName()) { + validation.add({ severity: Severity.error, - short: 'Domain name too long', + short: 'Domain name is too long', long: `Name exceeds ${settings.FIELD_MAX_CHARS} characters`, tab: 1, - anchor: this.anchor + uuid: this.uuid }) } } - // Check if the domain has a style if (!this.hasStyle()) { - result.add({ + validation.add({ severity: Severity.error, short: 'Domain has no style', tab: 1, - anchor: this.anchor + uuid: this.uuid }) } else { - // Check if the domain has a unique style - const first = this.findOriginal(this.graph.domains, this, domain => domain.style) - if (first !== -1) { - result.add({ + const original = await this.findOriginalStyle() + if (original !== -1) { + validation.add({ severity: Severity.warning, - short: 'Domain has duplicate style', - long: `Style first used by Domain nr. ${first + 1}`, + short: 'Domain style is already in use', + long: `Style first used by Domain nr. ${original + 1}`, tab: 1, - anchor: this.anchor + uuid: this.uuid }) } } - // Check if the domain has subjects if (!this.hasSubjects()) { - result.add({ + validation.add({ severity: Severity.warning, short: 'Domain has no subjects', tab: 1, - anchor: this.anchor + uuid: this.uuid }) } - return result + return validation } - reduce(): SerializedDomain { - /* Serialize domain to a POJO */ + /** + * Serialize the domain + * @returns `Promise` Serialized domain + */ + + async reduce(): Promise { return { id: this.id, x: this.x, y: this.y, - style: this.style!, name: this.name, - parents: this.parents.map(parent => parent.id), - children: this.children.map(child => child.id) + style: await this.style, + graph: this._graph_id, + parents: this._parent_ids, + children: this._child_ids, + subjects: this._subject_ids } } - async save(): Promise { - /* Save this domain */ + /** + * Save the domain + * @throws `APIError` if the API call fails + */ - // Serialize - const data = this.reduce() + async save(): Promise { - // Call the API + // Call API to save the domain await fetch(`/api/domain`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + body: JSON.stringify(this.reduce()) }) // Check the response - .catch(error => { - throw new Error(`Failed to save course: ${error}`) + .catch(error => { + throw new Error(`APIError (/api/domain PUT): ${error}`) }) } + /** + * Delete the domain + * @throws `APIError` if the API call fails + */ + async delete(): Promise { - /* Delete this domain */ + + // Call API to delete the domain + await fetch(`/api/domain`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: this.id }) + }) - // Shift indexes - for (const domain of this.graph.domains) { - if (domain.index > this.index) { - domain.index-- - } + // Check the response + .catch(error => { + throw new Error(`APIError (/api/domain DELETE): ${error}`) + }) + + // Unassign everywhere (mirroring is not necessary, as this object will be deleted) + const graph = await this.environment.getGraph(this._graph_id, false) + graph?.unassignDomain(this) + + const parents = await this.environment.getDomains(this._parent_ids, false) + parents.forEach(parent => parent.unassignChild(this, false)) + + const children = await this.environment.getDomains(this._child_ids, false) + children.forEach(child => child.unassignParent(this, false)) + + const subjects = await this.environment.getSubjects(this._subject_ids, false) + subjects.forEach(subject => subject.unassignFromDomain(this, false)) + + // Remove from environment + this.environment.remove(this) + } + + /** + * Find the first occurrence of the domain's name in the graph + * @returns `Promise` Index of the original name in the graph, or -1 if the name is unique/nonexistant + */ + + private async findOriginalName(): Promise { + const domains = await this.graph.then(graph => graph.domains) + const first = domains.findIndex(item => item.name === this.name) + const second = domains.indexOf(this, first + 1) + + return first < second ? domains[first].index : -1 + } + + /** + * Find the first occurrence of the domain's style in the graph + * @returns `Promise` Index of the original style in the graph, or -1 if the style is unique/nonexistant + */ + + private async findOriginalStyle(): Promise { + const domains = await this.graph.then(graph => graph.domains) + const first = domains.findIndex(async item => await item.style === await this.style) + const second = domains.indexOf(this, first + 1) + + return first < second ? domains[first].index : -1 + } + + /** + * Check if the domain has a style + * @returns `boolean` Whether the domain has a style + */ + + private hasStyle(): boolean { + return this.style !== null + } + + /** + * Check if the domain has subjects + * @returns `boolean` Whether the domain has parents + */ + + private hasSubjects(): boolean { + return this._subject_ids.length > 0 + } + + /** + * Assign a parent domain to the domain + * @param parent Domain to assign as a parent + * @param mirror Whether to mirror the assignment + * @throws `DomainError` if the domain is already assigned as a parent + */ + + assignParent(parent: DomainController, mirror: boolean = true): void { + if (this._parent_ids.includes(parent.id)) + throw new Error(`DomainError: Domain is already assigned as a parent of Domain with ID ${parent.id}`) + this._parent_ids.push(parent.id) + this._parents?.push(parent) + + if (mirror) { + parent.assignChild(this, false) } + } - // Delete relations - for (const relation of this.graph.domain_relations) { - if (relation.parent === this || relation.child === this) { - relation.delete() - } + /** + * Assign a child domain to the domain + * @param child Domain to assign as a child + * @param mirror Whether to mirror the assignment + * @throws `DomainError` if the domain is already assigned as a child + */ + + assignChild(child: DomainController, mirror: boolean = true): void { + if (this._child_ids.includes(child.id)) + throw new Error(`DomainError: Domain is already assigned as a child of Domain with ID ${child.id}`) + this._child_ids.push(child.id) + this._children?.push(child) + + if (mirror) { + child.assignParent(this, false) } + } - // Unset subjects with this domain - for (const subject of this.graph.subjects) { - if (subject.domain === this) { - subject.domain = undefined - } + /** + * Assign a subject to the domain + * @param subject Subject to assign to the domain + * @param mirror Whether to mirror the assignment + * @throws `DomainError` if the subject is already assigned to the domain + */ + + assignSubject(subject: SubjectController, mirror: boolean = true): void { + if (this._subject_ids.includes(subject.id)) + throw new Error(`DomainError: Domain is already assigned to Subject with ID ${subject.id}`) + this._subject_ids.push(subject.id) + this._subjects?.push(subject) + + if (mirror) { + subject.assignToDomain(this, false) + } + } + + /** + * Unassign a parent domain from the domain + * @param parent Domain to unassign as a parent + * @param mirror Whether to mirror the unassignment + * @throws `DomainError` if the domain is not assigned as a parent + */ + + unassignParent(parent: DomainController, mirror: boolean = true): void { + if (!this._parent_ids.includes(parent.id)) + throw new Error(`DomainError: Domain is not assigned as a parent of Domain with ID ${parent.id}`) + this._parent_ids = this._parent_ids.filter(id => id !== parent.id) + this._parents = this._parents?.filter(parent => parent.id !== parent.id) + + if (mirror) { + parent.unassignChild(this, false) } + } - // Call the API - await fetch(`/api/domain/${this.id}`, { method: 'DELETE' }) - .catch(error => { throw new Error(`Failed to delete domain: ${error}`) }) + /** + * Unassign a child domain from the domain + * @param child Domain to unassign as a child + * @param mirror Whether to mirror the unassignment + * @throws `DomainError` if the domain is not assigned as a child + */ + + unassignChild(child: DomainController, mirror: boolean = true): void { + if (!this._child_ids.includes(child.id)) + throw new Error(`DomainError: Domain is not assigned as a child of Domain with ID ${child.id}`) + this._child_ids = this._child_ids.filter(id => id !== child.id) + this._children = this._children?.filter(child => child.id !== child.id) + + if (mirror) { + child.unassignParent(this, false) + } + } - // Remove this domain from the graph - this.graph.domains = this.graph.domains.filter(domain => domain !== this) + /** + * Unassign a subject from the domain + * @param subject Subject to unassign from the domain + * @param mirror Whether to mirror the unassignment + * @throws `DomainError` if the subject is not assigned to the domain + */ + + unassignSubject(subject: SubjectController, mirror: boolean = true): void { + if (!this._subject_ids.includes(subject.id)) + throw new Error(`DomainError: Domain is not assigned to Subject with ID ${subject.id}`) + this._subject_ids = this._subject_ids.filter(id => id !== subject.id) + this._subjects = this._subjects?.filter(subject => subject.id !== subject.id) + + if (mirror) { + subject.unassignFromDomain(this, false) + } } } class SubjectController extends FieldController { - domain?: DomainController + private _domain?: DomainController | null + private _lectures?: LectureController[] constructor( - graph: GraphController, - index: number, + environment: ControllerEnvironment, id: number, - x: number = 0, - y: number = 0, - name: string = '', - domain?: DomainController, - parents: SubjectController[] = [], - children: SubjectController[] = [] + x: number, + y: number, + name: string, + private _domain_id: number | null, + _graph_id: number, + _parent_ids: number[], + _child_ids: number[], + private _lecture_ids: number[] ) { - super(graph, uuid.v4(), index, id, x, y, name, parents, children) - this.domain = domain + super(environment, id, x, y, name, _graph_id, _parent_ids, _child_ids) + this.environment.add(this) } - get color(): string { - /* Return the preview color of this subject */ + get domain(): Promise { + return (async () => { + if (this._domain !== undefined) + return this._domain + if (this._domain_id == null) + return this._domain = null - return this.domain?.color || 'transparent' + this._domain = await this.environment.getDomain(this._domain_id) as DomainController + return this._domain + })() } - get style(): string | undefined { - /* Return the style of this subject */ + get parents(): Promise { + return (async () => { + if (this._parents) return this._parents + this._parents = await this.environment.getSubjects(this._parent_ids) + return this._parents + })() + } - return this.domain?.style + get children(): Promise { + return (async () => { + if (this._children) return this._children + this._children = await this.environment.getSubjects(this._child_ids) + return this._children + })() } - get domain_options() { - /* Return the domain options of this subject */ + get lectures(): Promise { + return (async () => { + if (this._lectures) return this._lectures + this._lectures = await this.environment.getLectures(this._lecture_ids) + return this._lectures + })() + } - const options = [] - for (const domain of this.graph.domains) { + get graph(): Promise { + return (async () => { + if (this._graph) return this._graph + this._graph = await this.environment.getGraph(this._graph_id) as GraphController + return this._graph + })() + } - // Check if the domain has a name - if (!this.hasName(domain)) continue + get index(): Promise { + return this.graph.then(graph => graph.subjectIndex(this)) + } - options.push({ - name: domain.name, - value: domain, - validation: ValidationData.success() - }) - } + get style(): Promise { + return (async () => { + const domain = await this.domain + return domain?.style ?? null + })() + } - return options + get color(): Promise { + return (async () => { + const domain = await this.domain + return domain?.color ?? 'transparent' + })() } - private hasDomain(): boolean { - /* Check if the domain of a subject is undefined */ + /** + * Create a new subject + * @param environment Environment to create the subject in + * @param graph Graph to assign the subject to + * @returns `Promise` The newly created SubjectController + * @throws `APIError` if the API call fails + */ - return this.domain !== undefined - } + static async create(environment: ControllerEnvironment, graph: GraphController): Promise { + + // Call API to create a new subject + const response = await fetch(`/api/subject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ graph: graph.id }) + }) - static async create(graph: GraphController): Promise { - /* Create a new domain */ + // Check the response + .catch(error => { + throw new Error(`APIError (/api/subject POST): ${error}`) + }) - // Call the API - const response = await fetch(`/api/graph/${graph.id}/subject`, { method: 'POST' }) - .catch(error => { throw new Error(`Failed to create subject: ${error}`) }) - - // Parse response + // Revive the subject const data = await response.json() - - // Create subject object - const subject = new SubjectController(graph, graph.subjects.length, data.id) - graph.subjects.push(subject) + const subject = SubjectController.revive(environment, data) + graph.assignSubject(subject) return subject } - validate(): ValidationData { - /* Validate this subject */ + static revive(environment: ControllerEnvironment, data: SerializedSubject): SubjectController { + return new SubjectController(environment, data.id, data.x, data.y, data.name, data.domain, data.graph, data.parents, data.children, data.lectures) + } + + /** + * Validate the subject + * @returns `Promise` Validation data + */ - const result = new ValidationData() + async validate(): Promise { + const validation = new ValidationData() - // Check if the subject has a name - if (!this.hasName(this)) { - result.add({ + if (!this.hasName()) { + validation.add({ severity: Severity.error, short: 'Subject has no name', tab: 2, - anchor: this.anchor + uuid: this.uuid }) } else { - - // Check if the name is unique - const first = this.findOriginal(this.graph.subjects, this, subject => subject.name) - if (first !== -1) { - result.add({ - severity: Severity.error, - short: 'Subject has duplicate name', - long: `Name first used by Subject nr. ${first + 1}`, + const original = await this.findOriginalName() + if (original !== -1) { + validation.add({ + severity: Severity.warning, + short: 'Subject name is already in use', + long: `Name first used by Subject nr. ${original + 1}`, tab: 2, - anchor: this.anchor + uuid: this.uuid }) } - // Check if the name is too long - if (this.name.length > settings.FIELD_MAX_CHARS) { - result.add({ + if (this.hasLongName()) { + validation.add({ severity: Severity.error, - short: 'Subject name too long', + short: 'Subject name is too long', long: `Name exceeds ${settings.FIELD_MAX_CHARS} characters`, tab: 2, - anchor: this.anchor + uuid: this.uuid }) } } - // Check if the subject has a domain if (!this.hasDomain()) { - result.add({ + validation.add({ severity: Severity.error, short: 'Subject has no domain', tab: 2, - anchor: this.anchor + uuid: this.uuid }) } - return result + return validation } - reduce(): SerializedSubject { - /* Serialize subject to a POJO */ + /** + * Serialize the subject + * @returns `SerializedSubject` Serialized subject + */ + reduce(): SerializedSubject { return { id: this.id, x: this.x, y: this.y, - domain: this.domain?.id, name: this.name, - parents: this.parents.map(parent => parent.id), - children: this.children.map(child => child.id) + domain: this._domain_id, + graph: this._graph_id, + parents: this._parent_ids, + children: this._child_ids, + lectures: this._lecture_ids } } - async save(): Promise { - /* Save this subject */ + /** + * Save the subject + * @throws `APIError` if the API call fails + */ - // Serialize - const data = this.reduce() + async save(): Promise { - // Call the API + // Call API to save the subject await fetch(`/api/subject`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + body: JSON.stringify(this.reduce()) }) // Check the response - .catch(error => { - throw new Error(`Failed to save subject: ${error}`) + .catch(error => { + throw new Error(`APIError (/api/subject PUT): ${error}`) }) } + /** + * Delete the subject + * @throws `APIError` if the API call fails + */ + async delete(): Promise { - /* Delete this subject */ + + // Call API to delete the subject + await fetch(`/api/subject`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: this.id }) + }) - // Shift indexes - for (const subject of this.graph.subjects) { - if (subject.index > this.index) { - subject.index-- - } + // Check the response + .catch(error => { + throw new Error(`APIError (/api/subject DELETE): ${error}`) + }) + + // Unassign everywhere (mirroring is not necessary, as this object will be deleted) + const graph = await this.environment.getGraph(this._graph_id, false) + graph?.unassignSubject(this) + + if (this._domain_id !== null) { + const domain = await this.environment.getDomain(this._domain_id, false) + domain?.unassignSubject(this, false) } - // Delete relations - for (const relation of this.graph.subject_relations) { - if (relation.parent === this || relation.child === this) { - relation.delete() - } + const parents = await this.environment.getSubjects(this._parent_ids, false) + parents.forEach(parent => parent.unassignChild(this, false)) + + const children = await this.environment.getSubjects(this._child_ids, false) + children.forEach(child => child.unassignParent(this, false)) + + const lectures = await this.environment.getLectures(this._lecture_ids, false) + lectures.forEach(lecture => lecture.unassignSubject(this, false)) + + // Remove from environment + this.environment.remove(this) + } + + /** + * Find the first occurrence of the subject's name in the graph + * @returns `Promise` Index of the original name in the graph, or -1 if the name is unique/nonexistant + */ + + private async findOriginalName(): Promise { + const subjects = await this.graph.then(graph => graph.subjects) + const first = subjects.findIndex(item => item.name === this.name) + const second = subjects.indexOf(this, first + 1) + + return first < second ? subjects[first].index : -1 + } + + /** + * Check if the subject has a domain + * @returns `boolean` Whether the subject has a domain + */ + + private hasDomain(): boolean { + return this._domain_id !== null + } + + /** + * Assign a parent subject to the subject + * @param parent Subject to assign as a parent + * @param mirror Whether to mirror the assignment + * @throws `SubjectError` if the subject is already assigned as a parent + */ + + assignParent(parent: SubjectController, mirror: boolean = true): void { + if (this._parent_ids.includes(parent.id)) + throw new Error(`SubjectError: Subject is already assigned as a parent of Subject with ID ${parent.id}`) + this._parent_ids.push(parent.id) + this._parents?.push(parent) + + if (mirror) { + parent.assignChild(this, false) } + } + + /** + * Assign a child subject to the subject + * @param child Subject to assign as a child + * @param mirror Whether to mirror the assignment + * @throws `SubjectError` if the subject is already assigned as a child + */ + + assignChild(child: SubjectController, mirror: boolean = true): void { + if (this._child_ids.includes(child.id)) + throw new Error(`SubjectError: Subject is already assigned as a child of Subject with ID ${child.id}`) + this._child_ids.push(child.id) + this._children?.push(child) + + if (mirror) { + child.assignParent(this, false) + } + } - // Remove subject from lectures - for (const lecture of this.graph.lectures) { - lecture.lecture_subjects = lecture.lecture_subjects.filter(ls => ls.subject !== this) + /** + * Assign a domain to the subject + * @param domain Domain to assign to the subject + * @param mirror Whether to mirror the assignment + * @throws `SubjectError` if the subject is already assigned to a domain + */ + + assignToDomain(domain: DomainController, mirror: boolean = true): void { + if (this._domain_id !== null) + throw new Error(`SubjectError: Subject is already assigned to Domain with ID ${this._domain_id}`) + this._domain_id = domain.id + this._domain = domain + + if (mirror) { + domain.assignSubject(this, false) } + } - // Call the API - await fetch(`/api/subject/${this.id}`, { method: 'DELETE' }) - .catch(error => { throw new Error(`Failed to delete subject: ${error}`) }) + /** + * Assign the subject to a lecture + * @param lecture Lecture to assign the subject to + * @param mirror Whether to mirror the assignment + * @throws `SubjectError` if the subject is already assigned to the lecture + */ + + assignToLecture(lecture: LectureController, mirror: boolean = true): void { + if (this._lecture_ids.includes(lecture.id)) + throw new Error(`SubjectError: Subject is already assigned to Lecture with ID ${lecture.id}`) + this._lecture_ids.push(lecture.id) + this._lectures?.push(lecture) + + if (mirror) { + lecture.assignSubject(this, false) + } + } - // Remove this subject from the graph - this.graph.subjects = this.graph.subjects.filter(subject => subject !== this) + /** + * Unassign a parent subject from the subject + * @param parent Subject to unassign as a parent + * @param mirror Whether to mirror the unassignment + * @throws `SubjectError` if the subject is not assigned as a parent + */ + + unassignParent(parent: SubjectController, mirror: boolean = true): void { + if (!this._parent_ids.includes(parent.id)) + throw new Error(`SubjectError: Subject is not assigned as a parent of Subject with ID ${parent.id}`) + this._parent_ids = this._parent_ids.filter(id => id !== parent.id) + this._parents = this._parents?.filter(parent => parent.id !== parent.id) + + if (mirror) { + parent.unassignChild(this, false) + } } -} + + /** + * Unassign a child subject from the subject + * @param child Subject to unassign as a child + * @param mirror Whether to mirror the unassignment + * @throws `SubjectError` if the subject is not assigned as a child + */ + + unassignChild(child: SubjectController, mirror: boolean = true): void { + if (!this._child_ids.includes(child.id)) + throw new Error(`SubjectError: Subject is not assigned as a child of Subject with ID ${child.id}`) + this._child_ids = this._child_ids.filter(id => id !== child.id) + this._children = this._children?.filter(child => child.id !== child.id) + + if (mirror) { + child.unassignParent(this, false) + } + } + + /** + * Unassign the subject from a domain + * @param domain Domain to unassign the subject from + * @param mirror Whether to mirror the unassignment + * @throws `SubjectError` if the subject is not assigned to the domain + */ + + unassignFromDomain(domain: DomainController, mirror: boolean = true): void { + if (this._domain_id !== domain.id) + throw new Error(`SubjectError: Subject is not assigned to Domain with ID ${domain.id}`) + this._domain_id = null + this._domain = null + + if (mirror) { + domain.unassignSubject(this, false) + } + } + + /** + * Unassign the subject from a lecture + * @param lecture Lecture to unassign the subject from + * @param mirror Whether to mirror the unassignment + * @throws `SubjectError` if the subject is not assigned to the lecture + */ + + unassignFromLecture(lecture: LectureController, mirror: boolean = true): void { + if (!this._lecture_ids.includes(lecture.id)) + throw new Error(`SubjectError: Subject is not assigned to Lecture with ID ${lecture.id}`) + this._lecture_ids = this._lecture_ids.filter(id => id !== lecture.id) + this._lectures = this._lectures?.filter(lecture => lecture.id !== lecture.id) + + if (mirror) { + lecture.unassignSubject(this, false) + } + } +} \ No newline at end of file diff --git a/src/lib/scripts/controllers/GraphController.ts b/src/lib/scripts/controllers/GraphController.ts index b4c73b1..3f786ee 100644 --- a/src/lib/scripts/controllers/GraphController.ts +++ b/src/lib/scripts/controllers/GraphController.ts @@ -1,370 +1,432 @@ -// Internal imports + +// Internal dependencies import { - DomainController, DomainRelationController, - SubjectController, SubjectRelationController, - LectureController, LectureSubject, - CourseController + ControllerEnvironment, + CourseController, + DomainController, + SubjectController, + LectureController } from '$scripts/controllers' import { ValidationData, Severity } from '$scripts/validation' -import { styles } from '$scripts/settings' -import type { SerializedGraph } from '$scripts/types' +import type { SerializedGraph, DropdownOption } from '$scripts/types' // Exports -export { GraphController, SortOption } - +export { GraphController } -// --------------------> Classes +// --------------------> Controller -type SortOptions = number -enum SortOption { - ascending = 0b000000000, - descending = 0b100000000, - domains = 0b010000000, - subjects = 0b001000000, - relations = 0b000100000, - name = 0b000010000, - style = 0b000001000, - domain = 0b000000100, - parent = 0b000000010, - child = 0b000000001 -} class GraphController { + private _course?: CourseController + private _domains?: DomainController[] + private _subjects?: SubjectController[] + private _lectures?: LectureController[] + constructor( + public environment: ControllerEnvironment, public id: number, public name: string, - private _domains: DomainController[] = [], - private _subjects: SubjectController[] = [], - private _lectures: LectureController[] = [], - private _compact: boolean = true - ) { } - - // Inferred - domain_relations: DomainRelationController[] = [] - subject_relations: SubjectRelationController[] = [] - - get compact() { - return this._compact + private _course_id: number, + private _domain_ids: number[], + private _subject_ids: number[], + private _lecture_ids: number[] + ) { + this.environment.add(this) } - private set compact(value: boolean) { - this._compact = value + get course(): Promise { + return (async () => { + if (this._course) return this._course + this._course = await this.environment.getCourse(this._course_id) as CourseController + return this._course + })() } - get expanded() { - return !this._compact + get domains(): Promise { + return (async () => { + if (this._domains) return this._domains + this._domains = await this.environment.getDomains(this._domain_ids) + return this._domains + })() } - private set expanded(value: boolean) { - this._compact = !value + get subjects(): Promise { + return (async () => { + if (this._subjects) return this._subjects + this._subjects = await this.environment.getSubjects(this._subject_ids) + return this._subjects + })() } - get domains() { - - // Check if the domains are expanded - if (this.compact) throw new Error('Failed to get domains: Graph is too compact') - return this._domains + get lectures(): Promise { + return (async () => { + if (this._lectures) return this._lectures + this._lectures = await this.environment.getLectures(this._lecture_ids) + return this._lectures + })() } - set domains(value: DomainController[]) { - this._domains = value - } - - get subjects() { - - // Check if the subjects are expanded - if (this.compact) throw new Error('Failed to get subjects: Graph is too compact') - return this._subjects - } - - set subjects(value: SubjectController[]) { - this._subjects = value - } - - get lectures() { - - // Check if the lectures are expanded - if (this.compact) throw new Error('Failed to get lectures: Graph is too compact') - return this._lectures + get lecture_options(): Promise[]> { + return (async () => { + const lectures = await this.lectures + return Promise.all( + lectures.map( + async lecture => ({ + value: lecture, + label: lecture.name, + validation: await lecture.validate() + }) + ) + ) + })() } - set lectures(value: LectureController[]) { - this._lectures = value + get index(): Promise { + return this.course.then(course => course.graphIndex(this)) } - get lecture_options() { - /* Return the options of the lecture */ - - // Check if the graph is lazy - if (this._compact) throw new Error('Failed to get lecture options: graph is too compact') - - // Find lecture options - const options = [] - for (const lecture of this.lectures) { - options.push({ - name: lecture.name, - value: lecture, - validation: ValidationData.success() - }) - } - - return options - } + /** + * Create a new graph + * @param environment Environment to create the graph in + * @param course Course to assign the graph to + * @returns `Promise` The newly created GraphController + * @throws `APIError` if the API call fails + */ - static async create(course: CourseController, name: string): Promise { - /* Create a new graph */ + static async create(environment: ControllerEnvironment, course: CourseController): Promise { - // Call the API - const response = await fetch(`/api/course/${course.id}/graph`, { + // Call API to create a new graph + const response = await fetch(`/api/graph`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }) + body: JSON.stringify({ course: course.id }) }) // Check the response .catch(error => { - throw new Error(`Failed to create graph: ${error}`) + throw new Error(`APIError (/api/graph POST): ${error}`) }) // Revive the graph - const data: SerializedGraph = await response.json() - const graph = await GraphController.revive(data) - course.graphs.push(graph) + const data = await response.json() + const graph = GraphController.revive(environment, data) + course.assignGraph(graph) + return graph } - static async revive(data: SerializedGraph, depth: number = 0): Promise { - /* Revive graph from a POJO */ + /** + * Revive a graph from serialized data + * @param environment Environment to revive the graph in + * @param data Serialized data to revive + * @returns `GraphController` The revived GraphController + */ - const graph = new GraphController(data.id, data.name) - await graph.expand(depth) - return graph + static revive(environment: ControllerEnvironment, data: SerializedGraph): GraphController { + return new GraphController(environment, data.id, data.name, data.course, data.domains, data.subjects, data.lectures) } - async expand(depth: number = 1): Promise { - - /* Expand the program */ + /** + * Validate the graph + * @returns `Promise` Validation data + */ - // Check if expansion is possible - if (this.expanded || depth < 1) return this - this.expanded = true + async validate(): Promise { + const validation = new ValidationData() - // Call the API - const urls = [ - `/api/graph/${this.id}/domain`, - `/api/graph/${this.id}/subject`, - `/api/graph/${this.id}/lecture` - ] + if (!this.hasName()) { + validation.add({ + severity: Severity.error, + short: 'Graph has no name', + tab: 0, + uuid: 'graph-name' + }) + } else { - const [domains, subjects, lectures] = await Promise.all( - urls.map(url => fetch(url, { method: 'GET' }) - .then(response => response.json()) - .catch(error => { throw new Error(`Failed to load graph: ${error}`) }) - ) - ) - - // Define domains - for (const domain_data of domains) { - this.domains.push( - new DomainController( - this, - this.domains.length, - domain_data.id, - domain_data.x, - domain_data.y, - domain_data.name ?? '', - domain_data.style ?? undefined - ) - ) + const original = await this.findOriginalName() + if (original !== -1) { + validation.add({ + severity: Severity.error, + short: 'Graph name is not unique', + long: `Name first used by Graph nr. ${original + 1}`, + tab: 0, + uuid: 'graph-name' + }) + } } - // Find domain references - for (const parent_data of domains) { - const parent = this.domains.find(domain => domain.id === parent_data.id) - for (const child_id of parent_data.children) { - const child = this.domains.find(domain => domain.id === child_id) - - // Create relation - DomainRelationController.create(this, parent, child) - } + if (!this.hasDomains()) { + validation.add({ + severity: Severity.warning, + short: 'Graph has no domains' + }) } - // Define subjects - for (const subject_data of subjects) { - const domain = this.domains.find(domain => domain.id === subject_data.domain) - - this.subjects.push( - new SubjectController( - this, - this.subjects.length, - subject_data.id, - subject_data.x, - subject_data.y, - subject_data.name ?? '', - domain - ) - ) + if (!this.hasSubjects()) { + validation.add({ + severity: Severity.warning, + short: 'Graph has no subjects' + }) } - // Find subject references - for (const parent_data of subjects) { - const parent = this.subjects.find(subject => subject.id === parent_data.id) - for (const child_id of parent_data.children) { - const child = this.subjects.find(subject => subject.id === child_id) + if (!this.hasLectures()) { + validation.add({ + severity: Severity.warning, + short: 'Graph has no lectures' + }) + } - // Create relation - SubjectRelationController.create(this, parent, child) - } + const [domains, subjects, lectures] = await Promise.all([this.domains, this.subjects, this.lectures]) + for (const domain of domains) + validation.add(await domain.validate()) + for (const subject of subjects) + validation.add(await subject.validate()) + for (const lecture of lectures) { + validation.add(await lecture.validate()) } - // Define lectures - for (const lecture_data of lectures) { - const lecture = new LectureController( - this, - this.lectures.length, - lecture_data.id, - lecture_data.name ?? '' - ) + return validation + } - this.lectures.push(lecture) + /** + * Serialize the graph + * @returns `SerializedGraph` Serialized graph + */ - // Define lecture subjects - for (const subject_id of lecture_data.subjects) { - const subject = this.subjects.find(subject => subject.id === subject_id) - LectureSubject.create(lecture, subject) - } + reduce(): SerializedGraph { + return { + id: this.id, + name: this.name, + course: this._course_id, + domains: this._domain_ids, + subjects: this._subject_ids, + lectures: this._lecture_ids } - - return this } - async save() { - /* Save the graph to the database */ + /** + * Save the graph + * @throws `APIError` if the API call fails + */ + + async save(): Promise { - // Call the API + // Call API to save the graph await fetch(`/api/graph`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.reduce()) }) // Check the response .catch(error => { - throw new Error(`Failed to save graph: ${error}`) + throw new Error(`APIError (/api/graph PUT): ${error}`) }) } - async delete() { - /* Delete the graph from the database */ + /** + * Delete the graph, and all related domains, subjects, and lectures + * @throws `APIError` if the API call fails + */ + + async delete(): Promise { + + // Call API to delete the graph + await fetch(`/api/graph`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: this.id }) + }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/graph DELETE): ${error}`) + }) + + // Unassign from course + const course = await this.environment.getCourse(this._course_id, false) + course?.unassignGraph(this) + + // Delete all related domains, subjects, and lectures + const domains = await this.domains + await Promise.all(domains.map(domain => domain.delete())) + + const subjects = await this.subjects + await Promise.all(subjects.map(subject => subject.delete())) + + const lectures = await this.lectures + await Promise.all(lectures.map(lecture => lecture.delete())) - // Call the API - await fetch(`/api/graph/${this.id}`, { method: 'DELETE' }) - .catch(error => { throw new Error(`Failed to delete graph: ${error}`) }) + // Remove from environment + this.environment.remove(this) } - validate(): ValidationData { - /* Validate the graph */ + /** + * Check if the graph has a name + * @returns `boolean` Whether the graph has a name + */ - // Check if the graph is lazy - if (this.compact) throw new Error('Failed to validate graph: graph is too compact') + private hasName(): boolean { + return this.name.trim() !== '' + } - // Create response - const validation = new ValidationData() + /** + * Find the first occurrence of the graph's name in the course + * @returns `Promise` Index of the original name in the course, or -1 if the name is unique/nonexistant + */ - // Check if the graph has a name - if (this.name === '') { - validation.add({ - severity: Severity.error, - short: 'Graph has no name', - long: 'The graph must have a name', - tab: 0, - anchor: 'name' - }) - } + private async findOriginalName(): Promise { + const graphs = await this.course.then(course => course.graphs) + const first = graphs.findIndex(graph => graph.name === this.name) + const second = graphs.indexOf(this, first + 1) - // Validate domains, subjects and lectures - for (const domain of this.domains) - validation.add(domain.validate()) - for (const relation of this.domain_relations) - validation.add(relation.validate()) - for (const subject of this.subjects) - validation.add(subject.validate()) - for (const relation of this.subject_relations) - validation.add(relation.validate()) - for (const lecture of this.lectures) - validation.add(lecture.validate()) + return first < second ? graphs[first].index : -1 + } - return validation + /** + * Check if the graph has domains + * @returns `boolean` Whether the graph has domains + */ + + private hasDomains(): boolean { + return this._domain_ids.length > 0 } - sort(options: SortOptions): void { - /* Sort the graph */ - - // Check if the graph is lazy - if (this.compact) throw new Error('Failed to sort graph: graph is too compact') - - // Define key function - let key: (item: any) => string - if (options & SortOption.relations) { - if (options & SortOption.parent) { - key = relation => relation.parent?.name ?? '' - } else if (options & SortOption.child) { - key = relation => relation.child?.name ?? '' - } else return - } else if (options & SortOption.name) { - key = field => field.name - } else if (options & SortOption.style) { - key = domain => domain.style ? styles[domain.style].display_name : '' - } else if (options & SortOption.domain) { - key = subject => subject.domain?.name ?? '' - } else return - - // Sort the appropriate fields - if (options & SortOption.relations) { - if (options & SortOption.domains) { - this.domain_relations.sort((a, b) => key(b).localeCompare(key(a))) - if (options & SortOption.descending) this.domain_relations.reverse() - } else if (options & SortOption.subjects) { - this.subject_relations.sort((a, b) => key(b).localeCompare(key(a))) - if (options & SortOption.descending) this.subject_relations.reverse() - } - } else { - if (options & SortOption.domains) { - this.domains.sort((a, b) => key(b).localeCompare(key(a))) - if (options & SortOption.descending) this.domains.reverse() - } else if (options & SortOption.subjects) { - this.subjects.sort((a, b) => key(b).localeCompare(key(a))) - if (options & SortOption.descending) this.subjects.reverse() - } - } + /** + * Check if the graph has subjects + * @returns `boolean` Whether the graph has subjects + */ + + private hasSubjects(): boolean { + return this._subject_ids.length > 0 } - - nextDomainStyle(): string | undefined { - /* Return the next available domain style */ - // Check if the graph is lazy - if (this.compact) throw new Error('Failed to get next domain style: graph is too compact') + /** + * Check if the graph has lectures + * @returns `boolean` Whether the graph has lectures + */ - // Find used styles - const used_styles = this.domains.map(domain => domain.style) - return Object.keys(styles).find(style => !used_styles.includes(style)) + private hasLectures(): boolean { + return this._lecture_ids.length > 0 } - reduce(): SerializedGraph { - /* Reduce graph to a POJO */ + /** + * Get the index of a domain in the graph + * @param domain Domain to get the index of + * @returns `number` Index of the domain in the graph + * @throws `GraphError` if the domain is not assigned to the graph + */ + + domainIndex(domain: DomainController): number { + const index = this._domain_ids.indexOf(domain.id) + if (index === -1) throw new Error(`GraphError: Domain with ID ${domain.id} is not assigned to Graph`) + return index + } - return { - id: this.id, - name: this.name - } + /** + * Get the index of a subject in the graph + * @param subject Subject to get the index of + * @returns `number` Index of the subject in the graph + * @throws `GraphError` if the subject is not assigned to the graph + */ + + subjectIndex(subject: SubjectController): number { + const index = this._subject_ids.indexOf(subject.id) + if (index === -1) throw new Error(`GraphError: Subject with ID ${subject.id} is not assigned to Graph`) + return index } - // TODO: Temp because these were used in mocked data in course overview - hasLinks = () => true - isVisible = () => true -} + /** + * Get the index of a lecture in the graph + * @param lecture Lecture to get the index of + * @returns `number` Index of the lecture in the graph + * @throws `GraphError` if the lecture is not assigned to the graph + */ + + lectureIndex(lecture: LectureController): number { + const index = this._lecture_ids.indexOf(lecture.id) + if (index === -1) throw new Error(`GraphError: Lecture with ID ${lecture.id} is not assigned to Graph`) + return index + } + + /** + * Assign a domain to the graph + * @param domain Domain to assign to the graph + * @throws `GraphError` if the domain is already assigned to the graph + */ + + assignDomain(domain: DomainController): void { + if (this._domain_ids.includes(domain.id)) + throw new Error(`GraphError: Graph is already assigned to Domain with ID ${domain.id}`) + this._domain_ids.push(domain.id) + this._domains?.push(domain) + } + + /** + * Assign a subject to the graph + * @param subject Subject to assign to the graph + * @throws `GraphError` if the subject is already assigned to the graph + */ + + assignSubject(subject: SubjectController): void { + if (this._subject_ids.includes(subject.id)) + throw new Error(`GraphError: Graph is already assigned to Subject with ID ${subject.id}`) + this._subject_ids.push(subject.id) + this._subjects?.push(subject) + } + + /** + * Assign a lecture to the graph + * @param lecture Lecture to assign to the graph + * @throws `GraphError` if the lecture is already assigned to the graph + */ + + assignLecture(lecture: LectureController): void { + if (this._lecture_ids.includes(lecture.id)) + throw new Error(`GraphError: Graph is already assigned to Lecture with ID ${lecture.id}`) + this._lecture_ids.push(lecture.id) + this._lectures?.push(lecture) + } + + /** + * Unassign a domain from the graph + * @param domain Domain to unassign from the graph + * @throws `GraphError` if the domain is not assigned to the graph + */ + + unassignDomain(domain: DomainController): void { + if (!this._domain_ids.includes(domain.id)) + throw new Error(`GraphError: Graph is not assigned to Domain with ID ${domain.id}`) + this._domain_ids = this._domain_ids.filter(id => id !== domain.id) + this._domains = this._domains?.filter(domain => domain.id !== domain.id) + } + + /** + * Unassign a subject from the graph + * @param subject Subject to unassign from the graph + * @throws `GraphError` if the subject is not assigned to the graph + */ + + unassignSubject(subject: SubjectController): void { + if (!this._subject_ids.includes(subject.id)) + throw new Error(`GraphError: Graph is not assigned to Subject with ID ${subject.id}`) + this._subject_ids = this._subject_ids.filter(id => id !== subject.id) + this._subjects = this._subjects?.filter(subject => subject.id !== subject.id) + } + + /** + * Unassign a lecture from the graph + * @param lecture Lecture to unassign from the graph + * @throws `GraphError` if the lecture is not assigned to the graph + */ + + unassignLecture(lecture: LectureController): void { + if (!this._lecture_ids.includes(lecture.id)) + throw new Error(`GraphError: Graph is not assigned to Lecture with ID ${lecture.id}`) + this._lecture_ids = this._lecture_ids.filter(id => id !== lecture.id) + this._lectures = this._lectures?.filter(lecture => lecture.id !== lecture.id) + } +} \ No newline at end of file diff --git a/src/lib/scripts/controllers/LectureController.ts b/src/lib/scripts/controllers/LectureController.ts index 842c2ac..abf9852 100644 --- a/src/lib/scripts/controllers/LectureController.ts +++ b/src/lib/scripts/controllers/LectureController.ts @@ -1,304 +1,273 @@ -// External imports -import * as uuid from 'uuid' +// Internal dependencies +import { + ControllerEnvironment, + GraphController, + SubjectController +} from '$scripts/controllers' -// Internal imports -import { GraphController, SubjectController, SubjectRelationController } from '$scripts/controllers' import { ValidationData, Severity } from '$scripts/validation' + import type { SerializedLecture } from '$scripts/types' +// External dependencies +import * as uuid from 'uuid' + // Exports -export { LectureController, LectureSubject } +export { LectureController } // --------------------> Classes -class LectureSubject { - constructor( - public lecture: LectureController, - public subject?: SubjectController - ) { } - - static create(lecture: LectureController, subject?: SubjectController): LectureSubject { - /* Create a new lecture subject */ - - const lecture_subject = new LectureSubject(lecture, subject) - lecture.lecture_subjects.push(lecture_subject) - return lecture_subject - } - - get color(): string { - /* Return the color of the subject */ - - return this.subject?.color || 'transparent' - } - - get options() { - /* Return the options of the subject */ - - const options = [] - for (const subject of this.lecture.graph.subjects) { - if (subject.name === '') continue - - // Check if the subject is already in the lecture - const validation = new ValidationData() - if (this.lecture.present.includes(subject)) { - validation.add({ severity: Severity.error, short: 'Duplicate subject'}) - } - - options.push({ - name: subject.name, - value: subject, - validation - }) - } - - return options - } - - delete() { - /* Delete this lecture subject */ - - this.lecture.lecture_subjects = this.lecture.lecture_subjects.filter(subject => subject !== this) - } -} - class LectureController { - anchor: string + private _graph?: GraphController + private _subjects?: SubjectController[] + + uuid: string constructor( - public graph: GraphController, - public index: number, + public environment: ControllerEnvironment, public id: number, - public name: string = '', - public lecture_subjects: LectureSubject[] = [] + public name: string, + private _graph_id: number, + private _subject_ids: number[] ) { - /* Create a new lecture */ - - this.anchor = uuid.v4() + this.uuid = uuid.v4() + this.environment.add(this) } - static async create(graph: GraphController) { - /* Create a new lecture */ - - // Call API to create lecture - const response = await fetch(`/api/graph/${graph.id}/lecture`, { method: 'POST' }) - .catch(error => { throw new Error(`Failed to create lecture: ${error}`) }) - - // Parse response - const data = await response.json() - - // Create lecture object - const lecture = new LectureController(graph, graph.lectures.length, data.id) - graph.lectures.push(lecture) - - return lecture - } - - get size(): number { - /* Return the size of the lecture */ - - return Math.max( - this.past.length, - this.present.length, - this.future.length - ) - } - - get past(): SubjectController[] { - /* Return the past of this lecture */ - - const past: SubjectController[] = [] - for (const lecture_subject of this.lecture_subjects) { - if (!lecture_subject.subject) continue - for (const parent of lecture_subject.subject.parents) { - if (this.present.includes(parent) || past.includes(parent)) - continue - past.push(parent) - } - } - - return past + get subjects(): Promise { + return (async () => { + if (this._subjects) return this._subjects + this._subjects = await this.environment.getSubjects(this._subject_ids) + return this._subjects + })() } - get present(): SubjectController[] { - /* Return the present of this lecture */ - - const present: SubjectController[] = [] - for (const lecture_subject of this.lecture_subjects) { - if (!lecture_subject.subject) continue - present.push(lecture_subject.subject) - } - - return present - } - - get future(): SubjectController[] { - /* Return the future of this lecture */ - - const future: SubjectController[] = [] - for (const lecture_subject of this.lecture_subjects) { - if (!lecture_subject.subject) continue - for (const child of lecture_subject.subject.children) { - if (this.present.includes(child) || future.includes(child)) - continue - future.push(child) - } - } - - return future - } - - get subjects(): SubjectController[] { - /* Return the fields of this lecture */ - - return this.past - .concat(this.present) - .concat(this.future) + get graph(): Promise { + return (async () => { + if (this._graph) return this._graph + this._graph = await this.environment.getGraph(this._graph_id) as GraphController + return this._graph + })() } - get relations(): SubjectRelationController[] { - /* Return the relations of this lecture */ - - const relations: SubjectRelationController[] = [] - for (const subject of this.present) { - for (const relation of this.graph.subject_relations) { - if (relation.child === subject || relation.parent === subject) { - relations.push(relation) - } - } - } - - return relations + get index(): Promise { + return this.graph.then(graph => graph.lectureIndex(this)) } - private hasName(): boolean { - /* Check if the lecture has a name */ + /** + * Create a new lecture + * @param environment Environment to create the lecture in + * @param graph Graph to assign the lecture to + * @returns `Promise` The newly created LectureController + * @throws `APIError` if the API call fails + */ - return this.name !== '' - } + static async create(environment: ControllerEnvironment, graph: GraphController): Promise { - private hasSubjects(): boolean { - /* Check if the lecture has subjects */ + // Call API to create a new lecture + const response = await fetch(`/api/lecture`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ graph: graph.id }) + }) - return this.lecture_subjects.length > 0 - } + // Check the response + .catch(error => { + throw new Error(`APIError (/api/lecture POST): ${error}`) + }) - private isDefined(): boolean { - /* Check if the lecture is defined */ + // Revive the lecture + const data = await response.json() + const lecture = LectureController.revive(environment, data) + graph.assignLecture(lecture) - return this.lecture_subjects.every(lecture_subject => lecture_subject.subject) + return lecture } - private findOriginal(list: S[], value: S, key: (item: S) => T): number { - /* Find the original item in a list - * Returns -1 if value doesn't exist, or isnt a duplicate - * Returns the index of the first duplicate otherwise - */ + /** + * Revive a lecture from serialized data + * @param environment Environment to revive the lecture in + * @param data Serialized data to revive + * @returns `LectureController` The revived LectureController + */ - const first = list.findIndex(item => key(item) === key(value)) - const index = list.indexOf(value, first + 1) - return index === -1 ? -1 : first + static revive(environment: ControllerEnvironment, data: SerializedLecture): LectureController { + return new LectureController(environment, data.id, data.name, data.graph, data.subjects) } - validate(): ValidationData { - /* Validate the lecture */ + /** + * Validate the lecture + * @returns `Promise` Validation data + */ - const result = new ValidationData() + async validate(): Promise { + const validation = new ValidationData() - // Check if the lecture has a name - if (!this.hasName()){ - result.add({ + if (!this.hasName()) { + validation.add({ severity: Severity.error, short: 'Lecture has no name', tab: 3, - anchor: this.anchor + uuid: this.uuid }) } - // Check if the name is unique else { - const first = this.findOriginal(this.graph.lectures, this, lecture => lecture.name) - if (first !== -1) { - result.add({ - severity: Severity.error, - short: 'Duplicate lecture name', - long: `Name first used by Lecture nr. ${first + 1}`, + const original = await this.findOriginalName() + if (original !== -1) { + validation.add({ + severity: Severity.warning, + short: 'Lecture name is already in use', + long: `Name first used by Lecture nr. ${original + 1}`, tab: 3, - anchor: this.anchor + uuid: this.uuid }) } } - // Check if the lecture has subjects if (!this.hasSubjects()) { - result.add({ - severity: Severity.error, + validation.add({ + severity: Severity.warning, short: 'Lecture has no subjects', tab: 3, - anchor: this.anchor - }) - } - - // TODO maybe just save defined subjects and remove this error - // Check if the lecture has undefined subjects - else if (!this.isDefined()) { - result.add({ - severity: Severity.error, - short: 'Lecture has undefined subjects', - long: 'Make sure all subjects are defined', - tab: 3, - anchor: this.anchor + uuid: this.uuid }) } - return result + return validation } - reduce(): SerializedLecture { - /* Serialize lecture to a POJO */ + /** + * Serialize the lecture + * @returns `SerializedLecture` Serialized lecture + */ + reduce(): SerializedLecture { return { id: this.id, name: this.name, - subjects: this.present.map(subject => subject.id) + graph: this._graph_id, + subjects: this._subject_ids } } - async save(): Promise { - /* Save this lecture */ + /** + * Save the lecture + * @throws `APIError` if the API call fails + */ - // Serialize - const data = this.reduce() + async save(): Promise { - // Call the API + // Call API to save the lecture await fetch(`/api/lecture`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + body: JSON.stringify(this.reduce()) }) // Check the response .catch(error => { - throw new Error(`Failed to save lecture: ${error}`) + throw new Error(`APIError (/api/lecture PUT): ${error}`) }) } - async delete() { - /* Delete this lecture */ + /** + * Delete the lecture + * @throws `APIError` if the API call fails + */ - // Shift indexes - for (const lecture of this.graph.lectures) { - if (lecture.index > this.index) - lecture.index-- - } + async delete(): Promise { - // Call API to delete lecture - await fetch(`/api/lecture/${this.id}`, { method: 'DELETE' }) - .catch(error => { throw new Error(`Failed to delete lecture: ${error}`) }) + // Call API to delete the lecture + await fetch(`/api/lecture`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: this.id }) + }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/lecture DELETE): ${error}`) + }) + + // Unassign everywhere (mirroring is not necessary, as this object will be deleted) + const graph = await this.environment.getGraph(this._graph_id, false) + graph?.unassignLecture(this) + + const subjects = await this.environment.getSubjects(this._subject_ids, false) + subjects.forEach(subject => subject.unassignFromLecture(this, false)) + + // Remove from environment + this.environment.remove(this) + } + + /** + * Check if the lecture has a name + * @returns `boolean` Whether the lecture has a name + */ + + private hasName(): boolean { + return this.name.trim() !== '' + } + + /** + * Find the first occurrence of the lecture's name in the graph + * @returns `Promise` Index of the original name in the graph, or -1 if the name is unique/nonexistant + */ - // Remove this lecture from the graph - this.graph.lectures = this.graph.lectures.filter(lecture => lecture !== this) + private async findOriginalName(): Promise { + const lectures = await this.graph.then(graph => graph.lectures) + const first = lectures.findIndex(item => item.name === this.name) + const second = lectures.indexOf(this, first + 1) + + return first < second ? lectures[first].index : -1 + } + + /** + * Check if the lecture has subjects + * @returns `boolean` Whether the lecture has subjects + */ + + private hasSubjects(): boolean { + return this._subject_ids.length > 0 + } + + /** + * Assign a subject to the lecture + * @param subject Subject to assign to the lecture + * @param mirror Whether to mirror the assignment + * @throws `LectureError` if the subject is already assigned to the lecture + */ + + assignSubject(subject: SubjectController, mirror: boolean = true): void { + if (this._subject_ids.includes(subject.id)) + throw new Error(`LectureError: Lecture is already assigned to Subject with ID ${subject.id}`) + this._subject_ids.push(subject.id) + this._subjects?.push(subject) + + if (mirror) { + subject.assignToLecture(this, false) + } + } + + /** + * Unassign a subject from the lecture + * @param subject Subject to unassign from the lecture + * @param mirror Whether to mirror the unassignment + * @throws `LectureError` if the subject is not assigned to the lecture + */ + + unassignSubject(subject: SubjectController, mirror: boolean = true): void { + if (!this._subject_ids.includes(subject.id)) + throw new Error(`LectureError: Lecture is not assigned to Subject with ID ${subject.id}`) + this._subject_ids = this._subject_ids.filter(id => id !== subject.id) + this._subjects = this._subjects?.filter(subject => subject.id !== subject.id) + + if (mirror) { + subject.unassignFromLecture(this, false) + } } -} +} \ No newline at end of file diff --git a/src/lib/scripts/controllers/ProgramController.ts b/src/lib/scripts/controllers/ProgramController.ts index f1747de..a12bbd9 100644 --- a/src/lib/scripts/controllers/ProgramController.ts +++ b/src/lib/scripts/controllers/ProgramController.ts @@ -1,103 +1,462 @@ -// Internal imports -import { CourseController } from '$scripts/controllers' -import type { SerializedProgram, SerializedCourse } from '$scripts/types' +// Internal dependencies +import { + ControllerEnvironment, + UserController, + CourseController +} from '$scripts/controllers' + +import { ValidationData, Severity } from '$scripts/validation' + +import type { SerializedCourse, SerializedProgram, SerializedUser } from '$scripts/types' +import { error } from '@sveltejs/kit' // Exports export { ProgramController } -// --------------------> Classes +// --------------------> Controller class ProgramController { - constructor( + private _courses?: CourseController[] + private _admins?: UserController[] + private _editors?: UserController[] + + private constructor( + public environment: ControllerEnvironment, public id: number, public name: string, - private _courses: CourseController[]= [], - private _compact: boolean = true - // public coordinators: User[] = [], - ) { } - - get compact() { - return this._compact + private _course_ids: number[], + private _editor_ids: number[], + private _admin_ids: number[] + ) { + this.environment.add(this) } - private set compact(value: boolean) { - this._compact = value + get courses(): Promise { + + // Check if courses are already loaded + if (this._courses) { + return Promise.resolve(this._courses) + } + + // Call API to get the courses + return fetch(`/api/program/${this.id}/courses`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/program/${this.id}/courses GET): ${error}`) } + ) + + // Parse the data + .then(course_data => { + + // Get the courses from the environment + this._courses = course_data.map(course => this.environment.get(course)) + + // Check if client is in sync with the server + const client = JSON.stringify(this._course_ids.concat().sort()) + const server = JSON.stringify(this._courses.map(course => course.id).sort()) + if (client !== server) { + throw new Error('ProgramError: Courses are not in sync with the server') + } + + return this._courses + }) } - get expanded() { - return !this._compact + get admins(): Promise { + + // Check if admins are already loaded + if (this._admins) { + return Promise.resolve(this._admins) + } + + // Call API to get the admins + return fetch(`/api/program/${this.id}/admins`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/program/${this.id}/admins GET): ${error}`) } + ) + + // Parse the data + .then(admin_data => { + + // Get the admins from the environment + this._admins = admin_data.map(user => this.environment.get(user)) + + // Check if client is in sync with the server + const client = JSON.stringify(this._admin_ids.concat().sort()) + const server = JSON.stringify(this._admins.map(admin => admin.id).sort()) + if (client !== server) { + throw new Error('ProgramError: Admins are not in sync with the server') + } + + return this._admins + }) } - private set expanded(value: boolean) { - this._compact = !value + get editors(): Promise { + + // Check if editors are already loaded + if (this._editors) { + return Promise.resolve(this._editors) + } + + // Call API to get the editors + return fetch(`/api/program/${this.id}/editors`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/program/${this.id}/editors GET): ${error}`) } + ) + + // Parse the data + .then(editor_data => { + + // Get the editors from the environment + this._editors = editor_data.map(user => this.environment.get(user)) + + // Check if client is in sync with the server + const client = JSON.stringify(this._editor_ids.concat().sort()) + const server = JSON.stringify(this._editors.map(editor => editor.id).sort()) + if (client !== server) { + throw new Error('ProgramError: Editors are not in sync with the server') + } + + return this._editors + }) } - get courses() { + /** + * Get a program + * @param environment Environment to fetch the program in + * @param id ID of the program to fetch + * @returns `Promise` The fetched ProgramController + * @throws `APIError` if the API call fails + */ - // Check if the courses are expanded - if (this.compact) throw new Error('Failed to get courses: Program is too compact') - return this._courses + static async get(environment: ControllerEnvironment, id: number): Promise { + + // Check if the program is already loaded + const existing = environment.programs.find(program => program.id === id) + if (existing) return existing + + // Call API to get the program + const response = await fetch(`/api/program/${id}`, { method: 'GET' }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/program/${id} GET): ${error}`) + }) + + // Parse the response + const data = await response.json() as SerializedProgram + return ProgramController.revive(environment, data) } - set courses(value: CourseController[]) { - this._courses = value + /** + * Get all programs + * @param environment Environment to fetch the programs in + * @returns `Promise` All fetched ProgramControllers + * @throws `APIError` if the API call fails + */ + + static async getAll(environment: ControllerEnvironment): Promise { + + // Call API to get all programs + const response = await fetch(`/api/program`, { method: 'GET' }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/program GET): ${error}`) + }) + + // Parse the response + const data = await response.json() as SerializedProgram[] + return data.map(program => environment.get(program)) } - static async create(name: string, depth: number = 0): Promise { - /* Create a new program */ + /** + * Create a new program + * @param environment Environment to create the program in + * @param name Program name + * @returns `Promise` The newly created ProgramController + * @throws `APIError` if the API call fails + */ + + static async create(environment: ControllerEnvironment, name: string): Promise { - // Call the API + // Call API to create a new program const response = await fetch(`/api/program`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }) - + // Check the response .catch(error => { - throw new Error(`Failed to create program: ${error}`) + throw new Error(`APIError (/api/program POST): ${error}`) }) - - // Revive the program - const data: SerializedProgram = await response.json() - return await ProgramController.revive(data, depth) + + // Revive the course + const data = await response.json() as SerializedProgram + return ProgramController.revive(environment, data) } - static async revive(data: SerializedProgram, depth: number = 0): Promise { - /* Load the program from a POGO */ + /** + * Revive a program from serialized data + * @param environment Environment to revive the program in + * @param data Serialized data to revive + * @returns `ProgramController` The revived ProgramController + */ - const program = new ProgramController(data.id, data.name) - await program.expand(depth) - return program + static revive(environment: ControllerEnvironment, data: SerializedProgram): ProgramController { + return new ProgramController(environment, data.id, data.name, data.courses, data.editors, data.admins) } - async expand(depth: number = 1): Promise { - /* Expand the program */ + /** + * Validate the program + * @returns `boolean` Whether the program is valid + */ + + async validate(): Promise { + const validation = new ValidationData() + + if (!this.hasName()) { + validation.add({ + severity: Severity.error, + short: 'Program has no name' + }) + + } else if (await this.hasDuplicateName()) { + validation.add({ + severity: Severity.error, + short: 'Program name is not unique' + }) + } + + if (!this.hasAdmins()) { + validation.add({ + severity: Severity.warning, + short: 'Program has no admins' + }) + } + + return validation + } - // Check if expansion depth is reached - if (depth < 1) return this + /** + * Serialize the program + * @returns `SerializedProgram` Serialized program + */ - // Check if the course is already expanded - if (this.expanded) { - await Promise.all(this.courses.map(course => course.expand(depth - 1))) + reduce(): SerializedProgram { + return { + id: this.id, + name: this.name, + courses: this._course_ids, + admins: this._admin_ids, + editors: this._editor_ids } + } + + /** + * Save the program + * @throws `APIError` if the API call fails + */ - else { + async save(): Promise { + + // Call API to save the program + await fetch(`/api/program`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.reduce()) + }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/program PUT): ${error}`) + }) + } + + /** + * Delete the program + * @throws `APIError` if the API call fails + */ + + async delete(): Promise { + + // Call API to delete the program + await fetch(`/api/program`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: this.id }) + }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/program DELETE): ${error}`) + }) + + // Unassign everywhere (mirroring is not necessary, as this object will be deleted) + this.environment.courses + .filter(course => this._course_ids.includes(course.id)) + .forEach(course => course.unassignFromProgram(this, false)) + + this.environment.users + .filter(user => this._admin_ids.includes(user.id)) + .forEach(user => user.resignAsProgramAdmin(this, false)) + + this.environment.users + .filter(user => this._editor_ids.includes(user.id)) + .forEach(user => user.resignAsProgramEditor(this, false)) + + // Remove from environment + this.environment.remove(this) + } + + /** + * Check if the program has a name + * @returns `boolean` Whether the program has a name + */ + + hasName(): boolean { + return this.name.trim() !== '' + } - this.expanded = true + /** + * Check if the program has a duplicate name + * @returns `Promise` Whether the program has a duplicate name + */ + + async hasDuplicateName(): Promise { + const programs = await ProgramController.getAll(this.environment) + return programs.some(program => program.id !== this.id && program.name === this.name) + } + + /** + * Check if the program has admins + * @returns `boolean` Whether the program has admins + */ + + hasAdmins(): boolean { + return this._admin_ids.length > 0 + } + + /** + * Assign a course to the program + * @param course Course to assign to the program + * @param mirror Whether to mirror the assignment + * @throws `ProgramError` if the course is already assigned to the program + */ - // Call the API - const response = await fetch(`/api/program/${this.id}/course`, { method: 'GET' }) - .catch(error => { throw new Error(`Failed to load course: ${error}`) }) - const data: SerializedCourse[] = await response.json() + assignCourse(course: CourseController, mirror: boolean = true): void { + if (this._course_ids.includes(course.id)) + throw new Error(`ProgramError: Program is already assigned to Course with ID ${course.id}`) + this._course_ids.push(course.id) + this._courses?.push(course) - // Revive the courses - this.courses = await Promise.all(data.map(graph => CourseController.revive(graph, depth - 1))) + if (mirror) { + course.assignToProgram(this, false) } + } + + /** + * Assign a user as an admin of the program. Unassigns the user as an editor if they are one + * @param user User to assign as an admin + * @param mirror Whether to mirror the assignment + * @throws `ProgramError` if the user is already an admin of the program + */ + + assignAdmin(user: UserController, mirror: boolean = true): void { + if (this._admin_ids.includes(user.id)) + throw new Error(`ProgramError: User with ID ${user.id} is already an admin of Program with ID ${this.id}`) + this._admin_ids.push(user.id) + this._admins?.push(user) + + if (this._editor_ids.includes(user.id)) { + this.unassignEditor(user) + } + + if (mirror) { + user.becomeProgramAdmin(this, false) + } + } + + /** + * Assign a user as an editor of the program. Unassigns the user as an admin if they are one + * @param user User to assign as an editor + * @param mirror Whether to mirror the assignment + * @throws `ProgramError` if the user is already an editor of the program + */ + + assignEditor(user: UserController, mirror: boolean = true): void { + if (this._editor_ids.includes(user.id)) + throw new Error(`ProgramError: User with ID ${user.id} is already an editor of Program with ID ${this.id}`) + this._editor_ids.push(user.id) + this._editors?.push(user) + + if (this._admin_ids.includes(user.id)) { + this.unassignAdmin(user) + } + + if (mirror) { + user.becomeProgramEditor(this, false) + } + } + + /** + * Unassign a course from the program + * @param course Course to unassign from the program + * @param mirror Whether to mirror the unassignment + * @throws `ProgramError` if the course is not assigned to the program + */ + + unassignCourse(course: CourseController, mirror: boolean = true): void { + if (!this._course_ids.includes(course.id)) + throw new Error(`ProgramError: Program is not assigned to Course with ID ${course.id}`) + this._course_ids = this._course_ids.filter(id => id !== course.id) + this._courses = this._courses?.filter(course => course.id !== course.id) + + if (mirror) { + course.unassignFromProgram(this, false) + } + } + + /** + * Unassign an admin from the program + * @param user User to unassign as an admin + * @param mirror Whether to mirror the unassignment + * @throws `ProgramError` if the user is not an admin of the program + */ - return this + unassignAdmin(user: UserController, mirror: boolean = true): void { + if (!this._admin_ids.includes(user.id)) + throw new Error(`ProgramError: User with ID ${user.id} is not an admin of Program with ID ${this.id}`) + this._admin_ids = this._admin_ids.filter(id => id !== user.id) + this._admins = this._admins?.filter(admin => admin.id !== user.id) + + if (mirror) { + user.resignAsProgramAdmin(this, false) + } + } + + /** + * Unassign an editor from the program + * @param user User to unassign as an editor + * @param mirror Whether to mirror the unassignment + * @throws `ProgramError` if the user is not an editor of the program + */ + + unassignEditor(user: UserController, mirror: boolean = true): void { + if (!this._editor_ids.includes(user.id)) + throw new Error(`ProgramError: User with ID ${user.id} is not an editor of Program with ID ${this.id}`) + this._editor_ids = this._editor_ids.filter(id => id !== user.id) + this._editors = this._editors?.filter(editor => editor.id !== user.id) + + if (mirror) { + user.resignAsProgramEditor(this, false) + } } } diff --git a/src/lib/scripts/controllers/RelationsController.ts b/src/lib/scripts/controllers/RelationsController.ts index 3696a94..792a926 100644 --- a/src/lib/scripts/controllers/RelationsController.ts +++ b/src/lib/scripts/controllers/RelationsController.ts @@ -225,7 +225,7 @@ class DomainRelationController extends RelationController { short: 'Domain relation is not fully defined', long: 'Both the parent and child domains must be selected', tab: 1, - anchor: this.anchor + uuid: this.anchor }) } @@ -236,7 +236,7 @@ class DomainRelationController extends RelationController { short: 'Domain relation is inconsistent', long: 'The subjects of these domains are not related', tab: 1, - anchor: this.anchor + uuid: this.anchor }) } @@ -369,7 +369,7 @@ class SubjectRelationController extends RelationController { short: 'Subject relation is not fully defined', long: 'Both the parent and child subjects must be selected', tab: 2, - anchor: this.anchor + uuid: this.anchor }) } @@ -380,7 +380,7 @@ class SubjectRelationController extends RelationController { short: 'Subject relation is inconsistent', long: 'The domains of these subjects are not related', tab: 2, - anchor: this.anchor + uuid: this.anchor }) } diff --git a/src/lib/scripts/controllers/UserController.ts b/src/lib/scripts/controllers/UserController.ts index 292fe94..336ce3b 100644 --- a/src/lib/scripts/controllers/UserController.ts +++ b/src/lib/scripts/controllers/UserController.ts @@ -1,34 +1,375 @@ -// Internal imports -import type { SerializedUser } from "$scripts/types" +// Internal dependencies +import { + ControllerEnvironment, + ProgramController, + CourseController +} from '$scripts/controllers' + +import type { SerializedUser } from '$scripts/types' // Exports export { UserController } -// --------------------> Classes +// --------------------> Controller class UserController { + private _program_admins?: ProgramController[] + private _program_editors?: ProgramController[] + private _course_admins?: CourseController[] + private _course_editors?: CourseController[] + constructor( + public environment: ControllerEnvironment, public id: number, + public role: string, public netid: string, public first_name: string, public last_name: string, - public role: string, - public email?: string - ) { } + public email: string, + private _program_admin_ids: number[], + private _program_editor_ids: number[], + private _course_admin_ids: number[], + private _course_editor_ids: number[] + ) { + this.environment.add(this) + } - reduce(): SerializedUser { - /* Reduce the user to a POJO */ + get program_admins(): ProgramController[] { + if (this._program_admins) return this._program_admins + throw new Error('UserError: Program admins not loaded') + } + + get program_editors(): ProgramController[] { + if (this._program_editors) return this._program_editors + throw new Error('UserError: Program editors not loaded') + } + + get course_admins(): CourseController[] { + if (this._course_admins) return this._course_admins + throw new Error('UserError: Course admins not loaded') + } + + get course_editors(): CourseController[] { + if (this._course_editors) return this._course_editors + throw new Error('UserError: Course editors not loaded') + } + + /** + * Create a new user + * @param environment Environment to create the user in + * @param netid User netid + * @param first_name User first name + * @param last_name User last name + * @param email User email + * @returns `Promise` The newly created UserController + * @throws `APIError` if the API call fails + */ + + static async create(environment: ControllerEnvironment, netid: string, first_name: string, last_name: string, email: string): Promise { + + // Call API to create a new user + const response = await fetch(`/api/user`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ netid, first_name, last_name, email }) + }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/user POST): ${error}`) + }) + + // Revive the user + const data = await response.json() as SerializedUser + return UserController.revive(environment, data) + } + + /** + * Revive a user from serialized data + * @param environment Environment to revive the user in + * @param data Serialized data to revive + * @returns `UserController` The revived UserController + */ + + static revive(environment: ControllerEnvironment, data: SerializedUser): UserController { + return new UserController(environment, data.id, data.role, data.netid, data.first_name, data.last_name, data.email, data.program_admin, data.program_editor, data.course_admin, data.course_editor) + } + + /** + * Load the user's properties + * @param properties Properties to load + * @throws `UserError` if a property is already loaded or invalid + */ + + async load(...properties: ('program_admins' | 'program_editors' | 'course_admins' | 'course_editors')[]): Promise { + if (properties.length === 0) { + return await this.load('program_admins', 'program_editors', 'course_admins', 'course_editors') + } + + for (const property of properties) { + switch (property) { + case 'program_admins': + if (this._program_admins) throw new Error('UserError: Program admins already loaded') + this._program_admins = await this.environment.getPrograms(this._program_admin_ids) + break + case 'program_editors': + if (this._program_editors) throw new Error('UserError: Program editors already loaded') + this._program_editors = await this.environment.getPrograms(this._program_editor_ids) + break + + case 'course_admins': + if (this._course_admins) throw new Error('UserError: Course admins already loaded') + this._course_admins = await this.environment.getCourses(this._course_admin_ids) + break + + case 'course_editors': + if (this._course_editors) throw new Error('UserError: Course editors already loaded') + this._course_editors = await this.environment.getCourses(this._course_editor_ids) + break + + default: + throw new Error(`UserError: Property '${property}' is not valid`) + } + } + } + + /** + * Serialize the user + * @returns `SerializedUser` Serialized user + */ + + reduce(): SerializedUser { return { id: this.id, - netid: this.netid, - first_name: this.first_name, - last_name: this.last_name, - role: this.role, - email: this.email + role: this.role, + netid: this.netid, + first_name: this.first_name, + last_name: this.last_name, + email: this.email, + program_admin: this._program_admin_ids, + program_editor: this._program_editor_ids, + course_admin: this._course_admin_ids, + course_editor: this._course_editor_ids + } + } + + /** + * Save the user + * @throws `APIError` if the API call fails + */ + + async save(): Promise { + + // Call API to save the user + await fetch(`/api/user`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.reduce()) + }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/user PUT): ${error}`) + }) + } + + /** + * Delete the user + * @throws `APIError` if the API call fails + */ + + async delete(): Promise { + + // Call API to delete the user + await fetch(`/api/user`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: this.id }) + }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/user DELETE): ${error}`) + }) + + // Resign everywhere (mirroring is not necessary, as this object will be deleted) + const program_admins = await this.environment.getPrograms(this._program_admin_ids, false) + program_admins.forEach(program => program.unassignAdmin(this, false)) + + const program_editors = await this.environment.getPrograms(this._program_editor_ids, false) + program_editors.forEach(program => program.unassignEditor(this, false)) + + const course_admins = await this.environment.getCourses(this._course_admin_ids, false) + course_admins.forEach(course => course.unassignAdmin(this, false)) + + const course_editors = await this.environment.getCourses(this._course_editor_ids, false) + course_editors.forEach(course => course.unassignEditor(this, false)) + + // Remove from environment + this.environment.remove(this) + } + + /** + * Assign a user as an admin of a program. Resigns the user as an editor if they are one + * @param program Program to assign the user as an admin of + * @param mirror Whether to mirror the assignment + * @throws `UserError` if the user is already an admin of the program + */ + + becomeProgramAdmin(program: ProgramController, mirror: boolean = true): void { + if (this._program_admin_ids.includes(program.id)) + throw new Error(`UserError: User is already an admin of Program with ID ${program.id}`) + this._program_admin_ids.push(program.id) + this._program_admins?.push(program) + + if (this._program_editor_ids.includes(program.id)) { + this.resignAsProgramEditor(program) + } + + if (mirror) { + program.assignAdmin(this, false) + } + } + + /** + * Assign a user as an editor of a program. Resigns the user as an admin if they are one + * @param program Program to assign the user as an editor of + * @param mirror Whether to mirror the assignment + * @throws `UserError` if the user is already an editor of the program + */ + + becomeProgramEditor(program: ProgramController, mirror: boolean = true): void { + if (this._program_editor_ids.includes(program.id)) + throw new Error(`UserError: User is already an editor of Program with ID ${program.id}`) + this._program_editor_ids.push(program.id) + this._program_editors?.push(program) + + if (this._program_admin_ids.includes(program.id)) { + this.resignAsProgramAdmin(program) + } + + if (mirror) { + program.assignEditor(this, false) + } + } + + /** + * Assign a user as an admin of a course. Resigns the user as an editor if they are one + * @param course Course to assign the user as an admin of + * @param mirror Whether to mirror the assignment + * @throws `UserError` if the user is already an admin of the course + */ + + becomeCourseAdmin(course: CourseController, mirror: boolean = true): void { + if (this._course_admin_ids.includes(course.id)) + throw new Error(`UserError: User is already an admin of Course with ID ${course.id}`) + this._course_admin_ids.push(course.id) + this._course_admins?.push(course) + + if (this._course_editor_ids.includes(course.id)) { + this.resignAsCourseEditor(course) + } + + if (mirror) { + course.assignAdmin(this, false) + } + } + + /**` + * Assign a user as an editor of a course. Resigns the user as an admin if they are one + * @param course Course to assign the user as an editor of + * @param mirror Whether to mirror the assignment + * @throws `UserError` if the user is already an editor of the course + */ + + becomeCourseEditor(course: CourseController, mirror: boolean = true): void { + if (this._course_editor_ids.includes(course.id)) + throw new Error(`UserError: User is already an editor of Course with ID ${course.id}`) + this._course_editor_ids.push(course.id) + this._course_editors?.push(course) + + if (this._course_admin_ids.includes(course.id)) { + this.resignAsCourseAdmin(course) + } + + if (mirror) { + course.assignEditor(this, false) + } + } + + /** + * Unassign a user as an admin of a program + * @param program Program to unassign the user as an admin of + * @param mirror Whether to mirror the unassignment + * @throws `UserError` if the user is not an admin of the program + */ + + resignAsProgramAdmin(program: ProgramController, mirror: boolean = true): void { + if (!this._program_admin_ids.includes(program.id)) + throw new Error(`UserError: User is not an admin of Program with ID ${program.id}`) + this._program_admin_ids = this._program_admin_ids.filter(id => id !== program.id) + this._program_admins = this._program_admins?.filter(admin => admin.id !== program.id) + + if (mirror) { + program.unassignAdmin(this, false) + } + } + + /** + * Unassign a user as an editor of a program + * @param program Program to unassign the user as an editor of + * @param mirror Whether to mirror the unassignment + * @throws `UserError` if the user is not an editor of the program + */ + + resignAsProgramEditor(program: ProgramController, mirror: boolean = true): void { + if (!this._program_editor_ids.includes(program.id)) + throw new Error(`UserError: User is not an editor of Program with ID ${program.id}`) + this._program_editor_ids = this._program_editor_ids.filter(id => id !== program.id) + this._program_editors = this._program_editors?.filter(editor => editor.id !== program.id) + + if (mirror) { + program.unassignEditor(this, false) + } + } + + /** + * Unassign a user as an admin of a course + * @param course Course to unassign the user as an admin of + * @param mirror Whether to mirror the unassignment + * @throws `UserError` if the user is not an admin of the course + */ + + resignAsCourseAdmin(course: CourseController, mirror: boolean = true): void { + if (!this._course_admin_ids.includes(course.id)) + throw new Error(`UserError: User is not an admin of Course with ID ${course.id}`) + this._course_admin_ids = this._course_admin_ids.filter(id => id !== course.id) + this._course_admins = this._course_admins?.filter(admin => admin.id !== course.id) + + if (mirror) { + course.unassignAdmin(this, false) + } + } + + /** + * Unassign a user as an editor of a course + * @param course Course to unassign the user as an editor of + * @param mirror Whether to mirror the unassignment + * @throws `UserError` if the user is not an editor of the course + */ + + resignAsCourseEditor(course: CourseController, mirror: boolean = true): void { + if (!this._course_editor_ids.includes(course.id)) + throw new Error(`UserError: User is not an editor of Course with ID ${course.id}`) + this._course_editor_ids = this._course_editor_ids.filter(id => id !== course.id) + this._course_editors = this._course_editors?.filter(editor => editor.id !== course.id) + + if (mirror) { + course.unassignEditor(this, false) } } -} \ No newline at end of file +} diff --git a/src/lib/scripts/controllers/index.ts b/src/lib/scripts/controllers/index.ts index ce3b013..a513cbd 100644 --- a/src/lib/scripts/controllers/index.ts +++ b/src/lib/scripts/controllers/index.ts @@ -1,8 +1,9 @@ +export { ControllerEnvironment } from './ControllerEnvironment' export { ProgramController } from './ProgramController' export { CourseController } from './CourseController' export { FieldController, DomainController, SubjectController } from './FieldsController' -export { GraphController, SortOption } from './GraphController' -export { LectureController, LectureSubject } from './LectureController' +export { GraphController } from './GraphController' +export { LectureController } from './LectureController' export { RelationController, DomainRelationController, SubjectRelationController } from './RelationsController' export { UserController } from './UserController' \ No newline at end of file diff --git a/src/lib/scripts/helpers/CourseHelper.ts b/src/lib/scripts/helpers/CourseHelper.ts index 305bc24..424f222 100644 --- a/src/lib/scripts/helpers/CourseHelper.ts +++ b/src/lib/scripts/helpers/CourseHelper.ts @@ -7,60 +7,119 @@ import type { Course as PrismaCourse } from '@prisma/client' import type { SerializedCourse } from '$scripts/types' // Exports -export { create, remove, update, reduce, getAll, getById, getByProgramId } +export { create, remove, update, reduce, getAll, getById } // --------------------> Helper Functions /** - * Retrieves all Courses from the database. - * @returns Array of SerializedCourses + * Creates a Course object in the database. + * @param code `string` + * @param name `string` + * @returns `SerializedCourse` */ -async function getAll(): Promise { +async function create(code: string, name: string): Promise { try { - var courses = await prisma.course.findMany() + var course = await prisma.course.create({ + data: { + code, + name + } + }) } catch (error) { return Promise.reject(error) } - return await Promise.all(courses.map(reduce)) + return await reduce(course) } /** - * Retrieves a Course by its ID. - * @param course_id - * @returns SerializedCourse object + * Removes a Course from the database. + * @param course_id `number` */ -async function getById(course_id: number): Promise { +async function remove(course_id: number): Promise { try { - var course = await prisma.course.findUniqueOrThrow({ + await prisma.course.delete({ where: { id: course_id }}) + } catch (error) { + return Promise.reject(error) + } +} + +/** + * Updates a Course in the database. + * @param data `SerializedCourse` + */ + +async function update(data: SerializedCourse): Promise { + + const graphs = await getGraphIDs(data.id) + const old_graphs = graphs.filter(graph => !data.graphs.includes(graph)) + const new_graphs = data.graphs.filter(graph => !graphs.includes(graph)) + + const programs = await getProgramIDs(data.id) + const old_programs = programs.filter(program => !data.programs.includes(program)) + const new_programs = data.programs.filter(program => !programs.includes(program)) + + try { + await prisma.course.update({ where: { - id: course_id + id: data.id + }, + data: { + name: data.name, + code: data.code, + graphs: { + connect: new_graphs.map(graph => ({ id: graph })), + disconnect: old_graphs.map(graph => ({ id: graph })) + }, + programs: { + connect: new_programs.map(program => ({ id: program })), + disconnect: old_programs.map(program => ({ id: program })) + } } }) } catch (error) { return Promise.reject(error) } +} - return await reduce(course) +/** + * Reduces a Graph to a SerializedGraph. + * @param graph `PrismaGraph` + * @returns `SerializedGraph` + */ + +async function reduce(course: PrismaCourse): Promise { + const [graphs, admins, editors, programs] = await Promise.all([ + getGraphIDs(course.id), + getAdminIds(course.id), + getEditorIds(course.id), + getProgramIDs(course.id) + ]) + + return { + id: course.id, + code: course.code, + name: course.name, + archived: true, // TODO Implement archived field + graphs, + admins, + editors, + programs + } } /** - * Retrieves all Courses associated with a Program. - * @param program_id The ID of the Program - * @returns Array of SerializedCourses + * Retrieves all Courses from the database. + * @returns `SerializedCourse[]` */ -async function getByProgramId(program_id: number): Promise { +async function getAll(): Promise { try { - var courses = await prisma.course.findMany({ - where: { - programId: program_id - } - }) + var courses = await prisma.course.findMany() } catch (error) { return Promise.reject(error) } @@ -69,81 +128,91 @@ async function getByProgramId(program_id: number): Promise { } /** - * Creates a Course object in the database. - * @param program_id The ID of the Program the Course belongs to - * @param code The code of the Course - * @param name The name of the Course - * @returns SerializedCourse object + * Retrieves Courses by ID + * @param course_id `number` + * @returns `SerializedCourse` */ -async function create(program_id: number, code: string, name: string): Promise { +async function getById(course_id: number): Promise { try { - var course = await prisma.course.create({ - data: { - code, - name, - program: { - connect: { - id: program_id - } - } - } - }) + var courses = await prisma.course.findUniqueOrThrow({ where: { id: course_id }}) } catch (error) { return Promise.reject(error) } - return await reduce(course) + return await reduce(courses) } /** - * Removes a Course from the database. - * @param course_id ID of the Course to remove + * Retrieves Course graph IDs. + * @param course_id `number` + * @returns `number[]` */ -async function remove(course_id: number): Promise { +async function getGraphIDs(course_id: number): Promise { try { - await prisma.course.delete({ + var graphs = await prisma.course.findUniqueOrThrow({ where: { id: course_id + }, + select: { + graphs: { + select: { + id: true + } + } } }) } catch (error) { return Promise.reject(error) } + + return graphs.graphs.map(graph => graph.id) } /** - * Updates a Course in the database. - * @param data SerializedCourse object + * Retrieves Course program IDs. + * @param course_id `number` + * @returns `number[]` */ -async function update(data: SerializedCourse): Promise { +async function getProgramIDs(course_id: number): Promise { try { - await prisma.course.update({ + var programs = await prisma.program.findMany({ where: { - id: data.id + courses: { + some: { + id: course_id + } + } }, - data: { - name: data.name, - code: data.code + select: { + id: true } }) } catch (error) { return Promise.reject(error) } + + return programs.map(program => program.id) } /** - * Reduces a Graph to a SerializedGraph. - * @param graph PrismaGraph object - * @returns SerializedGraph object + * Retrieves Course admin User IDs. + * @param course_id `number` + * @returns `number[]` */ -async function reduce(course: PrismaCourse): Promise { - return { - id: course.id, - code: course.code, - name: course.name - } -} \ No newline at end of file +async function getAdminIds(course_id: number): Promise { + return [] // TODO Implement getAdminIds +} + +/** + * Retrieves Course editor User IDs. + * @param course_id `number` + * @returns `number[]` + */ + +async function getEditorIds(course_id: number): Promise { + return [] // TODO Implement getEditorIds +} diff --git a/src/lib/scripts/helpers/GraphHelper.ts b/src/lib/scripts/helpers/GraphHelper.ts index e96a54a..b9738d9 100644 --- a/src/lib/scripts/helpers/GraphHelper.ts +++ b/src/lib/scripts/helpers/GraphHelper.ts @@ -7,24 +7,28 @@ import type { Graph as PrismaGraph } from '@prisma/client' import type { SerializedGraph } from '$scripts/types' // Exports -export { create, remove, update, reduce, getByCourseId, getById } +export { create, remove, update, reduce, getAll, getById } // --------------------> Helper Functions /** - * Retrieves all Graphs associated with a Course. - * @param course_id - * @returns Array of SerializedGraph objects + * Creates a Graph object in the database. + * @param course_id `number` + * @param name `string` + * @returns `SerializedGraph` */ -async function getByCourseId(course_id: number): Promise { +async function create(course_id: number, name: string): Promise { try { - var graphs = await prisma.graph.findMany({ - where: { + var graph = await prisma.graph.create({ + data: { + name, course: { - id: course_id + connect: { + id: course_id + } } } }) @@ -32,63 +36,147 @@ async function getByCourseId(course_id: number): Promise { return Promise.reject(error) } - return await Promise.all(graphs.map(reduce)) + return await reduce(graph) } /** - * Retrieves a Graph by its ID. - * @param graph_id - * @returns SerializedGraph object + * Removes a Graph from the database. + * @param graph_ids `number[]` */ -async function getById(graph_id: number): Promise { +async function remove(...graph_ids: number[]): Promise { try { - var graph = await prisma.graph.findUniqueOrThrow({ + await prisma.graph.deleteMany({ where: { - id: graph_id + id: { + in: graph_ids + } } }) } catch (error) { return Promise.reject(error) } - - return await reduce(graph) } /** - * Creates a Graph object in the database. - * @param course_id The id of the Course to which the Graph belongs - * @param name The name of the Graph - * @returns SerializedGraph object + * Updates a Graph in the database. + * @param data 'SerializedGraph' */ -async function create(course_id: number, name: string): Promise { +async function update(data: SerializedGraph): Promise { + + const course = await getCourseID(data.id) + const course_data = course === data.course ? {} : { + connect : { id: data.course }, + disconnect : { id: course } + } + + const domains = await getDomainIDs(data.id) + const old_domains = domains.filter(domain => !data.domains.includes(domain)) + const new_domains = data.domains.filter(domain => !domains.includes(domain)) + + const subjects = await getSubjectIDs(data.id) + const old_subjects = subjects.filter(subject => !data.subjects.includes(subject)) + const new_subjects = data.subjects.filter(subject => !subjects.includes(subject)) + + const lectures = await getLectureIDs(data.id) + const old_lectures = lectures.filter(lecture => !data.lectures.includes(lecture)) + const new_lectures = data.lectures.filter(lecture => !lectures.includes(lecture)) + try { - var graph = await prisma.graph.create({ + await prisma.graph.update({ + where: { + id: data.id, + }, data: { - name, - course: { - connect: { - id: course_id - } + name: data.name, + course: course_data, + domains: { + connect: new_domains.map(domain => ({ id: domain })), + disconnect: old_domains.map(domain => ({ id: domain })) + }, + subjects: { + connect: new_subjects.map(subject => ({ id: subject })), + disconnect: old_subjects.map(subject => ({ id: subject })) + }, + lectures: { + connect: new_lectures.map(lecture => ({ id: lecture })), + disconnect: old_lectures.map(lecture => ({ id: lecture })) } } }) } catch (error) { return Promise.reject(error) } +} - return await reduce(graph) +/** + * Reduces a Graph to a SerializedGraph. + * @param graph `PrismaGraph` + * @returns `SerializedGraph` + */ + +async function reduce(graph: PrismaGraph): Promise { + return { + id: graph.id, + name: graph.name, + course: graph.courseId, + domains: await getDomainIDs(graph.id), + subjects: await getSubjectIDs(graph.id), + lectures: await getLectureIDs(graph.id) + } } /** - * Removes a Graph from the database. - * @param graph_id ID of the Graph to remove + * Retrieves all Graphs from the database. + * @returns `SerializedGraph[]` */ -async function remove(graph_id: number): Promise { +async function getAll(): Promise { try { - await prisma.graph.delete({ + var graphs = await prisma.graph.findMany() + } catch (error) { + return Promise.reject(error) + } + + return await Promise.all(graphs.map(reduce)) +} + +/** + * Retrieves Groups by ID + * @param group_ids `number[]` + * @returns `SerializedGraph[]` or `SerializedGraph` if a single ID is provided + */ + +async function getById(...graph_ids: number[]): Promise { + try { + var graphs = await prisma.graph.findMany({ + where: { + id: { + in: graph_ids + } + } + }) + } catch (error) { + return Promise.reject(error) + } + + if (graph_ids.length === 1) { + return await reduce(graphs[0]) + } else { + return await Promise.all(graphs.map(reduce)) + } +} + +/** + * Retrieves Graph course ID. + * @param graph_id `number` + * @returns `number` + */ + +async function getCourseID(graph_id: number): Promise { + try { + var graph = await prisma.graph.findUniqueOrThrow({ where: { id: graph_id } @@ -96,37 +184,66 @@ async function remove(graph_id: number): Promise { } catch (error) { return Promise.reject(error) } + + return graph.courseId } /** - * Updates a Graph in the database. - * @param data SerializedGraph object + * Retrieves Graph domain IDs. + * @param graph_id `number` + * @returns `number[]` */ -async function update(data: SerializedGraph): Promise { +async function getDomainIDs(graph_id: number): Promise { try { - await prisma.graph.update({ + var domains = await prisma.domain.findMany({ where: { - id: data.id - }, - data: { - name: data.name + graphId: graph_id } }) } catch (error) { return Promise.reject(error) } + + return domains.map(domain => domain.id) } /** - * Reduces a Graph to a SerializedGraph. - * @param graph PrismaGraph object - * @returns SerializedGraph object + * Retrieves Graph subject IDs. + * @param graph_id `number` + * @returns `number[]` */ -async function reduce(graph: PrismaGraph): Promise { - return { - id: graph.id, - name: graph.name +async function getSubjectIDs(graph_id: number): Promise { + try { + var subjects = await prisma.subject.findMany({ + where: { + graphId: graph_id + } + }) + } catch (error) { + return Promise.reject(error) + } + + return subjects.map(subject => subject.id) +} + +/** + * Retrieves Graph lecture IDs. + * @param graph_id `number` + * @returns `number[]` + */ + +async function getLectureIDs(graph_id: number): Promise { + try { + var lectures = await prisma.lecture.findMany({ + where: { + graphId: graph_id + } + }) + } catch (error) { + return Promise.reject(error) } + + return lectures.map(lecture => lecture.id) } \ No newline at end of file diff --git a/src/lib/scripts/helpers/ProgramHelper.ts b/src/lib/scripts/helpers/ProgramHelper.ts index e84b63b..48ef7b2 100644 --- a/src/lib/scripts/helpers/ProgramHelper.ts +++ b/src/lib/scripts/helpers/ProgramHelper.ts @@ -4,58 +4,194 @@ import prisma from '$lib/server/prisma' import type { Program as PrismaProgram } from '@prisma/client' // Internal imports -import type { SerializedProgram } from '$lib/scripts/types' +import { CourseHelper } from '$scripts/helpers' +import type { + SerializedProgram, + SerializedCourse +} from '$scripts/types' // Exports -export { create, reduce, getAll } +export { create, remove, update, reduce, getAll, getById, getCourses } + // --------------------> Helper Functions /** - * Retrieves all Programs from the database. - * @returns Array of SerializedPrograms + * Creates a new Program in the database. + * @param name `string` + * @returns `SerializedProgram` */ -async function getAll(): Promise { +async function create(name: string): Promise { try { - var courses = await prisma.program.findMany() + var program = await prisma.program.create({ data: { name }}) } catch (error) { return Promise.reject(error) } - return await Promise.all(courses.map(reduce)) + return await reduce(program) } /** - * Creates a new Program in the database. - * @param name string - * @returns SerializedProgram + * Removes Programs from the database. + * @param program_id `number` */ -async function create(name: string): Promise { - try { - var program = await prisma.program.create({ - data: { - name - } - }) - } catch (error) { - return Promise.reject(error) - } +async function remove(program_id: number): Promise { + try { + await prisma.program.delete({ where: { id: program_id }}) + } catch (error) { + return Promise.reject(error) + } +} + +/** + * Updates a Program in the database. + * @param data `SerializedProgram` + */ + +async function update(data: SerializedProgram): Promise { + const courses = await getCourses(data.id) + const old_courses = courses + .filter(course => !data.courses.includes(course.id)) + .map(course => ({ id: course.id })) + + const new_courses = data.courses + .filter(id => !courses.some(course => course.id === id)) + .map(id => ({ id })) - return await reduce(program) + try { + await prisma.program.update({ + where: { + id: data.id + }, + data: { + name: data.name, + courses: { + disconnect: old_courses, + connect: new_courses + } + } + }) + } catch (error) { + return Promise.reject(error) + } } /** * Reduces a Program to a SerializedProgram. - * @param program PrismaProgram - * @returns SerializedProgram + * @param program `PrismaProgram` + * @returns `SerializedProgram` */ async function reduce(program: PrismaProgram): Promise { + const [courses, admins, editors] = await Promise.all([ + getCourses(program.id).then(courses => courses.map(course => course.id)), + getAdminIds(program.id), + getEditorIds(program.id) + ]) + return { id: program.id, - name: program.name + name: program.name, + courses, + admins, + editors + } +} + +/** + * Retrieves all Programs from the database. + * @returns `SerializedPrograms[]` + */ + +async function getAll(): Promise { + try { + var courses = await prisma.program.findMany() + } catch (error) { + return Promise.reject(error) + } + + return await Promise.all(courses.map(reduce)) +} + +/** + * Retrieves Programs by ID + * @param program_id `number` + * @returns `SerializedProgram` + */ + +async function getById(program_id: number): Promise { + try { + var program = await prisma.program.findUniqueOrThrow({ where: { id: program_id }}) + } catch (error) { + return Promise.reject(error) + } + + return await reduce(program) +} + +/** + * Retrieves Program course IDs. + * @param program_id `number` + * @returns `number[]` + */ + +async function getCourses(program_id: number): Promise { + try { + var data = await prisma.program.findUniqueOrThrow({ + where: { + id: program_id + }, + select: { + courses: true + } + }) + + } catch (error) { + return Promise.reject(error) } + + return await Promise.all(data.courses.map(CourseHelper.reduce)) +} + +/** + * Retrieves Program admin User IDs. + * @param program_id `number` + * @returns `number[]` + */ + +async function getAdminIds(program_id: number): Promise { + try { + var data = await prisma.program.findUniqueOrThrow({ + where: { + id: program_id + }, + + select: { + coordinators: { + select: { + id: true + } + } + } + }) + } catch (error) { + return Promise.reject(error) + } + + return data.coordinators.map(admin => admin.id) +} + +/** + * Retrieves Program editor User IDs. + * @param program_id `number` + * @returns `number[]` + */ + +async function getEditorIds(program_id: number): Promise { + + // TODO i do not know how to distinguish between admin and editor, so everyone is an admin for now + return [] } \ No newline at end of file diff --git a/src/lib/scripts/types/Dropdown.ts b/src/lib/scripts/types/Dropdown.ts new file mode 100644 index 0000000..655a5b7 --- /dev/null +++ b/src/lib/scripts/types/Dropdown.ts @@ -0,0 +1,16 @@ + +// Internal dependencies +import { ValidationData } from '$scripts/validation' + +// Exports +export type { DropdownOption } + + +// --------------------> Types + + +type DropdownOption = { + value: T + label: string + validation: ValidationData +} \ No newline at end of file diff --git a/src/lib/scripts/types/Serialized.ts b/src/lib/scripts/types/Serialized.ts new file mode 100644 index 0000000..a74326f --- /dev/null +++ b/src/lib/scripts/types/Serialized.ts @@ -0,0 +1,112 @@ + +// Exports +export { instanceOfSerializedUser, instanceOfSerializedProgram, instanceOfSerializedCourse, instanceOfSerializedGraph, instanceOfSerializedDomain, instanceOfSerializedSubject, instanceOfSerializedLecture } +export type { SerializedUser, SerializedProgram, SerializedCourse, SerializedGraph, SerializedDomain, SerializedSubject, SerializedLecture } + + +// --------------------> Types + + +type SerializedUser = { + id: number, // Unique identifier + role: string, // User role + netid: string, // User netid + first_name: string, // User first name + last_name: string, // User last name + email: string, // User email + program_admin: number[], // List of Program IDs this User is an admin of + program_editor: number[], // List of Program IDs this User is an editor of + course_admin: number[], // List of Course IDs this User is an admin of + course_editor: number[] // List of Course IDs this User is an editor of +} + +type SerializedProgram = { + id: number, // Unique identifier + name: string, // Program name + courses: number[], // List of course IDs + admins: number[], // List of admin User IDs + editors: number[] // List of editor User IDs +} + +type SerializedCourse = { + id: number, // Unique identifier + code: string, // Course code + name: string, // Course name + archived: boolean, // Whether the course is archived + graphs: number[], // List of graph IDs + admins: number[], // List of admin User IDs + editors: number[], // List of editor User IDs + programs: number[] // List of program IDs this Course belongs to +} + +type SerializedGraph = { + id: number, // Unique identifier + name: string, // Graph name + course: number, // Course ID this Graph belongs to + domains: number[], // List of domain IDs + subjects: number[], // List of subject IDs + lectures: number[] // List of lecture IDs +} + +type SerializedDomain = { + id: number, // Unique identifier + x: number, // X-coordinate of the Domain + y: number, // Y-coordinate of the Domain + name: string, // Domain name + style: string | null, // Domain style + graph: number, // Graph ID this Domain belongs to + parents: number[], // List of parent Domain IDs + children: number[] // List of child Domain IDs + subjects: number[] // List of subject IDs +} + +type SerializedSubject = { + id: number, // Unique identifier + x: number, // X-coordinate of the Subject + y: number, // Y-coordinate of the Subject + name: string, // Subject name + domain: number | null, // Domain ID this Subject belongs to + graph: number, // Graph ID this Subject belongs to + parents: number[], // List of parent Subject IDs + children: number[], // List of child Subject IDs + lectures: number[] // List of lecture IDs this Subject belongs to +} + +type SerializedLecture = { + id: number, // Unique identifier + name: string, // Lecture name + graph: number, // Graph ID this Lecture belongs to + subjects: number[] // List of subject IDs +} + + +// --------------------> Instance Methods + + +function instanceOfSerializedUser(object: any): object is SerializedUser { + return 'id' in object && 'role' in object && 'netid' in object && 'first_name' in object && 'last_name' in object && 'email' in object && 'program_admin' in object && 'program_editor' in object && 'course_admin' in object && 'course_editor' in object +} + +function instanceOfSerializedProgram(object: any): object is SerializedProgram { + return 'id' in object && 'name' in object && 'courses' in object && 'admins' in object && 'editors' in object +} + +function instanceOfSerializedCourse(object: any): object is SerializedCourse { + return 'id' in object && 'code' in object && 'name' in object && 'archived' in object && 'graphs' in object && 'admins' in object && 'editors' in object && 'programs' in object +} + +function instanceOfSerializedGraph(object: any): object is SerializedGraph { + return 'id' in object && 'name' in object && 'course' in object && 'domains' in object && 'subjects' in object && 'lectures' in object +} + +function instanceOfSerializedDomain(object: any): object is SerializedDomain { + return 'id' in object && 'x' in object && 'y' in object && 'name' in object && 'style' in object && 'graph' in object && 'parents' in object && 'children' in object && 'subjects' in object +} + +function instanceOfSerializedSubject(object: any): object is SerializedSubject { + return 'id' in object && 'x' in object && 'y' in object && 'name' in object && 'domain' in object && 'graph' in object && 'parents' in object && 'children' in object && 'lectures' in object +} + +function instanceOfSerializedLecture(object: any): object is SerializedLecture { + return 'id' in object && 'name' in object && 'graph' in object && 'subjects' in object +} \ No newline at end of file diff --git a/src/lib/scripts/types/SerializedTypes.ts b/src/lib/scripts/types/SerializedTypes.ts deleted file mode 100644 index c8a3183..0000000 --- a/src/lib/scripts/types/SerializedTypes.ts +++ /dev/null @@ -1,58 +0,0 @@ - -// Exports -export type { SerializedProgram, SerializedCourse, SerializedGraph, SerializedDomain, SerializedSubject, SerializedLecture, SerializedUser } - - -// --------------------> Types - - -type SerializedProgram = { - id: number, - name: string -} - -type SerializedCourse = { - id: number, - code: string, - name: string -} - -type SerializedGraph = { - id: number, - name: string -} - -type SerializedDomain = { - id: number, - x: number, - y: number, - name?: string, - style?: string, - parents: number[], - children: number[] -} - -type SerializedSubject = { - id: number, - x: number, - y: number, - name?: string, - domain?: number, - parents: number[], - children: number[] -} - -type SerializedLecture = { - id: number, - name?: string, - subjects: number[] -} - -type SerializedUser = { - id: number, - netid: string, - first_name: string, - last_name: string, - role: string, - email?: string -} \ No newline at end of file diff --git a/src/lib/scripts/types/index.ts b/src/lib/scripts/types/index.ts index 482228d..fa2baba 100644 --- a/src/lib/scripts/types/index.ts +++ b/src/lib/scripts/types/index.ts @@ -1,2 +1,5 @@ -export type { SerializedProgram, SerializedCourse, SerializedGraph, SerializedDomain, SerializedSubject, SerializedLecture, SerializedUser } from './SerializedTypes' +export type { DropdownOption } from './Dropdown' +export type { SerializedUser, SerializedProgram, SerializedCourse, SerializedGraph, SerializedDomain, SerializedSubject, SerializedLecture } from './Serialized' + +export { instanceOfSerializedUser, instanceOfSerializedProgram, instanceOfSerializedCourse, instanceOfSerializedGraph, instanceOfSerializedDomain, instanceOfSerializedSubject, instanceOfSerializedLecture } from './Serialized' diff --git a/src/lib/scripts/validation.ts b/src/lib/scripts/validation.ts index 3f2566c..6a7cb28 100644 --- a/src/lib/scripts/validation.ts +++ b/src/lib/scripts/validation.ts @@ -18,7 +18,7 @@ type Violation = { short: string, long?: string, tab?: number, - anchor?: string + uuid?: string } class ValidationData { @@ -51,27 +51,27 @@ class ValidationData { return new ValidationData() } - static warning(short: string, long?: string, tab?: number, anchor?: string) { + static warning(short: string, long?: string, tab?: number, uuid?: string) { const data = new ValidationData() data.add({ severity: Severity.warning, short, long, tab, - anchor + uuid }) return data } - static error(short: string, long?: string, tab?: number, anchor?: string) { + static error(short: string, long?: string, tab?: number, uuid?: string) { const data = new ValidationData() data.add({ severity: Severity.error, short, long, tab, - anchor + uuid }) return data diff --git a/src/routes/api/course/+server.ts b/src/routes/api/course/+server.ts index df7a87a..22a101d 100644 --- a/src/routes/api/course/+server.ts +++ b/src/routes/api/course/+server.ts @@ -1,16 +1,46 @@ +// Internal dependencies import { CourseHelper } from '$scripts/helpers' -import type { SerializedCourse } from '$scripts/types' +import { instanceOfSerializedCourse } from '$scripts/types' + +// Exports +export { POST, PUT, GET } + +// --------------------> API Endpoints + + +/** + * API endpoint for creating a new Course in the database. + * @body `{ code: string, name: string }` + * @returns `SerializedCourse` + */ + +async function POST({ request }) { + + // Retrieve data + const { code, name } = await request.json() + if (!code || !name) return new Response('Missing code or name', { status: 400 }) + + // Create course + return await CourseHelper.create(code, name) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} /** * API endpoint for updating a Course in the database. - * @param request PUT request containing a SerializedCourse + * @body `SerializedCourse` */ -export async function PUT({ request }) { +async function PUT({ request }) { // Retrieve data - const data: SerializedCourse = await request.json() + const data = await request.json() + if (!instanceOfSerializedCourse(data)) { + return new Response('Invalid SerializedCourse', { status: 400 }) + } // Update course return await CourseHelper.update(data) @@ -22,15 +52,13 @@ export async function PUT({ request }) { /** * API endpoint for requesting all Courses in the database. - * @returns Array of SerializedCourses + * @returns `SerializedCourses[]` */ -export async function GET() { - - // Retrieve courses +async function GET() { return await CourseHelper.getAll() .then( - courses => new Response(JSON.stringify(courses), { status: 200 }), + data => new Response(JSON.stringify(data), { status: 200 }), error => new Response(error, { status: 400 }) ) } \ No newline at end of file diff --git a/src/routes/api/course/[id]/+server.ts b/src/routes/api/course/[id]/+server.ts index 5bb1b77..061166d 100644 --- a/src/routes/api/course/[id]/+server.ts +++ b/src/routes/api/course/[id]/+server.ts @@ -1,15 +1,48 @@ +// Internal dependencies import { CourseHelper } from '$scripts/helpers' +// Exports +export { GET, DELETE } + + +// --------------------> API Endpoints + + /** - * API endpoint for deleting a Subject from the database. + * API endpoint for fetching a Course from the database. + * @returns `SerializedCourse` */ -export async function DELETE({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to delete course: Invalid course ID', { status: 400 }) +async function GET({ params }) { + + // Retrieve data + const id = Number(params.id) + if (!id || isNaN(id)) { + return new Response('Missing ID', { status: 400 }) + } + + // Delete the course + return await CourseHelper.getById(id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * API endpoint for deleting a Course from the database. + */ + +async function DELETE({ params }) { + + // Retrieve data const id = Number(params.id) + if (!id || isNaN(id)) { + return new Response('Missing ID', { status: 400 }) + } + // Delete the course return await CourseHelper.remove(id) .then( () => new Response(null, { status: 200 }), diff --git a/src/routes/api/course/[id]/graph/+server.ts b/src/routes/api/course/[id]/graph/+server.ts deleted file mode 100644 index b26191a..0000000 --- a/src/routes/api/course/[id]/graph/+server.ts +++ /dev/null @@ -1,43 +0,0 @@ - -import { GraphHelper } from '$scripts/helpers' - -/** - * API endpoint for creating a Graph in the database. - * @params POST request with JSON body { name: string } - * @returns SerializedGraph - */ - -export async function POST({ request, params }) { - // Retrieve data - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to create graph: Invalid course ID', { status: 400 }) - const course_id = Number(params.id) - - const { name } = await request.json() - if (!name) return new Response('Failed to create graph: Missing name', { status: 400 }) - - // Create the graph - return await GraphHelper.create(course_id, name) - .then( - data => new Response(JSON.stringify(data), { status: 201 }), - error => new Response(error, { status: 400 }) - ) -} - -/** - * API endpoint for requesting all Graphs from the database related to this course. - * @returns Array of SerializedGraph - */ - -export async function GET({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to get graphs: Invalid course ID', { status: 400 }) - const id = Number(params.id) - - // Update domain - return await GraphHelper.getByCourseId(id) - .then( - graphs => new Response(JSON.stringify(graphs), { status: 200 }), - error => new Response(error, { status: 500 }) - ) -} \ No newline at end of file diff --git a/src/routes/api/domain/+server.ts b/src/routes/api/domain/+server.ts index ee92cff..5f9b027 100644 --- a/src/routes/api/domain/+server.ts +++ b/src/routes/api/domain/+server.ts @@ -2,8 +2,48 @@ import { DomainHelper } from '$scripts/helpers' /** - * An API endpoint for updating a Domain object in the database. - * @param request PUT request containing a SerializedDomain object + * API endpoint for creating a new Domain in the database. + * @body `{ graph_id: number }` + * @returns `SerializedDomain` + */ + +export async function POST({ request }) { + + // Retrieve data + const { graph_id } = await request.json() + if (!graph_id || isNaN(graph_id)) + return new Response('Failed to create Domain: missing Graph ID', { status: 400 }) + + // Create graph + return await DomainHelper.create(graph_id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * API endpoint for deleting Domains from the database. + * @body `{ ids: number[] }` + */ + +export async function DELETE({ request }) { + + // Retrieve data + const { ids } = await request.json() + if (!ids) return new Response('Failed to remove Domains: Missing IDs', { status: 400 }) + + // Remove graphs + return await DomainHelper.remove(ids) + .then( + () => new Response(null, { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * An API endpoint for updating a Domain in the database. + * @body `SerializedDomain` */ export async function PUT({ request }) { @@ -14,7 +54,7 @@ export async function PUT({ request }) { // Update domain return await DomainHelper.update(data) .then( - () => new Response('Domain updated', { status: 200 }), + () => new Response(null, { status: 200 }), error => new Response(error, { status: 400 }) ) } \ No newline at end of file diff --git a/src/routes/api/domain/[id]/+server.ts b/src/routes/api/domain/[id]/+server.ts deleted file mode 100644 index 29a722b..0000000 --- a/src/routes/api/domain/[id]/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ - -import { DomainHelper } from '$scripts/helpers' - -/** - * API endpoint for deleting a Subject from the database. - */ - -export async function DELETE({ params }) { - const id = Number(params.id) - - return await DomainHelper.remove(id) - .then( - () => new Response(null, { status: 200 }), - error => new Response(error, { status: 400 }) - ) -} \ No newline at end of file diff --git a/src/routes/api/graph/+server.ts b/src/routes/api/graph/+server.ts index f08725c..1885de5 100644 --- a/src/routes/api/graph/+server.ts +++ b/src/routes/api/graph/+server.ts @@ -2,6 +2,46 @@ import { GraphHelper } from '$scripts/helpers' import type { SerializedGraph } from '$scripts/types' +/** + * API endpoint for creating a new Graph in the database. + * @body `{ course_id: number, name: string }` + * @returns `SerializedGraph` + */ + +export async function POST({ request }) { + + // Retrieve data + const { course_id, name } = await request.json() + if (!course_id || isNaN(course_id) || !name) + return new Response('Failed to create graph: Missing course or name', { status: 400 }) + + // Create graph + return await GraphHelper.create(course_id, name) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * API endpoint for deleting Graphs from the database. + * @body `{ ids: number[] }` + */ + +export async function DELETE({ request }) { + + // Retrieve data + const { ids } = await request.json() + if (!ids) return new Response('Failed to remove graph: Missing IDs', { status: 400 }) + + // Remove graphs + return await GraphHelper.remove(ids) + .then( + () => new Response(null, { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + /** * API endpoint for updating a Graph in the database. * @param request PUT request containing a SerializedGraph @@ -18,4 +58,34 @@ export async function PUT({ request }) { () => new Response(null, { status: 200 }), error => new Response(error, { status: 400 }) ) +} + +/** + * API endpoint for requesting Graphs in the database. + * @body `{ ids: number[] }` If not provided, all graphs are returned + * @returns `SerializedGraph[]` or `SerializedGraph` if a single ID is provided + */ + +export async function GET({ request }) { + + // Retrieve data + const { ids } = await request.json() + + // Retrieve graphs by ID + if (ids) { + return await GraphHelper.getById(...ids) + .then( + graphs => new Response(JSON.stringify(graphs), { status: 200 }), + error => new Response(error, { status: 400 }) + ) + } + + // Retrieve all programs + else { + return await GraphHelper.getAll() + .then( + graphs => new Response(JSON.stringify(graphs), { status: 200 }), + error => new Response(error, { status: 400 }) + ) + } } \ No newline at end of file diff --git a/src/routes/api/graph/[id]/+server.ts b/src/routes/api/graph/[id]/+server.ts deleted file mode 100644 index ed1fc34..0000000 --- a/src/routes/api/graph/[id]/+server.ts +++ /dev/null @@ -1,18 +0,0 @@ - -import { GraphHelper } from '$scripts/helpers' - -/** - * API endpoint for deleting a Graph from the database. - */ - -export async function DELETE({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to delete graph: Invalid graph ID', { status: 400 }) - const id = Number(params.id) - - return await GraphHelper.remove(id) - .then( - () => new Response(null, { status: 200 }), - error => new Response(error, { status: 400 }) - ) -} \ No newline at end of file diff --git a/src/routes/api/graph/[id]/domain/+server.ts b/src/routes/api/graph/[id]/domain/+server.ts deleted file mode 100644 index 90cbea3..0000000 --- a/src/routes/api/graph/[id]/domain/+server.ts +++ /dev/null @@ -1,36 +0,0 @@ - -import { DomainHelper } from '$scripts/helpers' - -/** - * API endpoint for creating a Domain in the database. - * @returns Serialized Domain - */ - -export async function POST({ params }) { - if (!params.id || isNaN(Number(params.id)) ) - return new Response('Failed to create domain: Invalid graph ID', { status: 400 }) - const id = Number(params.id) - - return await DomainHelper.create(id) - .then( - data => new Response(JSON.stringify(data), { status: 201 }), - error => new Response(error, { status: 400 }) - ) -} - -/** - * API endpoint for retrieving all Domains associated with a Graph. - * @returns Array of SerializedDomains - */ - -export async function GET({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to get domains: Invalid graph ID', { status: 400 }) - const id = Number(params.id) - - return await DomainHelper.getByGraphId(id) - .then( - data => new Response(JSON.stringify(data), { status: 200 }), - error => new Response(error, { status: 400 }) - ) -} \ No newline at end of file diff --git a/src/routes/api/graph/[id]/lecture/+server.ts b/src/routes/api/graph/[id]/lecture/+server.ts deleted file mode 100644 index 14ab338..0000000 --- a/src/routes/api/graph/[id]/lecture/+server.ts +++ /dev/null @@ -1,36 +0,0 @@ - -import { LectureHelper } from '$scripts/helpers' - -/** - * API endpoint for creating a Lecture in the database. - * @returns SerializedLecture - */ - -export async function POST({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to create lecture: Invalid graph ID', { status: 400 }) - const id = Number(params.id) - - return await LectureHelper.create(id) - .then( - data => new Response(JSON.stringify(data), { status: 201 }), - error => new Response(error, { status: 400 }) - ) -} - -/** - * API endpoint for retrieving all Lectures associated with a Graph. - * @returns Array of SerializedLectures - */ - -export async function GET({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to get lectures: Invalid graph ID', { status: 400 }) - const id = Number(params.id) - - return await LectureHelper.getByGraphId(id) - .then( - data => new Response(JSON.stringify(data), { status: 200 }), - error => new Response(error, { status: 400 }) - ) -} \ No newline at end of file diff --git a/src/routes/api/graph/[id]/subject/+server.ts b/src/routes/api/graph/[id]/subject/+server.ts deleted file mode 100644 index 6ca8ded..0000000 --- a/src/routes/api/graph/[id]/subject/+server.ts +++ /dev/null @@ -1,37 +0,0 @@ - -import { SubjectHelper } from '$scripts/helpers' - -/** - * API endpoint for creating a Subject in the database. - * @returns SerializedSubject - */ - -export async function POST({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to create subject: Invalid graph ID', { status: 400 }) - const id = Number(params.id) - - return await SubjectHelper.create(id) - .then( - data => new Response(JSON.stringify(data), { status: 201 }), - error => new Response(error, { status: 400 }) - ) -} - -/** - * API endpoint for retrieving all Subjects associated with a Graph. - * @returns Array of SerializedSubjects - */ - -export async function GET({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to get subjects: Invalid graph ID', { status: 400 }) - const id = Number(params.id) - - return await SubjectHelper.getByGraphId(id) - .then( - data => new Response(JSON.stringify(data), { status: 200 }), - error => new Response(error, { status: 400 }) - ) -} - diff --git a/src/routes/api/program/+server.ts b/src/routes/api/program/+server.ts index 40a6196..fb0d9b2 100644 --- a/src/routes/api/program/+server.ts +++ b/src/routes/api/program/+server.ts @@ -1,13 +1,22 @@ +// Internal dependencies import { ProgramHelper } from '$scripts/helpers' +import { instanceOfSerializedProgram } from '$scripts/types' + +// Exports +export { POST, PUT, GET } + + +// --------------------> API Endpoints + /** * API endpoint for creating a Program in the database. - * @param POST request with JSON body { name: string } - * @returns SerializedProgram + * @body `{ name: string }` + * @returns `SerializedProgram` */ -export async function POST({ request }) { +async function POST({ request }) { // Retrieve data const { name } = await request.json() @@ -19,4 +28,38 @@ export async function POST({ request }) { data => new Response(JSON.stringify(data), { status: 200 }), error => new Response(error, { status: 400 }) ) +} + +/** + * API endpoint for updating a Program in the database. + * @body `SerializedProgram` + */ + +async function PUT({ request }) { + + // Retrieve data + const data = await request.json() + if (!instanceOfSerializedProgram(data)) { + return new Response('Invalid SerializedProgram', { status: 400 }) + } + + // Update program + return await ProgramHelper.update(data) + .then( + () => new Response(null, { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * API endpoint for requesting all Programs in the database. + * @returns `SerializedProgram[]` + */ + +async function GET() { + return await ProgramHelper.getAll() + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) } \ No newline at end of file diff --git a/src/routes/api/program/[id]/+server.ts b/src/routes/api/program/[id]/+server.ts new file mode 100644 index 0000000..6a48a88 --- /dev/null +++ b/src/routes/api/program/[id]/+server.ts @@ -0,0 +1,51 @@ + +// Internal dependencies +import { ProgramHelper } from '$scripts/helpers' + +// Exports +export { GET, DELETE } + + +// --------------------> API Endpoints + + +/** + * API endpoint for fetching a Program from the database. + * @returns `SerializedProgram` + */ + +async function GET({ params }) { + + // Retrieve data + const id = Number(params.id) + if (!id || isNaN(id)) { + return new Response('Missing ID', { status: 400 }) + } + + // Delete the program + return await ProgramHelper.getById(id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * API endpoint for deleting a Program from the database. + */ + +async function DELETE({ params }) { + + // Retrieve data + const id = Number(params.id) + if (!id || isNaN(id)) { + return new Response('Missing ID', { status: 400 }) + } + + // Delete the program + return await ProgramHelper.remove(id) + .then( + () => new Response(null, { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/program/[id]/course/+server.ts b/src/routes/api/program/[id]/course/+server.ts deleted file mode 100644 index a9f90d4..0000000 --- a/src/routes/api/program/[id]/course/+server.ts +++ /dev/null @@ -1,42 +0,0 @@ - -import { CourseHelper } from '$scripts/helpers' - -/** - * API endpoint for creating a Course in the database. - * @returns SerializedCourse - */ - -export async function POST({ request, params }) { - - // Retrieve data - if (!params.id || isNaN(Number(params.id)) ) - return new Response('Failed to create domain: Invalid program ID', { status: 400 }) - const program_id = Number(params.id) - - const { code, name } = await request.json() - if (!code || !name) return new Response('Failed to create course: Missing code or name', { status: 400 }) - - // Create the course - return await CourseHelper.create(program_id, code, name) - .then( - data => new Response(JSON.stringify(data), { status: 201 }), - error => new Response(error, { status: 400 }) - ) -} - -/** - * API endpoint for retrieving all Courses associated with a Program. - * @returns Array of SerializedCourses - */ - -export async function GET({ params }) { - if (!params.id || isNaN(Number(params.id))) - return new Response('Failed to get courses: Invalid program ID', { status: 400 }) - const id = Number(params.id) - - return await CourseHelper.getByProgramId(id) - .then( - data => new Response(JSON.stringify(data), { status: 200 }), - error => new Response(error, { status: 400 }) - ) -} \ No newline at end of file diff --git a/src/routes/api/program/[id]/courses/+server.ts b/src/routes/api/program/[id]/courses/+server.ts new file mode 100644 index 0000000..06e4dd3 --- /dev/null +++ b/src/routes/api/program/[id]/courses/+server.ts @@ -0,0 +1,27 @@ + +// Internal dependencies +import { ProgramHelper } from "$scripts/helpers" + +// Exports +export { GET } + +// --------------------> API Endpoints + + +/** + * Get all courses for a program + * @returns `SerializedCourse[]` + */ + +async function GET({ params }) { + const program_id = Number(params.id) + if (!program_id || isNaN(program_id)) { + return new Response('Invalid program ID', { status: 400 }) + } + + return await ProgramHelper.getCourses(program_id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/subject/+server.ts b/src/routes/api/subject/+server.ts index 1df5789..56e7ab6 100644 --- a/src/routes/api/subject/+server.ts +++ b/src/routes/api/subject/+server.ts @@ -2,8 +2,48 @@ import { SubjectHelper } from '$scripts/helpers' /** - * An API endpoint for updating a Subject object in the database. - * @param request PUT request containing a SerializedSubject object + * API endpoint for creating a new Subject in the database. + * @body `{ graph_id: number }` + * @returns `SerializedSubject` + */ + +export async function POST({ request }) { + + // Retrieve data + const { graph_id } = await request.json() + if (!graph_id || isNaN(graph_id)) + return new Response('Failed to create Subject: missing Graph ID', { status: 400 }) + + // Create graph + return await SubjectHelper.create(graph_id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * API endpoint for deleting Subjects from the database. + * @body `{ ids: number[] }` + */ + +export async function DELETE({ request }) { + + // Retrieve data + const { ids } = await request.json() + if (!ids) return new Response('Failed to remove Subjects: Missing IDs', { status: 400 }) + + // Remove graphs + return await SubjectHelper.remove(ids) + .then( + () => new Response(null, { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * An API endpoint for updating a Subject in the database. + * @body `SerializedSubject` */ export async function PUT({ request }) { @@ -11,10 +51,10 @@ export async function PUT({ request }) { // Retrieve data const data = await request.json() - // Update domain + // Update subject return await SubjectHelper.update(data) .then( - () => new Response('Subject updated', { status: 200 }), + () => new Response(null, { status: 200 }), error => new Response(error, { status: 400 }) ) } \ No newline at end of file diff --git a/src/routes/api/subject/[id]/+server.ts b/src/routes/api/subject/[id]/+server.ts deleted file mode 100644 index 78da278..0000000 --- a/src/routes/api/subject/[id]/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ - -import { SubjectHelper } from '$scripts/helpers' - -/** - * API endpoint for deleting a Subject from the database. - */ - -export async function DELETE({ params }) { - const id = Number(params.id) - - return await SubjectHelper.remove(id) - .then( - () => new Response(null, { status: 200 }), - error => new Response(error, { status: 400 }) - ) -} \ No newline at end of file diff --git a/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte b/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte index 1c783a8..fa2a1c2 100644 --- a/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte +++ b/src/routes/app/course/[course]/graph/[graph]/settings/DomainSettings.svelte @@ -146,7 +146,7 @@ {#each $graph.domains as domain} {#if domainMatchesQuery(domain_query, domain)} -
+
{domain.index + 1} {#each $graph.subjects as subject} {#if subjectMatchesQuery(subject_query, subject)} -
+
{subject.index + 1} Promise.reject(error)) - - return { course } + + // Retrieve data + const course_id = Number(params.course) + if (isNaN(course_id)) return Promise.reject('Invalid course ID') + const course = await CourseHelper.getById(course_id) + .catch(error => Promise.reject(error)) as SerializedCourse + + return { course } } diff --git a/src/routes/app/course/[course]/overview/+page.svelte b/src/routes/app/course/[course]/overview/+page.svelte index 8ee01d5..6080a19 100644 --- a/src/routes/app/course/[course]/overview/+page.svelte +++ b/src/routes/app/course/[course]/overview/+page.svelte @@ -1,6 +1,7 @@ - @@ -149,7 +152,7 @@ {#if $course.graphs.length === 0}

There's nothing here.

- {/if} + {/if} {#each $course.graphs as graph} diff --git a/src/routes/app/course/[course]/overview/+page.ts b/src/routes/app/course/[course]/overview/+page.ts deleted file mode 100644 index 745bc5d..0000000 --- a/src/routes/app/course/[course]/overview/+page.ts +++ /dev/null @@ -1,9 +0,0 @@ - -// Internal imports -import { CourseController } from '$scripts/controllers' - -// Load -export async function load({ data }) { - const course = await CourseController.revive(data.course) - return { course } -} \ No newline at end of file diff --git a/src/routes/app/dashboard/+page.server.ts b/src/routes/app/dashboard/+page.server.ts deleted file mode 100644 index d2b905d..0000000 --- a/src/routes/app/dashboard/+page.server.ts +++ /dev/null @@ -1,9 +0,0 @@ - -// Internal imports -import { ProgramHelper } from '$scripts/helpers' - -// Load -export async function load() { - const programs = await ProgramHelper.getAll() - return { programs } -} diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte index 2cb3e39..de37878 100644 --- a/src/routes/app/dashboard/+page.svelte +++ b/src/routes/app/dashboard/+page.svelte @@ -1,8 +1,11 @@ @@ -141,175 +149,166 @@ -{#await load() then} + + + + + + + + +
+ + + + +

Create Sandbox

+ + Sandboxes are environments where you can experiment with the Graph editor. They are not associated with any program or course. + +
+ + + + + + +
+ + +
+ +
+ + +

Create Program

+ + Programs are collections of courses, usually pertaining to the same field of study. Looking to try out the Graph editor? Try making a sandbox environment instead! + +
+ + + +
+ + +
+ +
+ + +

Create Course

+ + Courses are the building blocks of your program. They have their own unique code and name, and are associated with a program. Looking to try out the Graph editor? Try making a sandbox environment instead! + +
+ + + + + + +
+ + +
+ +
+ + + +

My Courses

+ + + {#if !$courses.some(course => courseMatchesQuery(query, course))} + There's nothing here + {:else} + +
+ {#each $courses as course} + {#if courseMatchesQuery(query, course)} + {course.code} {course.name} + {/if} + {/each} +
- - - - - - - - -
- - - - -

Create Sandbox

- - Sandboxes are environments where you can experiment with the Graph editor. They are not associated with any program or course. - -
- - - - - - -
- - -
- -
- - -

Create Program

- - Programs are collections of courses, usually pertaining to the same field of study. Looking to try out the Graph editor? Try making a sandbox environment instead! - -
- - - -
- - -
- -
- - -

Create Course

- - Courses are the building blocks of your program. They have their own unique code and name, and are associated with a program. Looking to try out the Graph editor? Try making a sandbox environment instead! - -
- - - - - - -
- - -
- -
+ {/if} + + {#each $programs as program} -

My Courses

- - - {#if !$courses.some(course => courseMatchesQuery(query, course))} - There's nothing here - {:else} - -
- {#each $courses as course} - {#if courseMatchesQuery(query, course)} - {course.code} {course.name} - {/if} + +

{program.name}

+ +
+ + + + Program settings + + +

Program Coordinators

+

+ These are the coordinators of the {program.name} program. You can contact them via email to request + access to a course. +

+ +
- - {#each $programs as program} - - -

{program.name}

- -
- - - - Program settings - - -

Program Coordinators

-

- These are the coordinators of the {program.name} program. You can contact them via email to request - access to a course. -

- -
- - - - {#await program.courses then courses} - {#if !courses.some(course => courseMatchesQuery(query, course))} - There's nothing here - {:else} - -
- {#each courses as course} - {#if courseMatchesQuery(query, course)} - {course.code} {course.name} - {/if} - {/each} -
+
- {/if} - {/await} -
-
- {/each} - - -{/await} + {/if} + {/await} + + + {/each} + diff --git a/src/routes/app/dashboard/+page.ts b/src/routes/app/dashboard/+page.ts new file mode 100644 index 0000000..8f9eae7 --- /dev/null +++ b/src/routes/app/dashboard/+page.ts @@ -0,0 +1,16 @@ + +// Internal dependencies +import { + ControllerEnvironment, + ProgramController, + CourseController +} from '$scripts/controllers' + +// Load +export async function load({ data }) { + const environment = new ControllerEnvironment() + const programs = data.programs.map(datum => ProgramController.revive(environment, datum)) + const courses = data.courses.map(datum => CourseController.revive(environment, datum)) + + return { environment, programs, courses } +} \ No newline at end of file From aa376c4990b86ac045998bdefa54359b6aecbb08 Mon Sep 17 00:00:00 2001 From: Bram Kreulen Date: Fri, 11 Oct 2024 17:36:24 +0200 Subject: [PATCH 28/45] Course overview API implementation --- .../scripts/controllers/CourseController.ts | 3 +- .../scripts/controllers/GraphController.ts | 257 ++++++++++++++++-- src/lib/scripts/helpers/CourseHelper.ts | 49 ++-- src/lib/scripts/helpers/GraphHelper.ts | 192 ++++++++----- src/lib/scripts/helpers/ProgramHelper.ts | 67 +++-- src/routes/api/course/+server.ts | 1 + src/routes/api/course/[id]/admins/+server.ts | 1 + src/routes/api/course/[id]/editors/+server.ts | 1 + src/routes/api/course/[id]/graphs/+server.ts | 1 + .../api/course/[id]/programs/+server.ts | 1 + src/routes/api/graph/+server.ts | 79 ++---- src/routes/api/graph/[id]/+server.ts | 51 ++++ src/routes/api/graph/[id]/course/+server.ts | 28 ++ src/routes/api/graph/[id]/domains/+server.ts | 28 ++ src/routes/api/graph/[id]/lectures/+server.ts | 28 ++ src/routes/api/graph/[id]/subjects/+server.ts | 28 ++ src/routes/api/program/+server.ts | 2 +- src/routes/api/program/[id]/admins/+server.ts | 1 + .../api/program/[id]/courses/+server.ts | 1 + .../api/program/[id]/editors/+server.ts | 1 + .../course/[course]/overview/+page.server.ts | 7 +- .../app/course/[course]/overview/+page.svelte | 206 +++++++------- .../app/course/[course]/overview/+page.ts | 14 + src/routes/app/dashboard/+page.server.ts | 4 +- 24 files changed, 735 insertions(+), 316 deletions(-) create mode 100644 src/routes/api/graph/[id]/+server.ts create mode 100644 src/routes/api/graph/[id]/course/+server.ts create mode 100644 src/routes/api/graph/[id]/domains/+server.ts create mode 100644 src/routes/api/graph/[id]/lectures/+server.ts create mode 100644 src/routes/api/graph/[id]/subjects/+server.ts create mode 100644 src/routes/app/course/[course]/overview/+page.ts diff --git a/src/lib/scripts/controllers/CourseController.ts b/src/lib/scripts/controllers/CourseController.ts index 1b1fd21..7b0db82 100644 --- a/src/lib/scripts/controllers/CourseController.ts +++ b/src/lib/scripts/controllers/CourseController.ts @@ -11,7 +11,6 @@ import { } from '$scripts/controllers' import { ValidationData, Severity } from '$scripts/validation' - import type { SerializedCourse, SerializedGraph, SerializedProgram, SerializedUser } from '$scripts/types' // Exports @@ -213,7 +212,7 @@ class CourseController { } // Check if the course is already loaded - const existing = environment.courses.find(existing => existing.id === data.id) + const existing = environment.courses.find(existing => existing.id === id) if (existing) return existing // Call API to get the course diff --git a/src/lib/scripts/controllers/GraphController.ts b/src/lib/scripts/controllers/GraphController.ts index 3f786ee..e31cb4e 100644 --- a/src/lib/scripts/controllers/GraphController.ts +++ b/src/lib/scripts/controllers/GraphController.ts @@ -1,4 +1,7 @@ +// External dependencies +import { browser } from '$app/environment' + // Internal dependencies import { ControllerEnvironment, @@ -9,8 +12,7 @@ import { } from '$scripts/controllers' import { ValidationData, Severity } from '$scripts/validation' - -import type { SerializedGraph, DropdownOption } from '$scripts/types' +import type { SerializedGraph, DropdownOption, SerializedDomain, SerializedCourse, SerializedSubject } from '$scripts/types' // Exports export { GraphController } @@ -38,41 +40,160 @@ class GraphController { } get course(): Promise { - return (async () => { - if (this._course) return this._course - this._course = await this.environment.getCourse(this._course_id) as CourseController + + // Guard against SSR + if (!browser) { + return Promise.reject() + } + + // Check if course is already loaded + if (this._course) { + return Promise.resolve(this._course) + } + + // Call API to get the course + return fetch(`/api/graph/${this.id}/course`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/course/${this._course_id} GET): ${error}`) } + ) + + // Parse the data + .then(data => { + + // Revive course + const existing = this.environment.courses.find(existing => existing.id === data.id) + this._course = existing ? existing : CourseController.revive(this.environment, data) + + // Check if course is in sync + if (this._course.id !== this._course_id) { + throw new Error('GraphError: Course is not in sync') + } + return this._course - })() + }) } get domains(): Promise { - return (async () => { - if (this._domains) return this._domains - this._domains = await this.environment.getDomains(this._domain_ids) + + // Guard against SSR + if (!browser) { + return Promise.reject() + } + + // Check if domains are already loaded + if (this._domains) { + return Promise.resolve(this._domains) + } + + // Call API to get the domains + return fetch(`/api/graph/${this.id}/domains`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/graph/${this.id}/domains GET): ${error}`) } + ) + + // Parse the data + .then(data => { + + // Revive domains + this._domains = data.map(domain => { + const existing = this.environment.domains.find(existing => existing.id === domain.id) + return existing ? existing : DomainController.revive(this.environment, domain) + }) + + // Check if domains are in sync + const client = JSON.stringify(this._domain_ids.concat().sort()) + const server = JSON.stringify(this._domains.map(domain => domain.id).sort()) + if (client !== server) { + throw new Error('GraphError: Domains are not in sync') + } + return this._domains - })() + }) } get subjects(): Promise { - return (async () => { - if (this._subjects) return this._subjects - this._subjects = await this.environment.getSubjects(this._subject_ids) + + // Guard against SSR + if (!browser) { + return Promise.reject() + } + + // Check if subjects are already loaded + if (this._subjects) { + return Promise.resolve(this._subjects) + } + + // Call API to get the subjects + return fetch(`/api/graph/${this.id}/subjects`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/graph/${this.id}/subjects GET): ${error}`) } + ) + + // Parse the data + .then(data => { + + // Revive subjects + this._subjects = data.map(subject => { + const existing = this.environment.subjects.find(existing => existing.id === subject.id) + return existing ? existing : SubjectController.revive(this.environment, subject) + }) + + // Check if subjects are in sync + const client = JSON.stringify(this._subject_ids.concat().sort()) + const server = JSON.stringify(this._subjects.map(subject => subject.id).sort()) + if (client !== server) { + throw new Error('GraphError: Subjects are not in sync') + } + return this._subjects - })() + }) } get lectures(): Promise { - return (async () => { - if (this._lectures) return this._lectures - this._lectures = await this.environment.getLectures(this._lecture_ids) + + // Guard against SSR + if (!browser) { + return Promise.reject() + } + + // Check if lectures are already loaded + if (this._lectures) { + return Promise.resolve(this._lectures) + } + + // Call API to get the lectures + return fetch(`/api/graph/${this.id}/lectures`, { method: 'GET' }) + .then( + response => response.json() as Promise, + error => { throw new Error(`APIError (/api/graph/${this.id}/lectures GET): ${error}`) } + ) + + // Parse the data + .then(data => { + + // Revive lectures + this._lectures = data.map(lecture => { + const existing = this.environment.lectures.find(existing => existing.id === lecture.id) + return existing ? existing : LectureController.revive(this.environment, lecture) + }) + + // Check if lectures are in sync + const client = JSON.stringify(this._lecture_ids.concat().sort()) + const server = JSON.stringify(this._lectures.map(lecture => lecture.id).sort()) + if (client !== server) { + throw new Error('GraphError: Lectures are not in sync') + } + return this._lectures - })() + }) } get lecture_options(): Promise[]> { - return (async () => { - const lectures = await this.lectures - return Promise.all( + return this.lectures + .then(lectures => Promise.all( lectures.map( async lecture => ({ value: lecture, @@ -80,14 +201,75 @@ class GraphController { validation: await lecture.validate() }) ) - ) - })() + )) } get index(): Promise { return this.course.then(course => course.graphIndex(this)) } + /** + * Get a graph + * @param environment Environment to get the graph from + * @param id ID of the graph to get + * @returns `Promise` The requested GraphController + * @throws `APIError` if the API call fails + */ + + static async get(environment: ControllerEnvironment, id: number): Promise { + + // Guard against SSR + if (!browser) { + return Promise.reject() + } + + // Check if the course is already loaded + const existing = environment.graphs.find(existing => existing.id === id) + if (existing) return existing + + // Call API to get the graph + const response = await fetch(`/api/graph/${id}`, { method: 'GET' }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/graph/${id} GET): ${error}`) + }) + + // Revive the graph + const data = await response.json() as SerializedGraph + return GraphController.revive(environment, data) + } + + /** + * Get all graphs + * @param environment Environment to get the graphs from + * @returns `Promise` All GraphControllers + * @throws `APIError` if the API call fails + */ + + static async getAll(environment: ControllerEnvironment): Promise { + + // Guard against SSR + if (!browser) { + return Promise.reject() + } + + // Call API to get all graphs + const response = await fetch(`/api/graph`, { method: 'GET' }) + + // Check the response + .catch(error => { + throw new Error(`APIError (/api/graph GET): ${error}`) + }) + + // Revive all graphs + const data = await response.json() as SerializedGraph[] + return data.map(course => { + const existing = environment.graphs.find(existing => existing.id === course.id) + return existing ? existing : GraphController.revive(environment, course) + }) + } + /** * Create a new graph * @param environment Environment to create the graph in @@ -96,13 +278,18 @@ class GraphController { * @throws `APIError` if the API call fails */ - static async create(environment: ControllerEnvironment, course: CourseController): Promise { + static async create(environment: ControllerEnvironment, course: number, name: string): Promise { + + // Guard against SSR + if (!browser) { + Promise.reject() + } // Call API to create a new graph const response = await fetch(`/api/graph`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ course: course.id }) + body: JSON.stringify({ course, name }) }) // Check the response @@ -113,7 +300,10 @@ class GraphController { // Revive the graph const data = await response.json() const graph = GraphController.revive(environment, data) - course.assignGraph(graph) + + // Assign to course + await CourseController.get(environment, course) + .then(course => course.assignGraph(graph)) return graph } @@ -214,6 +404,11 @@ class GraphController { async save(): Promise { + // Guard against SSR + if (!browser) { + return Promise.reject() + } + // Call API to save the graph await fetch(`/api/graph`, { method: 'PUT', @@ -234,6 +429,11 @@ class GraphController { async delete(): Promise { + // Guard against SSR + if (!browser) { + return Promise.reject() + } + // Call API to delete the graph await fetch(`/api/graph`, { method: 'DELETE', @@ -247,8 +447,9 @@ class GraphController { }) // Unassign from course - const course = await this.environment.getCourse(this._course_id, false) - course?.unassignGraph(this) + this.environment.courses + .find(course => course.id === this._course_id) + ?.unassignGraph(this) // Delete all related domains, subjects, and lectures const domains = await this.domains diff --git a/src/lib/scripts/helpers/CourseHelper.ts b/src/lib/scripts/helpers/CourseHelper.ts index e1bd37c..a5e581e 100644 --- a/src/lib/scripts/helpers/CourseHelper.ts +++ b/src/lib/scripts/helpers/CourseHelper.ts @@ -1,9 +1,9 @@ -// External imports +// External dependencies import prisma from '$lib/server/prisma' import type { Course as PrismaCourse } from '@prisma/client' -// Internal imports +// Internal dependencies import { ProgramHelper, GraphHelper @@ -51,7 +51,11 @@ async function create(code: string, name: string): Promise { async function remove(course_id: number): Promise { try { - await prisma.course.delete({ where: { id: course_id }}) + await prisma.course.delete({ + where: { + id: course_id + } + }) } catch (error) { return Promise.reject(error) } @@ -63,15 +67,26 @@ async function remove(course_id: number): Promise { */ async function update(data: SerializedCourse): Promise { - - const graphs = await getGraphs(data.id) - const old_graphs = graphs.filter(graph => !data.graphs.includes(graph)) - const new_graphs = data.graphs.filter(graph => !graphs.includes(graph)) + // Get old and new graphs + const graphs = await getGraphs(data.id) + const old_graphs = graphs + .filter(graph => !data.graphs.includes(graph.id)) + .map(graph => ({ id: graph.id })) + const new_graphs = data.graphs + .filter(id => !graphs.some(graph => graph.id === id)) + .map(id => ({ id })) + + // Get old and new programs const programs = await getPrograms(data.id) - const old_programs = programs.filter(program => !data.programs.includes(program)) - const new_programs = data.programs.filter(program => !programs.includes(program)) - + const old_programs = programs + .filter(program => !data.programs.includes(program.id)) + .map(program => ({ id: program.id })) + const new_programs = data.programs + .filter(id => !programs.some(program => program.id === id)) + .map(id => ({ id })) + + // Update try { await prisma.course.update({ where: { @@ -81,12 +96,12 @@ async function update(data: SerializedCourse): Promise { name: data.name, code: data.code, graphs: { - connect: new_graphs.map(graph => ({ id: graph })), - disconnect: old_graphs.map(graph => ({ id: graph })) + connect: new_graphs, + disconnect: old_graphs }, programs: { - connect: new_programs.map(program => ({ id: program })), - disconnect: old_programs.map(program => ({ id: program })) + connect: new_programs, + disconnect: old_programs } } }) @@ -103,7 +118,7 @@ async function update(data: SerializedCourse): Promise { async function reduce(course: PrismaCourse): Promise { - // Retrieve additional data + // Get additional data try { var data = await prisma.course.findUniqueOrThrow({ where: { @@ -139,11 +154,11 @@ async function reduce(course: PrismaCourse): Promise { const admins = data.coordinators .filter(coordinator => coordinator.role === 'ADMIN') .map(coordinator => Number(coordinator.userId)) - const editors = data.coordinators .filter(coordinator => coordinator.role === 'EDITOR') - .map(coordinator => Number(coordinator.userId)) + .map(coordinator => Number(coordinator.userId)) + // Return reduced data return { id: data.id, code: data.code, diff --git a/src/lib/scripts/helpers/GraphHelper.ts b/src/lib/scripts/helpers/GraphHelper.ts index b9738d9..fd4e432 100644 --- a/src/lib/scripts/helpers/GraphHelper.ts +++ b/src/lib/scripts/helpers/GraphHelper.ts @@ -4,10 +4,23 @@ import prisma from '$lib/server/prisma' import type { Graph as PrismaGraph } from '@prisma/client' // Internal imports -import type { SerializedGraph } from '$scripts/types' +import { + CourseHelper, + DomainHelper, + SubjectHelper, + LectureHelper +} from '$scripts/helpers' + +import type { + SerializedGraph, + SerializedCourse, + SerializedDomain, + SerializedSubject, + SerializedLecture +} from '$scripts/types' // Exports -export { create, remove, update, reduce, getAll, getById } +export { create, remove, update, reduce, getAll, getById, getCourse, getDomains, getSubjects, getLectures } // --------------------> Helper Functions @@ -41,16 +54,14 @@ async function create(course_id: number, name: string): Promise /** * Removes a Graph from the database. - * @param graph_ids `number[]` + * @param graph_id `number` */ -async function remove(...graph_ids: number[]): Promise { +async function remove(graph_id: number): Promise { try { - await prisma.graph.deleteMany({ + await prisma.graph.delete({ where: { - id: { - in: graph_ids - } + id: graph_id } }) } catch (error) { @@ -65,24 +76,41 @@ async function remove(...graph_ids: number[]): Promise { async function update(data: SerializedGraph): Promise { - const course = await getCourseID(data.id) - const course_data = course === data.course ? {} : { + // Get old and new domains + const domains = await getDomains(data.id) + const old_domains = domains + .filter(domain => !data.domains.includes(domain.id)) + .map(domain => ({ id: domain.id })) + const new_domains = data.domains + .filter(id => !domains.some(domain => domain.id === id)) + .map(id => ({ id })) + + // Get old and new subjects + const subjects = await getSubjects(data.id) + const old_subjects = subjects + .filter(subject => !data.subjects.includes(subject.id)) + .map(subject => ({ id: subject.id })) + const new_subjects = data.subjects + .filter(id => !subjects.some(subject => subject.id === id)) + .map(id => ({ id })) + + // Get old and new lectures + const lectures = await getLectures(data.id) + const old_lectures = lectures + .filter(lecture => !data.lectures.includes(lecture.id)) + .map(lecture => ({ id: lecture.id })) + const new_lectures = data.lectures + .filter(id => !lectures.some(lecture => lecture.id === id)) + .map(id => ({ id })) + + // Get course connection data + const course = await getCourse(data.id) + const course_data = course.id === data.course ? {} : { connect : { id: data.course }, - disconnect : { id: course } + disconnect : { id: course.id } } - const domains = await getDomainIDs(data.id) - const old_domains = domains.filter(domain => !data.domains.includes(domain)) - const new_domains = data.domains.filter(domain => !domains.includes(domain)) - - const subjects = await getSubjectIDs(data.id) - const old_subjects = subjects.filter(subject => !data.subjects.includes(subject)) - const new_subjects = data.subjects.filter(subject => !subjects.includes(subject)) - - const lectures = await getLectureIDs(data.id) - const old_lectures = lectures.filter(lecture => !data.lectures.includes(lecture)) - const new_lectures = data.lectures.filter(lecture => !lectures.includes(lecture)) - + // Update try { await prisma.graph.update({ where: { @@ -92,16 +120,16 @@ async function update(data: SerializedGraph): Promise { name: data.name, course: course_data, domains: { - connect: new_domains.map(domain => ({ id: domain })), - disconnect: old_domains.map(domain => ({ id: domain })) + connect: new_domains, + disconnect: old_domains }, subjects: { - connect: new_subjects.map(subject => ({ id: subject })), - disconnect: old_subjects.map(subject => ({ id: subject })) + connect: new_subjects, + disconnect: old_subjects }, lectures: { - connect: new_lectures.map(lecture => ({ id: lecture })), - disconnect: old_lectures.map(lecture => ({ id: lecture })) + connect: new_lectures, + disconnect: old_lectures } } }) @@ -117,13 +145,47 @@ async function update(data: SerializedGraph): Promise { */ async function reduce(graph: PrismaGraph): Promise { + + // Get aditional data + try { + var data = await prisma.graph.findUniqueOrThrow({ + where: { id: graph.id }, + include: { + domains: { + select: { + id: true + } + }, + subjects: { + select: { + id: true + } + }, + lectures: { + select: { + id: true + } + } + } + }) + } catch (error) { + return Promise.reject(error) + } + + // Parse data + const domains = data.domains + .map(domain => domain.id) + const subjects = data.subjects + .map(subject => subject.id) + const lectures = data.lectures + .map(lecture => lecture.id) + + // Return reduced data return { - id: graph.id, - name: graph.name, - course: graph.courseId, - domains: await getDomainIDs(graph.id), - subjects: await getSubjectIDs(graph.id), - lectures: await getLectureIDs(graph.id) + id: data.id, + name: data.name, + course: data.courseId, + domains, subjects, lectures } } @@ -144,59 +206,57 @@ async function getAll(): Promise { /** * Retrieves Groups by ID - * @param group_ids `number[]` - * @returns `SerializedGraph[]` or `SerializedGraph` if a single ID is provided + * @param group_id `number` + * @returns `SerializedGraph` */ -async function getById(...graph_ids: number[]): Promise { +async function getById(graph_id: number): Promise { try { - var graphs = await prisma.graph.findMany({ + var graph = await prisma.graph.findUniqueOrThrow({ where: { - id: { - in: graph_ids - } + id: graph_id } }) } catch (error) { return Promise.reject(error) } - if (graph_ids.length === 1) { - return await reduce(graphs[0]) - } else { - return await Promise.all(graphs.map(reduce)) - } + return await reduce(graph) } /** - * Retrieves Graph course ID. + * Retrieves Graph course. * @param graph_id `number` - * @returns `number` + * @returns `SerializedCourse` */ -async function getCourseID(graph_id: number): Promise { +async function getCourse(graph_id: number): Promise { try { - var graph = await prisma.graph.findUniqueOrThrow({ + var data = await prisma.course.findFirstOrThrow({ where: { - id: graph_id + graphs: { + some: { + id: graph_id + } + } } }) } catch (error) { return Promise.reject(error) } - return graph.courseId + return await CourseHelper.reduce(data) } /** - * Retrieves Graph domain IDs. + * Retrieves Graph domains. * @param graph_id `number` - * @returns `number[]` + * @returns `SerializedGraph[]` */ -async function getDomainIDs(graph_id: number): Promise { +async function getDomains(graph_id: number): Promise { try { - var domains = await prisma.domain.findMany({ + var data = await prisma.domain.findMany({ where: { graphId: graph_id } @@ -205,16 +265,16 @@ async function getDomainIDs(graph_id: number): Promise { return Promise.reject(error) } - return domains.map(domain => domain.id) + return await Promise.all(data.map(DomainHelper.reduce)) } /** - * Retrieves Graph subject IDs. + * Retrieves Graph subjects. * @param graph_id `number` - * @returns `number[]` + * @returns `SerializedSubject[]` */ -async function getSubjectIDs(graph_id: number): Promise { +async function getSubjects(graph_id: number): Promise { try { var subjects = await prisma.subject.findMany({ where: { @@ -225,16 +285,16 @@ async function getSubjectIDs(graph_id: number): Promise { return Promise.reject(error) } - return subjects.map(subject => subject.id) + return await Promise.all(subjects.map(SubjectHelper.reduce)) } /** - * Retrieves Graph lecture IDs. + * Retrieves Graph lectures. * @param graph_id `number` - * @returns `number[]` + * @returns `SerializedLecture[]` */ -async function getLectureIDs(graph_id: number): Promise { +async function getLectures(graph_id: number): Promise { try { var lectures = await prisma.lecture.findMany({ where: { @@ -245,5 +305,5 @@ async function getLectureIDs(graph_id: number): Promise { return Promise.reject(error) } - return lectures.map(lecture => lecture.id) + return await Promise.all(lectures.map(LectureHelper.reduce)) } \ No newline at end of file diff --git a/src/lib/scripts/helpers/ProgramHelper.ts b/src/lib/scripts/helpers/ProgramHelper.ts index 76834cf..106723f 100644 --- a/src/lib/scripts/helpers/ProgramHelper.ts +++ b/src/lib/scripts/helpers/ProgramHelper.ts @@ -1,9 +1,9 @@ -// External imports +// External dependencies import prisma from '$lib/server/prisma' import type { Program as PrismaProgram } from '@prisma/client' -// Internal imports +// Internal dependencies import { CourseHelper } from '$scripts/helpers' import type { SerializedProgram, @@ -26,7 +26,11 @@ export { create, remove, update, reduce, getAll, getById, getCourses, getAdmins, async function create(name: string): Promise { try { - var program = await prisma.program.create({ data: { name }}) + var program = await prisma.program.create({ + data: { + name + }} + ) } catch (error) { return Promise.reject(error) } @@ -41,7 +45,11 @@ async function create(name: string): Promise { async function remove(program_id: number): Promise { try { - await prisma.program.delete({ where: { id: program_id }}) + await prisma.program.delete({ + where: { + id: program_id + } + }) } catch (error) { return Promise.reject(error) } @@ -53,15 +61,17 @@ async function remove(program_id: number): Promise { */ async function update(data: SerializedProgram): Promise { + + // Get old and new courses const courses = await getCourses(data.id) const old_courses = courses .filter(course => !data.courses.includes(course.id)) .map(course => ({ id: course.id })) - const new_courses = data.courses .filter(id => !courses.some(course => course.id === id)) .map(id => ({ id })) + // Update try { await prisma.program.update({ where: { @@ -87,9 +97,13 @@ async function update(data: SerializedProgram): Promise { */ async function reduce(program: PrismaProgram): Promise { + + // Get additional data try { var data = await prisma.program.findUniqueOrThrow({ - where: { id: program.id }, + where: { + id: program.id + }, include: { courses: { select: { @@ -108,19 +122,19 @@ async function reduce(program: PrismaProgram): Promise { return Promise.reject(error) } + // Parse data const courses = data.courses.map(course => course.id) - const admins = data.coordinators .filter(coordinator => coordinator.role === 'ADMIN') .map(coordinator => Number(coordinator.userId)) - const editors = data.coordinators .filter(coordinator => coordinator.role === 'EDITOR') .map(coordinator => Number(coordinator.userId)) - return { + // Return reduced data + return { id: data.id, - name: data.name, + name: data.name, courses, admins, editors } } @@ -167,38 +181,21 @@ async function getById(program_id: number): Promise { */ async function getCourses(program_id: number): Promise { - - // TODO Course and program still not many to many :( - /* try { */ - /* var data = await prisma.course.findMany({ */ - /* where: { */ - /* programs: { */ - /* some: { */ - /* programId: program_id */ - /* } */ - /* } */ - /* } */ - /* }) */ - /* } catch (error) { */ - /* return Promise.reject(error) */ - /* } */ - - /* return await Promise.all(data.map(CourseHelper.reduce)) */ - try { - var data = await prisma.program.findUniqueOrThrow({ - where: { - id: program_id - }, - select: { - courses: true + var data = await prisma.course.findMany({ + where: { + programs: { + some: { + id: program_id + } + } } }) } catch (error) { return Promise.reject(error) } - return await Promise.all(data.courses.map(CourseHelper.reduce)) + return await Promise.all(data.map(CourseHelper.reduce)) } /** diff --git a/src/routes/api/course/+server.ts b/src/routes/api/course/+server.ts index 22a101d..3815e7a 100644 --- a/src/routes/api/course/+server.ts +++ b/src/routes/api/course/+server.ts @@ -6,6 +6,7 @@ import { instanceOfSerializedCourse } from '$scripts/types' // Exports export { POST, PUT, GET } + // --------------------> API Endpoints diff --git a/src/routes/api/course/[id]/admins/+server.ts b/src/routes/api/course/[id]/admins/+server.ts index be01f72..9ee093b 100644 --- a/src/routes/api/course/[id]/admins/+server.ts +++ b/src/routes/api/course/[id]/admins/+server.ts @@ -5,6 +5,7 @@ import { CourseHelper } from "$scripts/helpers" // Exports export { GET } + // --------------------> API Endpoints diff --git a/src/routes/api/course/[id]/editors/+server.ts b/src/routes/api/course/[id]/editors/+server.ts index a0b22e6..aed842a 100644 --- a/src/routes/api/course/[id]/editors/+server.ts +++ b/src/routes/api/course/[id]/editors/+server.ts @@ -5,6 +5,7 @@ import { CourseHelper } from "$scripts/helpers" // Exports export { GET } + // --------------------> API Endpoints diff --git a/src/routes/api/course/[id]/graphs/+server.ts b/src/routes/api/course/[id]/graphs/+server.ts index 7ff51c8..3cdf867 100644 --- a/src/routes/api/course/[id]/graphs/+server.ts +++ b/src/routes/api/course/[id]/graphs/+server.ts @@ -5,6 +5,7 @@ import { CourseHelper } from "$scripts/helpers" // Exports export { GET } + // --------------------> API Endpoints diff --git a/src/routes/api/course/[id]/programs/+server.ts b/src/routes/api/course/[id]/programs/+server.ts index cee3575..79b7b9a 100644 --- a/src/routes/api/course/[id]/programs/+server.ts +++ b/src/routes/api/course/[id]/programs/+server.ts @@ -5,6 +5,7 @@ import { CourseHelper } from "$scripts/helpers" // Exports export { GET } + // --------------------> API Endpoints diff --git a/src/routes/api/graph/+server.ts b/src/routes/api/graph/+server.ts index 1885de5..811b2f1 100644 --- a/src/routes/api/graph/+server.ts +++ b/src/routes/api/graph/+server.ts @@ -1,56 +1,48 @@ +// Internal dependencies import { GraphHelper } from '$scripts/helpers' -import type { SerializedGraph } from '$scripts/types' +import { instanceOfSerializedGraph } from '$scripts/types' + +// Exports +export { POST, PUT, GET } + + +// --------------------> API Endpoints + /** * API endpoint for creating a new Graph in the database. - * @body `{ course_id: number, name: string }` + * @body `{ course: number, name: string }` * @returns `SerializedGraph` */ -export async function POST({ request }) { +async function POST({ request }) { // Retrieve data - const { course_id, name } = await request.json() - if (!course_id || isNaN(course_id) || !name) - return new Response('Failed to create graph: Missing course or name', { status: 400 }) + const { course, name } = await request.json() + if (!course || !name || isNaN(course)) + return new Response('Missing course or name', { status: 400 }) // Create graph - return await GraphHelper.create(course_id, name) + return await GraphHelper.create(course, name) .then( data => new Response(JSON.stringify(data), { status: 200 }), error => new Response(error, { status: 400 }) ) } -/** - * API endpoint for deleting Graphs from the database. - * @body `{ ids: number[] }` - */ - -export async function DELETE({ request }) { - - // Retrieve data - const { ids } = await request.json() - if (!ids) return new Response('Failed to remove graph: Missing IDs', { status: 400 }) - - // Remove graphs - return await GraphHelper.remove(ids) - .then( - () => new Response(null, { status: 200 }), - error => new Response(error, { status: 400 }) - ) -} - /** * API endpoint for updating a Graph in the database. - * @param request PUT request containing a SerializedGraph + * @body `SerializedGraph` */ -export async function PUT({ request }) { +async function PUT({ request }) { // Retrieve data - const data: SerializedGraph = await request.json() + const data = await request.json() + if (!instanceOfSerializedGraph(data)) { + return new Response('Invalid SerializedGraph', { status: 400 }) + } // Update domain return await GraphHelper.update(data) @@ -62,30 +54,13 @@ export async function PUT({ request }) { /** * API endpoint for requesting Graphs in the database. - * @body `{ ids: number[] }` If not provided, all graphs are returned * @returns `SerializedGraph[]` or `SerializedGraph` if a single ID is provided */ -export async function GET({ request }) { - - // Retrieve data - const { ids } = await request.json() - - // Retrieve graphs by ID - if (ids) { - return await GraphHelper.getById(...ids) - .then( - graphs => new Response(JSON.stringify(graphs), { status: 200 }), - error => new Response(error, { status: 400 }) - ) - } - - // Retrieve all programs - else { - return await GraphHelper.getAll() - .then( - graphs => new Response(JSON.stringify(graphs), { status: 200 }), - error => new Response(error, { status: 400 }) - ) - } +async function GET() { + return await GraphHelper.getAll() + .then( + graphs => new Response(JSON.stringify(graphs), { status: 200 }), + error => new Response(error, { status: 400 }) + ) } \ No newline at end of file diff --git a/src/routes/api/graph/[id]/+server.ts b/src/routes/api/graph/[id]/+server.ts new file mode 100644 index 0000000..f23cb99 --- /dev/null +++ b/src/routes/api/graph/[id]/+server.ts @@ -0,0 +1,51 @@ + +// Internal dependencies +import { GraphHelper } from '$scripts/helpers' + +// Exports +export { GET, DELETE } + + +// --------------------> API Endpoints + + +/** + * API endpoint for fetching a Graph from the database. + * @returns `SerializedGraph` + */ + +async function GET({ params }) { + + // Retrieve data + const id = Number(params.id) + if (!id || isNaN(id)) { + return new Response('Missing ID', { status: 400 }) + } + + // Delete the graph + return await GraphHelper.getById(id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} + +/** + * API endpoint for deleting a Graph from the database. + */ + +async function DELETE({ params }) { + + // Retrieve data + const id = Number(params.id) + if (!id || isNaN(id)) { + return new Response('Missing ID', { status: 400 }) + } + + // Delete the graph + return await GraphHelper.remove(id) + .then( + () => new Response(null, { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/graph/[id]/course/+server.ts b/src/routes/api/graph/[id]/course/+server.ts new file mode 100644 index 0000000..07d7c9e --- /dev/null +++ b/src/routes/api/graph/[id]/course/+server.ts @@ -0,0 +1,28 @@ + +// Internal dependencies +import { GraphHelper } from "$scripts/helpers" + +// Exports +export { GET } + + +// --------------------> API Endpoints + + +/** + * Get the course of a graph + * @returns `SerializedCourse` + */ + +async function GET({ params }) { + const course_id = Number(params.id) + if (!course_id || isNaN(course_id)) { + return new Response('Invalid course ID', { status: 400 }) + } + + return await GraphHelper.getCourse(course_id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/graph/[id]/domains/+server.ts b/src/routes/api/graph/[id]/domains/+server.ts new file mode 100644 index 0000000..4d55aaa --- /dev/null +++ b/src/routes/api/graph/[id]/domains/+server.ts @@ -0,0 +1,28 @@ + +// Internal dependencies +import { GraphHelper } from "$scripts/helpers" + +// Exports +export { GET } + + +// --------------------> API Endpoints + + +/** + * Get all domains for a graph + * @returns `SerializedDomain[]` + */ + +async function GET({ params }) { + const domain_id = Number(params.id) + if (!domain_id || isNaN(domain_id)) { + return new Response('Invalid domain ID', { status: 400 }) + } + + return await GraphHelper.getDomains(domain_id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/graph/[id]/lectures/+server.ts b/src/routes/api/graph/[id]/lectures/+server.ts new file mode 100644 index 0000000..faa06f1 --- /dev/null +++ b/src/routes/api/graph/[id]/lectures/+server.ts @@ -0,0 +1,28 @@ + +// Internal dependencies +import { GraphHelper } from "$scripts/helpers" + +// Exports +export { GET } + + +// --------------------> API Endpoints + + +/** + * Get all lectures for a graph + * @returns `SerializedLecture[]` + */ + +async function GET({ params }) { + const lecture_id = Number(params.id) + if (!lecture_id || isNaN(lecture_id)) { + return new Response('Invalid lecture ID', { status: 400 }) + } + + return await GraphHelper.getLectures(lecture_id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/graph/[id]/subjects/+server.ts b/src/routes/api/graph/[id]/subjects/+server.ts new file mode 100644 index 0000000..42efd7f --- /dev/null +++ b/src/routes/api/graph/[id]/subjects/+server.ts @@ -0,0 +1,28 @@ + +// Internal dependencies +import { GraphHelper } from "$scripts/helpers" + +// Exports +export { GET } + + +// --------------------> API Endpoints + + +/** + * Get all subjects for a graph + * @returns `SerializedSubject[]` + */ + +async function GET({ params }) { + const subject_id = Number(params.id) + if (!subject_id || isNaN(subject_id)) { + return new Response('Invalid subject ID', { status: 400 }) + } + + return await GraphHelper.getSubjects(subject_id) + .then( + data => new Response(JSON.stringify(data), { status: 200 }), + error => new Response(error, { status: 400 }) + ) +} \ No newline at end of file diff --git a/src/routes/api/program/+server.ts b/src/routes/api/program/+server.ts index fb0d9b2..50d6485 100644 --- a/src/routes/api/program/+server.ts +++ b/src/routes/api/program/+server.ts @@ -20,7 +20,7 @@ async function POST({ request }) { // Retrieve data const { name } = await request.json() - if (!name) return new Response('Failed to create program: Missing name', { status: 400 }) + if (!name) return new Response('Missing name', { status: 400 }) // Create the program return await ProgramHelper.create(name) diff --git a/src/routes/api/program/[id]/admins/+server.ts b/src/routes/api/program/[id]/admins/+server.ts index 49a94c1..6203629 100644 --- a/src/routes/api/program/[id]/admins/+server.ts +++ b/src/routes/api/program/[id]/admins/+server.ts @@ -5,6 +5,7 @@ import { ProgramHelper } from "$scripts/helpers" // Exports export { GET } + // --------------------> API Endpoints diff --git a/src/routes/api/program/[id]/courses/+server.ts b/src/routes/api/program/[id]/courses/+server.ts index 06e4dd3..5bb2b0a 100644 --- a/src/routes/api/program/[id]/courses/+server.ts +++ b/src/routes/api/program/[id]/courses/+server.ts @@ -5,6 +5,7 @@ import { ProgramHelper } from "$scripts/helpers" // Exports export { GET } + // --------------------> API Endpoints diff --git a/src/routes/api/program/[id]/editors/+server.ts b/src/routes/api/program/[id]/editors/+server.ts index 251509b..081279a 100644 --- a/src/routes/api/program/[id]/editors/+server.ts +++ b/src/routes/api/program/[id]/editors/+server.ts @@ -5,6 +5,7 @@ import { ProgramHelper } from "$scripts/helpers" // Exports export { GET } + // --------------------> API Endpoints diff --git a/src/routes/app/course/[course]/overview/+page.server.ts b/src/routes/app/course/[course]/overview/+page.server.ts index e0887be..19261bf 100644 --- a/src/routes/app/course/[course]/overview/+page.server.ts +++ b/src/routes/app/course/[course]/overview/+page.server.ts @@ -1,16 +1,13 @@ -// Internal imports +// Internal dependencies import { CourseHelper } from '$scripts/helpers' -import type { SerializedCourse } from '$scripts/types' // Load export async function load({ params }) { - - // Retrieve data const course_id = Number(params.course) if (isNaN(course_id)) return Promise.reject('Invalid course ID') const course = await CourseHelper.getById(course_id) - .catch(error => Promise.reject(error)) as SerializedCourse + .catch(error => Promise.reject(error)) return { course } } diff --git a/src/routes/app/course/[course]/overview/+page.svelte b/src/routes/app/course/[course]/overview/+page.svelte index 6080a19..edcf515 100644 --- a/src/routes/app/course/[course]/overview/+page.svelte +++ b/src/routes/app/course/[course]/overview/+page.svelte @@ -5,7 +5,7 @@ import { writable } from 'svelte/store' // Internal imports - import { CourseController, GraphController } from '$scripts/controllers' + import { GraphController } from '$scripts/controllers' import { ValidationData, Severity } from '$scripts/validation' import { BaseModal } from '$scripts/modals' @@ -49,23 +49,18 @@ return result } - } - - // Functions - async function load() { - course.set( - await CourseController.revive(data.course) - .then(course => course.expand()) - ) - } - function update() { - course.update(() => $course) + async submit() { + await GraphController.create(environment, $course.id, this.name) + graph_modal.hide() + $course = $course + } } // Variables export let data - const course = writable() + const environment = data.environment + const course = writable(data.course) const graph_modal = new GraphModal() @@ -75,98 +70,95 @@ -{#await load() then} - - - - - - - -
- - Course settings - - -

Create Graph

- Add a new graph to this course. Graphs are visual representations of the course content. They are intended to help students understand the course structure. - -
- - - -
- - -
- -
- - - - - -

Graphs

- - - {#if $course.graphs.length === 0} + + + + + + +
+ + Course settings + + +

Create Graph

+ Add a new graph to this course. Graphs are visual representations of the course content. They are intended to help students understand the course structure. + +
+ + + +
+ + +
+ +
+ + + + + +

Graphs

+ + + {#await $course.graphs then graphs} + + {#if graphs.length === 0}

There's nothing here.

{/if} - {#each $course.graphs as graph} + {#each graphs as graph} - {#if graph.hasLinks()} + {#if true} Link icon {/if} {graph.name}
+ @@ -178,32 +170,28 @@ /> - {/each} - - - - -

Links

- - - {#if true} -

There's nothing here.

- {/if} -
-
- + {/await} + + -{/await} + +

Links

+ + {#if true} +

There's nothing here.

+ {/if} +
+
+ - diff --git a/src/routes/app/course/[course]/overview/+page.ts b/src/routes/app/course/[course]/overview/+page.ts index 1ab5370..3aa2475 100644 --- a/src/routes/app/course/[course]/overview/+page.ts +++ b/src/routes/app/course/[course]/overview/+page.ts @@ -1,14 +1,11 @@ // Internal dependencies -import { - ControllerEnvironment, - CourseController -} from '$scripts/controllers' +import { ControllerEnvironment } from '$scripts/controllers' // Load export async function load({ data }) { const environment = new ControllerEnvironment() - const course = CourseController.revive(environment, data.course) + const course = environment.get(data.course) return { environment, course } } \ No newline at end of file diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte index f70a8a2..ece82c7 100644 --- a/src/routes/app/dashboard/+page.svelte +++ b/src/routes/app/dashboard/+page.svelte @@ -281,20 +281,21 @@ These are the coordinators of the {program.name} program. You can contact them via email to request access to a course.

- +
    + {#await program.getAdmins() then admins} + {#each admins as admin} +
  • {admin.first_name} {admin.last_name}
  • + {/each} + {/await} +
- {#await program.courses then courses} + {#await program.getCourses() then courses} {#if !courses.some(course => courseMatchesQuery(query, course))} There's nothing here {:else} -
{#each courses as course} {#if courseMatchesQuery(query, course)} @@ -302,7 +303,6 @@ {/if} {/each}
- {/if} {/await}
diff --git a/src/routes/app/dashboard/+page.ts b/src/routes/app/dashboard/+page.ts index 8f9eae7..4ca9a7a 100644 --- a/src/routes/app/dashboard/+page.ts +++ b/src/routes/app/dashboard/+page.ts @@ -1,16 +1,12 @@ // Internal dependencies -import { - ControllerEnvironment, - ProgramController, - CourseController -} from '$scripts/controllers' +import { ControllerEnvironment} from '$scripts/controllers' // Load export async function load({ data }) { const environment = new ControllerEnvironment() - const programs = data.programs.map(datum => ProgramController.revive(environment, datum)) - const courses = data.courses.map(datum => CourseController.revive(environment, datum)) + const programs = data.programs.map(program => environment.get(program)) + const courses = data.courses.map(course => environment.get(course)) return { environment, programs, courses } } \ No newline at end of file From ad0883a38fd87609a64079227c8eecb6e074e39c Mon Sep 17 00:00:00 2001 From: Bluerberry Date: Tue, 15 Oct 2024 12:24:48 +0200 Subject: [PATCH 31/45] Course settings done --- src/lib/components/Dropdown.svelte | 20 ++-- src/lib/components/IconButton.svelte | 4 +- src/lib/components/Textfield.svelte | 25 +++-- .../scripts/controllers/CourseController.ts | 28 ++--- .../scripts/controllers/GraphController.ts | 49 ++++---- src/lib/scripts/controllers/LinkController.ts | 37 +++--- .../scripts/controllers/ProgramController.ts | 22 ++-- src/lib/scripts/helpers/GraphHelper.ts | 1 + src/lib/scripts/helpers/LinkHelper.ts | 10 +- src/lib/scripts/hocusfocus.ts | 32 +++++- src/lib/scripts/validation.ts | 7 +- src/routes/api/link/+server.ts | 4 +- .../app/course/[course]/overview/+page.svelte | 80 ++++++++----- .../course/[course]/overview/LinkURL.svelte | 106 ++++++++++++++++++ 14 files changed, 302 insertions(+), 123 deletions(-) create mode 100644 src/routes/app/course/[course]/overview/LinkURL.svelte diff --git a/src/lib/components/Dropdown.svelte b/src/lib/components/Dropdown.svelte index 3db74b7..1bdb7ce 100644 --- a/src/lib/components/Dropdown.svelte +++ b/src/lib/components/Dropdown.svelte @@ -2,13 +2,13 @@ @@ -61,7 +65,6 @@