Merge branch 'dev' into github-actions/coverage
This commit is contained in:
commit
4227b301fc
206 changed files with 14505 additions and 3467 deletions
|
|
@ -52,11 +52,18 @@ npm run test:unit
|
|||
### Run End-to-End Tests with [Playwright](https://playwright.dev)
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
|
||||
# Install browsers for the first run
|
||||
npx playwright install
|
||||
# On Ubuntu, you can also use
|
||||
npx playwright install --with-deps
|
||||
# to additionally install the dependencies.
|
||||
|
||||
# When testing on CI, must build the project first
|
||||
cd ..
|
||||
npm run build
|
||||
cd frontend
|
||||
|
||||
# Runs the end-to-end tests
|
||||
npm run test:e2e
|
||||
|
|
|
|||
86
frontend/e2e/basic-homepage.spec.ts
Normal file
86
frontend/e2e/basic-homepage.spec.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("User can pick their language", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("button", { name: "translate" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "translate" }).click();
|
||||
await page.getByText("Nederlands").click();
|
||||
await expect(page.locator("h1")).toContainText("Onze sterke punten");
|
||||
await expect(page.getByRole("heading", { name: "Innovatief" })).toBeVisible();
|
||||
|
||||
await page.getByRole("heading", { name: "Innovatief" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "vertalen" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "vertalen" }).click();
|
||||
await page.getByText("English").click();
|
||||
await expect(page.locator("h1")).toContainText("Our strengths");
|
||||
await expect(page.getByRole("heading", { name: "Innovative" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Teacher can sign in", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("link", { name: "log in" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "log in" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "teacher" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "teacher" }).click();
|
||||
|
||||
await expect(page.getByText("teacher")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/realms\/teacher\//);
|
||||
|
||||
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1");
|
||||
await page.getByRole("textbox", { name: "Password" }).fill("password");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page.getByRole("link", { name: "Dwengo logo teacher" })).toBeVisible();
|
||||
await expect(page.getByRole("button").nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Student can sign in", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("link", { name: "log in" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "log in" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "student" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "student" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/realms\/student\//);
|
||||
|
||||
await expect(page.getByText("student")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
|
||||
|
||||
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1");
|
||||
await page.getByRole("textbox", { name: "Password" }).fill("password");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page.getByRole("link", { name: "Dwengo logo student" })).toBeVisible();
|
||||
await expect(page.getByRole("button").nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Cannot sign in with invalid credentials", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("link", { name: "log in" }).click();
|
||||
await page.getByRole("button", { name: "teacher" }).click();
|
||||
await page.getByRole("textbox", { name: "Username or email" }).fill("doesnotexist");
|
||||
await page.getByRole("textbox", { name: "Password" }).fill("wrong");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await expect(page.getByText("Invalid username or password.")).toBeVisible();
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("link", { name: "log in" }).click();
|
||||
await page.getByRole("button", { name: "student" }).click();
|
||||
await page.getByRole("textbox", { name: "Username or email" }).fill("doesnotexist");
|
||||
await page.getByRole("textbox", { name: "Password" }).fill("wrong");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await expect(page.getByText("Invalid username or password.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Cannot skip login", async ({ page }) => {
|
||||
await page.goto("/user");
|
||||
// Should redirect to login
|
||||
await expect(page.getByText("login")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "teacher" })).toBeVisible();
|
||||
});
|
||||
12
frontend/e2e/basic-learning.spec.ts
Normal file
12
frontend/e2e/basic-learning.spec.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
test("Users can filter", async ({ page }) => {
|
||||
await page.goto("/user");
|
||||
|
||||
await page.getByRole("combobox").filter({ hasText: "Select a themeAll" }).locator("i").click();
|
||||
await page.getByText("Nature and climate").click();
|
||||
await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click();
|
||||
await page.getByText("and older").click();
|
||||
|
||||
await expect(page.getByRole("link", { name: "AI and Climate Students in" })).toBeVisible();
|
||||
});
|
||||
5
frontend/e2e/basic-learning.ts
Normal file
5
frontend/e2e/basic-learning.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
test("myTest", async ({ page }) => {
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
116
frontend/e2e/fixtures.ts
Normal file
116
frontend/e2e/fixtures.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/* eslint-disable no-await-in-loop */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { test as baseTest, expect } from "@playwright/test";
|
||||
import type { Browser } from "playwright-core";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/* Based on https://playwright.dev/docs/auth#moderate-one-account-per-parallel-worker */
|
||||
|
||||
export * from "@playwright/test";
|
||||
export const ROOT_URL = "http://localhost:5173";
|
||||
|
||||
interface Account {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an account by logging in or creating a new one.
|
||||
* @param id
|
||||
* @param browser
|
||||
*/
|
||||
async function acquireAccount(id: number, browser: Browser): Promise<Account> {
|
||||
const account = {
|
||||
username: `worker${id}`,
|
||||
password: "password",
|
||||
};
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.goto(ROOT_URL);
|
||||
|
||||
await page.getByRole("link", { name: "log in" }).click();
|
||||
await page.getByRole("button", { name: "student" }).click();
|
||||
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(account.username);
|
||||
await page.getByRole("textbox", { name: "Password", exact: true }).fill(account.password);
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
let failed = await page.getByText("Invalid username or password.").isVisible();
|
||||
|
||||
if (failed) {
|
||||
await page.getByRole("link", { name: "Register" }).click();
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
let retries = 0;
|
||||
while (failed && retries < MAX_RETRIES) {
|
||||
// Retry with a different username, based on Unix timestamp.
|
||||
account.username = `worker${id}-${Date.now()}`;
|
||||
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(account.username);
|
||||
await page.getByRole("textbox", { name: "Password", exact: true }).fill(account.password);
|
||||
await page.getByRole("textbox", { name: "Confirm password" }).fill(account.password);
|
||||
await page.getByRole("textbox", { name: "Email" }).fill(`${account.username}@dwengo.org`);
|
||||
await page.getByRole("textbox", { name: "First name" }).fill("Worker");
|
||||
await page.getByRole("textbox", { name: "Last name" }).fill(id.toString());
|
||||
await page.getByRole("button", { name: "Register" }).click();
|
||||
|
||||
await page.waitForURL(/localhost/);
|
||||
|
||||
failed = await page.getByText("Username already exists.").isVisible();
|
||||
retries += failed ? 1 : 0;
|
||||
}
|
||||
|
||||
await page.waitForURL(/localhost/);
|
||||
await page.close();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
export const test = baseTest.extend<object, { workerStorageState: string }>({
|
||||
// Use the same storage state for all tests in this worker.
|
||||
storageState: async ({ workerStorageState }, use) => use(workerStorageState),
|
||||
|
||||
// Authenticate once per worker with a worker-scoped fixture.
|
||||
workerStorageState: [
|
||||
async ({ browser }, use): Promise<void> => {
|
||||
// Use parallelIndex as a unique identifier for each worker.
|
||||
const id = test.info().parallelIndex;
|
||||
const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);
|
||||
|
||||
if (fs.existsSync(fileName)) {
|
||||
// Reuse existing authentication state if any.
|
||||
await use(fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Important: make sure we authenticate in a clean environment by unsetting storage state.
|
||||
const page = await browser.newPage({ storageState: undefined });
|
||||
|
||||
// Acquire a unique account by creating a new one.
|
||||
const account = await acquireAccount(id, browser);
|
||||
|
||||
// Perform authentication steps. Replace these actions with your own.
|
||||
await page.goto(ROOT_URL);
|
||||
await page.getByRole("link", { name: "log in" }).click();
|
||||
await page.getByRole("button", { name: "student" }).click();
|
||||
await page.getByRole("textbox", { name: "Username or email" }).fill(account.username);
|
||||
await page.getByRole("textbox", { name: "Password" }).fill(account.password);
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
// Wait until the page receives the cookies.
|
||||
//
|
||||
// Sometimes login flow sets cookies in the process of several redirects.
|
||||
// Wait for the final URL to ensure that the cookies are actually set.
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
// Alternatively, you can wait until the page reaches a state where all cookies are set.
|
||||
|
||||
// End of authentication steps.
|
||||
|
||||
await page.context().storageState({ path: fileName });
|
||||
await page.close();
|
||||
await use(fileName);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// See here how to get started:
|
||||
// https://playwright.dev/docs/intro
|
||||
test("visits the app root url", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("h1")).toHaveText("You did it!");
|
||||
});
|
||||
|
|
@ -21,7 +21,14 @@ const vueConfig = defineConfigWithVueTs(
|
|||
|
||||
{
|
||||
name: "app/files-to-ignore",
|
||||
ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**", "prettier.config.js"],
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/dist-ssr/**",
|
||||
"**/coverage/**",
|
||||
"prettier.config.js",
|
||||
"**/test-results/**",
|
||||
"**/playwright-report/**",
|
||||
],
|
||||
},
|
||||
|
||||
pluginVue.configs["flat/essential"],
|
||||
|
|
|
|||
|
|
@ -19,16 +19,18 @@
|
|||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@tanstack/vue-query": "^5.69.0",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.8.2",
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.2",
|
||||
"vue-router": "^4.5.0",
|
||||
"vuetify": "^3.7.12"
|
||||
"vuetify": "^3.7.12",
|
||||
"wait-on": "^8.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@tsconfig/node22": "^22.0.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^22.13.4",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default defineConfig({
|
|||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
|
|
@ -65,18 +65,18 @@ export default defineConfig({
|
|||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// Name: 'Mobile Chrome',
|
||||
// Use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// Name: 'Mobile Safari',
|
||||
// Use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: "Mobile Chrome",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Mobile Safari",
|
||||
use: {
|
||||
...devices["iPhone 12"],
|
||||
},
|
||||
},
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
|
|
@ -97,14 +97,25 @@ export default defineConfig({
|
|||
// OutputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
/**
|
||||
* Use the dev server by default for faster feedback loop.
|
||||
* Use the preview server on CI for more realistic testing.
|
||||
* Playwright will re-use the local server if there is already a dev-server running.
|
||||
*/
|
||||
command: process.env.CI ? "npm run preview" : "npm run dev",
|
||||
port: process.env.CI ? 4173 : 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
webServer: [
|
||||
// Assuming the idp is already running (because it is slow)
|
||||
{
|
||||
/* Frontend */
|
||||
command: `VITE_API_BASE_URL='http://localhost:9876/api' ${process.env.CI ? "npm run preview" : "npm run dev"}`,
|
||||
port: process.env.CI ? 4173 : 5173,
|
||||
timeout: 120 * 1000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
/* Backend */
|
||||
command: `
|
||||
cd .. \
|
||||
&& npx tsc --build common/tsconfig.json \
|
||||
&& cd backend \
|
||||
&& npx tsx --env-file=./.env.test ./tool/startTestApp.ts
|
||||
`,
|
||||
port: 9876,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
import MenuBar from "@/components/MenuBar.vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { computed } from "vue";
|
||||
import authService from "@/services/auth/auth-service.ts";
|
||||
|
||||
void authService.loadUser();
|
||||
|
||||
const route = useRoute();
|
||||
interface RouteMeta {
|
||||
|
|
@ -10,10 +13,6 @@
|
|||
}
|
||||
|
||||
const showMenuBar = computed(() => (route.meta as RouteMeta).requiresAuth && auth.authState.user);
|
||||
|
||||
auth.loadUser().catch((_error) => {
|
||||
// TODO Could not load user!
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
49
frontend/src/assets/assignment.css
Normal file
49
frontend/src/assets/assignment.css
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.assignment-card {
|
||||
width: 80%;
|
||||
padding: 2%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 2%;
|
||||
line-height: 1.6;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.top-right-btn {
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.group-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.group-section h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.group-section ul {
|
||||
padding-left: 1.2rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.subtitle-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.assignmentTopTitle {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
selectedAge: { type: String, required: true },
|
||||
});
|
||||
|
||||
const { locale } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const language = computed(() => locale.value);
|
||||
|
||||
const { data: allThemes, isLoading, error } = useThemeQuery(language);
|
||||
|
|
@ -74,6 +74,22 @@
|
|||
class="fill-height"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="4"
|
||||
class="d-flex"
|
||||
>
|
||||
<ThemeCard
|
||||
path="/learningPath/search"
|
||||
:is-absolute-path="true"
|
||||
:title="t('searchAllLearningPathsTitle')"
|
||||
:description="t('searchAllLearningPathsDescription')"
|
||||
icon="mdi-magnify"
|
||||
class="fill-height"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
const { t, locale } = useI18n();
|
||||
|
||||
const role = auth.authState.activeRole;
|
||||
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
|
||||
|
||||
const name: string = auth.authState.user!.profile.name!;
|
||||
const initials: string = name
|
||||
|
|
@ -80,13 +82,14 @@
|
|||
>
|
||||
{{ t("classes") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="menu_item"
|
||||
variant="text"
|
||||
to="/user/discussion"
|
||||
>
|
||||
{{ t("discussions") }}
|
||||
</v-btn>
|
||||
<!-- TODO Re-enable this button when the discussion page is ready -->
|
||||
<!-- <v-btn-->
|
||||
<!-- class="menu_item"-->
|
||||
<!-- variant="text"-->
|
||||
<!-- to="/user/discussion"-->
|
||||
<!-- >-->
|
||||
<!-- {{ t("discussions") }}-->
|
||||
<!-- </v-btn>-->
|
||||
<v-menu open-on-hover>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
path: string;
|
||||
isAbsolutePath?: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
image?: string;
|
||||
icon?: string;
|
||||
}>();
|
||||
|
||||
const routerLink = computed(() => (props.isAbsolutePath ? props.path : `/theme/${props.path}`));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="theme-card d-flex flex-column"
|
||||
:to="`theme/${path}`"
|
||||
:to="routerLink"
|
||||
link
|
||||
>
|
||||
<v-card-title class="title-container">
|
||||
|
|
@ -27,12 +32,18 @@
|
|||
contain
|
||||
class="title-image"
|
||||
></v-img>
|
||||
<v-icon
|
||||
v-if="icon"
|
||||
class="title-image"
|
||||
>{{ icon }}</v-icon
|
||||
>
|
||||
|
||||
<span class="title">{{ title }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="description flex-grow-1">{{ description }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
:to="`theme/${path}`"
|
||||
:to="routerLink"
|
||||
variant="text"
|
||||
>
|
||||
{{ t("read-more") }}
|
||||
|
|
|
|||
49
frontend/src/components/assignments/DeadlineSelector.vue
Normal file
49
frontend/src/components/assignments/DeadlineSelector.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { deadlineRules } from "@/utils/assignment-rules.ts";
|
||||
|
||||
const date = ref("");
|
||||
const time = ref("23:59");
|
||||
const emit = defineEmits(["update:deadline"]);
|
||||
|
||||
const formattedDeadline = computed(() => {
|
||||
if (!date.value || !time.value) return "";
|
||||
return `${date.value} ${time.value}`;
|
||||
});
|
||||
|
||||
function updateDeadline(): void {
|
||||
if (date.value && time.value) {
|
||||
emit("update:deadline", formattedDeadline.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="date"
|
||||
label="Select Deadline Date"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="deadlineRules"
|
||||
required
|
||||
@update:modelValue="updateDeadline"
|
||||
></v-text-field>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="time"
|
||||
label="Select Deadline Time"
|
||||
type="time"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
@update:modelValue="updateDeadline"
|
||||
></v-text-field>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
75
frontend/src/components/assignments/GroupSelector.vue
Normal file
75
frontend/src/components/assignments/GroupSelector.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import type { StudentsResponse } from "@/controllers/students.ts";
|
||||
import { useClassStudentsQuery } from "@/queries/classes.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
classId: string | undefined;
|
||||
groups: string[][];
|
||||
}>();
|
||||
const emit = defineEmits(["groupCreated"]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedStudents = ref([]);
|
||||
|
||||
const studentQueryResult = useClassStudentsQuery(() => props.classId, true);
|
||||
|
||||
function filterStudents(data: StudentsResponse): { title: string; value: string }[] {
|
||||
const students = data.students;
|
||||
const studentsInGroups = props.groups.flat();
|
||||
|
||||
return students
|
||||
?.map((st) => ({
|
||||
title: `${st.firstName} ${st.lastName}`,
|
||||
value: st.username,
|
||||
}))
|
||||
.filter((student) => !studentsInGroups.includes(student.value));
|
||||
}
|
||||
|
||||
function createGroup(): void {
|
||||
if (selectedStudents.value.length) {
|
||||
// Extract only usernames (student.value)
|
||||
const usernames = selectedStudents.value.map((student) => student.value);
|
||||
emit("groupCreated", usernames);
|
||||
selectedStudents.value = []; // Reset selection after creating group
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<using-query-result
|
||||
:query-result="studentQueryResult"
|
||||
v-slot="{ data }: { data: StudentsResponse }"
|
||||
>
|
||||
<h3>{{ t("create-groups") }}</h3>
|
||||
<v-card-text>
|
||||
<v-combobox
|
||||
v-model="selectedStudents"
|
||||
:items="filterStudents(data)"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="t('choose-students')"
|
||||
variant="outlined"
|
||||
clearable
|
||||
multiple
|
||||
hide-details
|
||||
density="compact"
|
||||
chips
|
||||
append-inner-icon="mdi-magnify"
|
||||
></v-combobox>
|
||||
|
||||
<v-btn
|
||||
@click="createGroup"
|
||||
color="primary"
|
||||
class="mt-2"
|
||||
size="small"
|
||||
>
|
||||
{{ t("create-group") }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</using-query-result>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,8 +1,24 @@
|
|||
export const apiConfig = {
|
||||
baseUrl:
|
||||
window.location.hostname === "localhost" && !(window.location.port === "80" || window.location.port === "")
|
||||
? "http://localhost:3000/api"
|
||||
: window.location.origin + "/api",
|
||||
baseUrl: ((): string => {
|
||||
if (import.meta.env.MODE === "test") {
|
||||
// TODO Remove hardcoding
|
||||
return "http://localhost:9876/api";
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_API_BASE_URL) {
|
||||
return import.meta.env.VITE_API_BASE_URL;
|
||||
}
|
||||
|
||||
if (
|
||||
window.location.hostname === "localhost" &&
|
||||
!(window.location.port === "80" || window.location.port === "")
|
||||
) {
|
||||
return "http://localhost:3000/api";
|
||||
}
|
||||
|
||||
// Fallback to the current origin with "/api" suffix
|
||||
return `${window.location.origin}/api`;
|
||||
})(),
|
||||
};
|
||||
|
||||
export const loginRoute = "/login";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { BaseController } from "./base-controller";
|
||||
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
|
||||
import type { AssignmentDTO, AssignmentDTOId } from "@dwengo-1/common/interfaces/assignment";
|
||||
import type { SubmissionsResponse } from "./submissions";
|
||||
import type { QuestionsResponse } from "./questions";
|
||||
import type { GroupsResponse } from "./groups";
|
||||
|
||||
export interface AssignmentsResponse {
|
||||
assignments: AssignmentDTO[] | string[];
|
||||
assignments: AssignmentDTO[] | AssignmentDTOId[];
|
||||
}
|
||||
|
||||
export interface AssignmentResponse {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import apiClient from "@/services/api-client/api-client.ts";
|
||||
import type { AxiosResponse, ResponseType } from "axios";
|
||||
import { HttpErrorResponseException } from "@/exception/http-error-response-exception.ts";
|
||||
import { apiConfig } from "@/config.ts";
|
||||
|
||||
export abstract class BaseController {
|
||||
protected basePath: string;
|
||||
|
|
@ -16,9 +17,18 @@ export abstract class BaseController {
|
|||
}
|
||||
|
||||
protected async get<T>(path: string, queryParams?: QueryParams, responseType?: ResponseType): Promise<T> {
|
||||
const response = await apiClient.get<T>(this.absolutePathFor(path), { params: queryParams, responseType });
|
||||
BaseController.assertSuccessResponse(response);
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.get<T>(this.absolutePathFor(path), { params: queryParams, responseType });
|
||||
BaseController.assertSuccessResponse(response);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpErrorResponseException) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`An unexpected error occurred while fetching data from ${apiConfig.baseUrl}${this.absolutePathFor(path)}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async post<T>(path: string, body: unknown, queryParams?: QueryParams): Promise<T> {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { BaseController } from "./base-controller";
|
|||
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
|
||||
import type { StudentsResponse } from "./students";
|
||||
import type { AssignmentsResponse } from "./assignments";
|
||||
import type { TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation";
|
||||
import type { TeachersResponse } from "@/controllers/teachers.ts";
|
||||
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations.ts";
|
||||
|
||||
export interface ClassesResponse {
|
||||
classes: ClassDTO[] | string[];
|
||||
|
|
@ -13,14 +13,6 @@ export interface ClassResponse {
|
|||
class: ClassDTO;
|
||||
}
|
||||
|
||||
export interface TeacherInvitationsResponse {
|
||||
invites: TeacherInvitationDTO[];
|
||||
}
|
||||
|
||||
export interface TeacherInvitationResponse {
|
||||
invite: TeacherInvitationDTO;
|
||||
}
|
||||
|
||||
export class ClassController extends BaseController {
|
||||
constructor() {
|
||||
super("class");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { BaseController } from "./base-controller";
|
||||
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
|
||||
import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group";
|
||||
import type { SubmissionsResponse } from "./submissions";
|
||||
import type { QuestionsResponse } from "./questions";
|
||||
|
||||
export interface GroupsResponse {
|
||||
groups: GroupDTO[];
|
||||
groups: GroupDTO[] | GroupDTOId[];
|
||||
}
|
||||
|
||||
export interface GroupResponse {
|
||||
|
|
@ -36,11 +36,11 @@ export class GroupController extends BaseController {
|
|||
return this.put<GroupResponse>(`/${num}`, data);
|
||||
}
|
||||
|
||||
async getSubmissions(groupNumber: number, full = true): Promise<SubmissionsResponse> {
|
||||
return this.get<SubmissionsResponse>(`/${groupNumber}/submissions`, { full });
|
||||
async getSubmissions(num: number, full = true): Promise<SubmissionsResponse> {
|
||||
return this.get<SubmissionsResponse>(`/${num}/submissions`, { full });
|
||||
}
|
||||
|
||||
async getQuestions(groupNumber: number, full = true): Promise<QuestionsResponse> {
|
||||
return this.get<QuestionsResponse>(`/${groupNumber}/questions`, { full });
|
||||
async getQuestions(num: number, full = true): Promise<QuestionsResponse> {
|
||||
return this.get<QuestionsResponse>(`/${num}/questions`, { full });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,20 +8,21 @@ export class LearningPathController extends BaseController {
|
|||
constructor() {
|
||||
super("learningPath");
|
||||
}
|
||||
async search(query: string): Promise<LearningPath[]> {
|
||||
const dtos = await this.get<LearningPathDTO[]>("/", { search: query });
|
||||
async search(query: string, language: string): Promise<LearningPath[]> {
|
||||
const dtos = await this.get<LearningPathDTO[]>("/", { search: query, language });
|
||||
return dtos.map((dto) => LearningPath.fromDTO(dto));
|
||||
}
|
||||
async getBy(
|
||||
hruid: string,
|
||||
language: Language,
|
||||
options?: { forGroup?: string; forStudent?: string },
|
||||
forGroup?: { forGroup: number; assignmentNo: number; classId: string },
|
||||
): Promise<LearningPath> {
|
||||
const dtos = await this.get<LearningPathDTO[]>("/", {
|
||||
hruid,
|
||||
language,
|
||||
forGroup: options?.forGroup,
|
||||
forStudent: options?.forStudent,
|
||||
forGroup: forGroup?.forGroup,
|
||||
assignmentNo: forGroup?.assignmentNo,
|
||||
classId: forGroup?.classId,
|
||||
});
|
||||
return LearningPath.fromDTO(single(dtos));
|
||||
}
|
||||
|
|
@ -29,4 +30,10 @@ export class LearningPathController extends BaseController {
|
|||
const dtos = await this.get<LearningPathDTO[]>("/", { theme });
|
||||
return dtos.map((dto) => LearningPath.fromDTO(dto));
|
||||
}
|
||||
|
||||
async getAllLearningPaths(language: string | null = null): Promise<LearningPath[]> {
|
||||
const query = language ? { language } : undefined;
|
||||
const dtos = await this.get<LearningPathDTO[]>("/", query);
|
||||
return dtos.map((dto) => LearningPath.fromDTO(dto));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export interface StudentResponse {
|
|||
student: StudentDTO;
|
||||
}
|
||||
export interface JoinRequestsResponse {
|
||||
requests: ClassJoinRequestDTO[];
|
||||
joinRequests: ClassJoinRequestDTO[];
|
||||
}
|
||||
export interface JoinRequestResponse {
|
||||
request: ClassJoinRequestDTO;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BaseController } from "./base-controller";
|
||||
import type { SubmissionDTO, SubmissionDTOId } from "@dwengo-1/common/interfaces/submission";
|
||||
import type { Language } from "@dwengo-1/common/util/language";
|
||||
|
||||
export interface SubmissionsResponse {
|
||||
submissions: SubmissionDTO[] | SubmissionDTOId[];
|
||||
|
|
@ -10,19 +11,39 @@ export interface SubmissionResponse {
|
|||
}
|
||||
|
||||
export class SubmissionController extends BaseController {
|
||||
constructor(classid: string, assignmentNumber: number, groupNumber: number) {
|
||||
super(`class/${classid}/assignments/${assignmentNumber}/groups/${groupNumber}`);
|
||||
constructor(hruid: string) {
|
||||
super(`learningObject/${hruid}/submissions`);
|
||||
}
|
||||
|
||||
async getAll(full = true): Promise<SubmissionsResponse> {
|
||||
return this.get<SubmissionsResponse>(`/`, { full });
|
||||
async getAll(
|
||||
language: Language,
|
||||
version: number,
|
||||
classId: string,
|
||||
assignmentId: number,
|
||||
groupId?: number,
|
||||
full = true,
|
||||
): Promise<SubmissionsResponse> {
|
||||
return this.get<SubmissionsResponse>(`/`, { language, version, classId, assignmentId, groupId, full });
|
||||
}
|
||||
|
||||
async getByNumber(submissionNumber: number): Promise<SubmissionResponse> {
|
||||
return this.get<SubmissionResponse>(`/${submissionNumber}`);
|
||||
async getByNumber(
|
||||
language: Language,
|
||||
version: number,
|
||||
classId: string,
|
||||
assignmentId: number,
|
||||
groupId: number,
|
||||
submissionNumber: number,
|
||||
): Promise<SubmissionResponse> {
|
||||
return this.get<SubmissionResponse>(`/${submissionNumber}`, {
|
||||
language,
|
||||
version,
|
||||
classId,
|
||||
assignmentId,
|
||||
groupId,
|
||||
});
|
||||
}
|
||||
|
||||
async createSubmission(data: unknown): Promise<SubmissionResponse> {
|
||||
async createSubmission(data: SubmissionDTO): Promise<SubmissionResponse> {
|
||||
return this.post<SubmissionResponse>(`/`, data);
|
||||
}
|
||||
|
||||
|
|
|
|||
37
frontend/src/controllers/teacher-invitations.ts
Normal file
37
frontend/src/controllers/teacher-invitations.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { BaseController } from "@/controllers/base-controller.ts";
|
||||
import type { TeacherInvitationData, TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation";
|
||||
|
||||
export interface TeacherInvitationsResponse {
|
||||
invitations: TeacherInvitationDTO[];
|
||||
}
|
||||
|
||||
export interface TeacherInvitationResponse {
|
||||
invitation: TeacherInvitationDTO;
|
||||
}
|
||||
|
||||
export class TeacherInvitationController extends BaseController {
|
||||
constructor() {
|
||||
super("teacher/invitations");
|
||||
}
|
||||
|
||||
async getAll(username: string, s: boolean): Promise<TeacherInvitationsResponse> {
|
||||
const sent = s.toString();
|
||||
return this.get<TeacherInvitationsResponse>(`/${username}`, { sent });
|
||||
}
|
||||
|
||||
async getBy(data: TeacherInvitationData): Promise<TeacherInvitationResponse> {
|
||||
return this.get<TeacherInvitationResponse>(`/${data.sender}/${data.receiver}/${data.class}`);
|
||||
}
|
||||
|
||||
async create(data: TeacherInvitationData): Promise<TeacherInvitationResponse> {
|
||||
return this.post<TeacherInvitationResponse>("/", data);
|
||||
}
|
||||
|
||||
async remove(data: TeacherInvitationData): Promise<TeacherInvitationResponse> {
|
||||
return this.delete<TeacherInvitationResponse>(`/${data.sender}/${data.receiver}/${data.class}`);
|
||||
}
|
||||
|
||||
async respond(data: TeacherInvitationData): Promise<TeacherInvitationResponse> {
|
||||
return this.put<TeacherInvitationResponse>("/", data);
|
||||
}
|
||||
}
|
||||
|
|
@ -54,10 +54,9 @@ export class TeacherController extends BaseController {
|
|||
studentUsername: string,
|
||||
accepted: boolean,
|
||||
): Promise<JoinRequestResponse> {
|
||||
return this.put<JoinRequestResponse>(
|
||||
`/${teacherUsername}/joinRequests/${classId}/${studentUsername}`,
|
||||
return this.put<JoinRequestResponse>(`/${teacherUsername}/joinRequests/${classId}/${studentUsername}`, {
|
||||
accepted,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// GetInvitations(id: string) {return this.get<{ invitations: string[] }>(`/${id}/invitations`);}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"welcome": "Willkommen",
|
||||
"student": "schüler",
|
||||
"teacher": "lehrer",
|
||||
"student": "Schüler",
|
||||
"teacher": "Lehrer",
|
||||
"assignments": "Aufgaben",
|
||||
"classes": "Klasses",
|
||||
"classes": "Klassen",
|
||||
"discussions": "Diskussionen",
|
||||
"login": "einloggen",
|
||||
"logout": "ausloggen",
|
||||
"cancel": "kündigen",
|
||||
"cancel": "abbrechen",
|
||||
"logoutVerification": "Sind Sie sicher, dass Sie sich abmelden wollen?",
|
||||
"homeTitle": "Unsere Stärken",
|
||||
"homeIntroduction1": "Wir entwickeln innovative Workshops und Bildungsressourcen, die wir in Zusammenarbeit mit Lehrern und Freiwilligen Schülern auf der ganzen Welt zur Verfügung stellen. Unsere Train-the-Trainer-Sitzungen ermöglichen es ihnen, unsere praktischen Workshops an die Schüler weiterzugeben.",
|
||||
|
|
@ -23,10 +23,10 @@
|
|||
"submitCode": "senden",
|
||||
"members": "Mitglieder",
|
||||
"themes": "Themen",
|
||||
"choose-theme": "Wähle ein thema",
|
||||
"choose-theme": "Wählen Sie ein Thema",
|
||||
"choose-age": "Alter auswählen",
|
||||
"theme-options": {
|
||||
"all": "Alle themen",
|
||||
"all": "Alle Themen",
|
||||
"culture": "Kultur",
|
||||
"electricity-and-mechanics": "Elektrizität und Mechanik",
|
||||
"nature-and-climate": "Natur und Klima",
|
||||
|
|
@ -37,11 +37,11 @@
|
|||
"algorithms": "Algorithmisches Denken"
|
||||
},
|
||||
"age-options": {
|
||||
"all": "Alle altersgruppen",
|
||||
"all": "Alle Altersgruppen",
|
||||
"primary-school": "Grundschule",
|
||||
"lower-secondary": "12-14 jahre alt",
|
||||
"upper-secondary": "14-16 jahre alt",
|
||||
"high-school": "16-18 jahre alt",
|
||||
"lower-secondary": "12-14 Jahre alt",
|
||||
"upper-secondary": "14-16 Jahre alt",
|
||||
"high-school": "16-18 Jahre alt",
|
||||
"older": "18 und älter"
|
||||
},
|
||||
"read-more": "Mehr lesen",
|
||||
|
|
@ -57,8 +57,21 @@
|
|||
"legendNotCompletedYet": "Noch nicht fertig",
|
||||
"legendCompleted": "Fertig",
|
||||
"legendTeacherExclusive": "Information für Lehrkräfte",
|
||||
"new-assignment": "Neue Aufgabe",
|
||||
"edit-assignment": "Zuordnung bearbeiten",
|
||||
"groups": "Gruppen",
|
||||
"learning-path": "Lernpfad",
|
||||
"choose-lp": "Einen lernpfad auswählen",
|
||||
"choose-classes": "Klassen wählen",
|
||||
"create-groups": "Gruppen erstellen",
|
||||
"title": "Titel",
|
||||
"pick-class": "Wählen Sie eine klasse",
|
||||
"choose-students": "Studenten auswählen",
|
||||
"create-group": "Gruppe erstellen",
|
||||
"class": "klasse",
|
||||
"delete": "löschen",
|
||||
"view-assignment": "Auftrag anzeigen",
|
||||
"code": "code",
|
||||
"class": "Klasse",
|
||||
"invitations": "Einladungen",
|
||||
"createClass": "Klasse erstellen",
|
||||
"createClassInstructions": "Geben Sie einen Namen für Ihre Klasse ein und klicken Sie auf „Erstellen“. Es erscheint ein Fenster mit einem Code, den Sie kopieren können. Geben Sie diesen Code an Ihre Schüler weiter und sie können Ihrer Klasse beitreten.",
|
||||
|
|
@ -73,7 +86,39 @@
|
|||
"accept": "akzeptieren",
|
||||
"deny": "ablehnen",
|
||||
"sent": "sent",
|
||||
"failed": "gescheitert",
|
||||
"failed": "fehlgeschlagen",
|
||||
"wrong": "etwas ist schief gelaufen",
|
||||
"created": "erstellt"
|
||||
"created": "erstellt",
|
||||
"callbackLoading": "Sie werden angemeldet...",
|
||||
"loginUnexpectedError": "Anmeldung fehlgeschlagen",
|
||||
"submitSolution": "Lösung einreichen",
|
||||
"submitNewSolution": "Neue Lösung einreichen",
|
||||
"markAsDone": "Als fertig markieren",
|
||||
"groupSubmissions": "Einreichungen dieser Gruppe",
|
||||
"taskCompleted": "Aufgabe erledigt.",
|
||||
"submittedBy": "Eingereicht von",
|
||||
"timestamp": "Zeitpunkt",
|
||||
"loadSubmission": "Einladen",
|
||||
"noSubmissionsYet": "Noch keine Lösungen eingereicht.",
|
||||
"viewAsGroup": "Fortschritt ansehen von Gruppe...",
|
||||
"assignLearningPath": "Als Aufgabe geben",
|
||||
"group": "Gruppe",
|
||||
"description": "Beschreibung",
|
||||
"no-submission": "keine vorlage",
|
||||
"submission": "Einreichung",
|
||||
"progress": "Fortschritte",
|
||||
"remove": "entfernen",
|
||||
"students": "Studenten",
|
||||
"classJoinRequests": "Beitrittsanfragen",
|
||||
"reject": "ablehnen",
|
||||
"areusure": "Sind Sie sicher?",
|
||||
"yes": "ja",
|
||||
"teachers": "Lehrer",
|
||||
"rejected": "abgelehnt",
|
||||
"accepted": "akzeptiert",
|
||||
"enterUsername": "Geben Sie den Benutzernamen der Lehrkraft ein, die Sie einladen möchten",
|
||||
"username": "Nutzername",
|
||||
"invite": "einladen",
|
||||
"searchAllLearningPathsTitle": "Alle Lernpfade durchsuchen",
|
||||
"searchAllLearningPathsDescription": "Nicht gefunden, was Sie gesucht haben? Klicken Sie hier, um unsere gesamte Lernpfad-Datenbank zu durchsuchen."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
"welcome": "Welcome",
|
||||
"student": "student",
|
||||
"teacher": "teacher",
|
||||
"assignments": "assignments",
|
||||
"classes": "classes",
|
||||
"assignments": "Assignments",
|
||||
"classes": "Classes",
|
||||
"discussions": "discussions",
|
||||
"logout": "log out",
|
||||
"error_title": "Error",
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"JoinClassExplanation": "Enter the code the teacher has given you to join the class.",
|
||||
"invalidFormat": "Invalid format.",
|
||||
"submitCode": "submit",
|
||||
"members": "members",
|
||||
"members": "Members",
|
||||
"themes": "Themes",
|
||||
"choose-theme": "Select a theme",
|
||||
"choose-age": "Select age",
|
||||
|
|
@ -57,8 +57,21 @@
|
|||
"older": "18 and older"
|
||||
},
|
||||
"read-more": "Read more",
|
||||
"code": "code",
|
||||
"new-assignment": "New Assignment",
|
||||
"edit-assignment": "Edit Assignment",
|
||||
"groups": "Groups",
|
||||
"learning-path": "Learning path",
|
||||
"choose-lp": "Select a learning path",
|
||||
"choose-classes": "Select classes",
|
||||
"create-groups": "Create groups",
|
||||
"title": "Title",
|
||||
"pick-class": "Pick a class",
|
||||
"choose-students": "Select students",
|
||||
"create-group": "Create group",
|
||||
"class": "class",
|
||||
"delete": "delete",
|
||||
"view-assignment": "View assignment",
|
||||
"code": "code",
|
||||
"invitations": "invitations",
|
||||
"createClass": "create class",
|
||||
"classname": "classname",
|
||||
|
|
@ -75,5 +88,37 @@
|
|||
"sent": "sent",
|
||||
"failed": "failed",
|
||||
"wrong": "something went wrong",
|
||||
"created": "created"
|
||||
"callbackLoading": "You are being logged in...",
|
||||
"loginUnexpectedError": "Login failed",
|
||||
"submitSolution": "Submit solution",
|
||||
"submitNewSolution": "Submit new solution",
|
||||
"markAsDone": "Mark as completed",
|
||||
"groupSubmissions": "This group's submissions",
|
||||
"taskCompleted": "Task completed.",
|
||||
"submittedBy": "Submitted by",
|
||||
"timestamp": "Timestamp",
|
||||
"loadSubmission": "Load",
|
||||
"noSubmissionsYet": "No submissions yet.",
|
||||
"viewAsGroup": "View progress of group...",
|
||||
"assignLearningPath": "assign",
|
||||
"group": "Group",
|
||||
"description": "Description",
|
||||
"no-submission": "no submission",
|
||||
"submission": "Submission",
|
||||
"progress": "Progress",
|
||||
"created": "created",
|
||||
"remove": "remove",
|
||||
"students": "students",
|
||||
"classJoinRequests": "join requests",
|
||||
"reject": "reject",
|
||||
"areusure": "Are you sure?",
|
||||
"yes": "yes",
|
||||
"teachers": "teachers",
|
||||
"accepted": "accepted",
|
||||
"rejected": "rejected",
|
||||
"enterUsername": "enter the username of the teacher you would like to invite",
|
||||
"username": "username",
|
||||
"invite": "invite",
|
||||
"searchAllLearningPathsTitle": "Search all learning paths",
|
||||
"searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
"JoinClassExplanation": "Entrez le code que l'enseignant vous a donné pour rejoindre la classe.",
|
||||
"invalidFormat": "Format non valide.",
|
||||
"submitCode": "envoyer",
|
||||
"members": "membres",
|
||||
"members": "Membres",
|
||||
"themes": "Thèmes",
|
||||
"choose-theme": "Choisis un thème",
|
||||
"choose-age": "Choisis un âge",
|
||||
|
|
@ -57,8 +57,21 @@
|
|||
"older": "18 et plus"
|
||||
},
|
||||
"read-more": "En savoir plus",
|
||||
"code": "code",
|
||||
"new-assignment": "Nouveau travail",
|
||||
"edit-assignment": "Modifier le travail",
|
||||
"groups": "Groupes",
|
||||
"learning-path": "Parcours d'apprentissage",
|
||||
"choose-lp": "Choisissez un parcours d'apprentissage",
|
||||
"choose-classes": "Choisissez des classes",
|
||||
"create-groups": "Créer des groupes",
|
||||
"title": "Titre",
|
||||
"pick-class": "Choisissez une classe",
|
||||
"choose-students": "Sélectionnez des élèves",
|
||||
"create-group": "Créer un groupe",
|
||||
"class": "classe",
|
||||
"delete": "supprimer",
|
||||
"view-assignment": "Voir le travail",
|
||||
"code": "code",
|
||||
"invitations": "invitations",
|
||||
"createClass": "créer une classe",
|
||||
"createClassInstructions": "Entrez un nom pour votre classe et cliquez sur créer. Une fenêtre apparaît avec un code que vous pouvez copier. Donnez ce code à vos élèves et ils pourront rejoindre votre classe.",
|
||||
|
|
@ -75,5 +88,37 @@
|
|||
"sent": "envoyé",
|
||||
"failed": "échoué",
|
||||
"wrong": "quelque chose n'a pas fonctionné",
|
||||
"created": "créé"
|
||||
"created": "créé",
|
||||
"callbackLoading": "Vous serez connecté...",
|
||||
"loginUnexpectedError": "La connexion a échoué",
|
||||
"submitSolution": "Soumettre la solution",
|
||||
"submitNewSolution": "Soumettre une nouvelle solution",
|
||||
"markAsDone": "Marquer comme terminé",
|
||||
"groupSubmissions": "Soumissions de ce groupe",
|
||||
"taskCompleted": "Tâche terminée.",
|
||||
"submittedBy": "Soumis par",
|
||||
"timestamp": "Horodatage",
|
||||
"loadSubmission": "Charger",
|
||||
"noSubmissionsYet": "Pas encore de soumissions.",
|
||||
"viewAsGroup": "Voir la progression du groupe...",
|
||||
"assignLearningPath": "donner comme tâche",
|
||||
"group": "Groupe",
|
||||
"description": "Description",
|
||||
"no-submission": "aucune soumission",
|
||||
"submission": "Soumission",
|
||||
"progress": "Progrès",
|
||||
"remove": "supprimer",
|
||||
"students": "étudiants",
|
||||
"classJoinRequests": "demandes d'adhésion",
|
||||
"reject": "rejeter",
|
||||
"areusure": "Êtes-vous sûr?",
|
||||
"yes": "oui",
|
||||
"teachers": "enseignants",
|
||||
"accepted": "acceptée",
|
||||
"rejected": "rejetée",
|
||||
"enterUsername": "entrez le nom d'utilisateur de l'enseignant que vous souhaitez inviter",
|
||||
"username": "Nom d'utilisateur",
|
||||
"invite": "inviter",
|
||||
"searchAllLearningPathsTitle": "Rechercher tous les parcours d'apprentissage",
|
||||
"searchAllLearningPathsDescription": "Vous n'avez pas trouvé ce que vous cherchiez ? Cliquez ici pour rechercher dans toute notre base de données de parcours d'apprentissage disponibles."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
"welcome": "Welkom",
|
||||
"student": "leerling",
|
||||
"teacher": "leerkracht",
|
||||
"assignments": "opdrachten",
|
||||
"classes": "klassen",
|
||||
"assignments": "Opdrachten",
|
||||
"classes": "Klassen",
|
||||
"discussions": "discussies",
|
||||
"logout": "log uit",
|
||||
"error_title": "Fout",
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"JoinClassExplanation": "Voer de code in die je van de docent hebt gekregen om lid te worden van de klas.",
|
||||
"invalidFormat": "Ongeldig formaat.",
|
||||
"submitCode": "verzenden",
|
||||
"members": "leden",
|
||||
"members": "Leden",
|
||||
"themes": "Lesthema's",
|
||||
"choose-theme": "Kies een thema",
|
||||
"choose-age": "Kies een leeftijd",
|
||||
|
|
@ -57,8 +57,21 @@
|
|||
"older": "Hoger onderwijs"
|
||||
},
|
||||
"read-more": "Lees meer",
|
||||
"code": "code",
|
||||
"new-assignment": "Nieuwe opdracht",
|
||||
"edit-assignment": "Opdracht bewerken",
|
||||
"groups": "Groepen",
|
||||
"learning-path": "Leerpad",
|
||||
"choose-lp": "Kies een leerpad",
|
||||
"choose-classes": "Kies klassen",
|
||||
"create-groups": "Groepen maken",
|
||||
"title": "Titel",
|
||||
"pick-class": "Kies een klas",
|
||||
"choose-students": "Studenten selecteren",
|
||||
"create-group": "Groep aanmaken",
|
||||
"class": "klas",
|
||||
"delete": "verwijderen",
|
||||
"view-assignment": "Opdracht bekijken",
|
||||
"code": "code",
|
||||
"invitations": "uitnodigingen",
|
||||
"createClass": "klas aanmaken",
|
||||
"createClassInstructions": "Voer een naam in voor je klas en klik op create. Er verschijnt een venster met een code die je kunt kopiëren. Geef deze code aan je leerlingen en ze kunnen deelnemen aan je klas.",
|
||||
|
|
@ -75,5 +88,37 @@
|
|||
"sent": "verzonden",
|
||||
"failed": "mislukt",
|
||||
"wrong": "er ging iets verkeerd",
|
||||
"created": "gecreëerd"
|
||||
"created": "gecreëerd",
|
||||
"callbackLoading": "Je wordt ingelogd...",
|
||||
"loginUnexpectedError": "Inloggen mislukt",
|
||||
"submitSolution": "Oplossing indienen",
|
||||
"submitNewSolution": "Nieuwe oplossing indienen",
|
||||
"markAsDone": "Markeren als afgewerkt",
|
||||
"groupSubmissions": "Indieningen van deze groep",
|
||||
"taskCompleted": "Taak afgewerkt.",
|
||||
"submittedBy": "Ingediend door",
|
||||
"timestamp": "Tijdstip",
|
||||
"loadSubmission": "Inladen",
|
||||
"noSubmissionsYet": "Nog geen indieningen.",
|
||||
"viewAsGroup": "Vooruitgang bekijken van groep...",
|
||||
"assignLearningPath": "Als opdracht geven",
|
||||
"group": "Groep",
|
||||
"description": "Beschrijving",
|
||||
"no-submission": "geen indiening",
|
||||
"submission": "Indiening",
|
||||
"progress": "Vooruitgang",
|
||||
"remove": "verwijder",
|
||||
"students": "studenten",
|
||||
"classJoinRequests": "deelname verzoeken",
|
||||
"reject": "weiger",
|
||||
"areusure": "Bent u zeker?",
|
||||
"yes": "ja",
|
||||
"teachers": "leerkrachten",
|
||||
"accepted": "geaccepteerd",
|
||||
"rejected": "geweigerd",
|
||||
"enterUsername": "vul de gebruikersnaam van de leerkracht die je wilt uitnodigen in",
|
||||
"username": "gebruikersnaam",
|
||||
"invite": "uitnodigen",
|
||||
"searchAllLearningPathsTitle": "Alle leerpaden doorzoeken",
|
||||
"searchAllLearningPathsDescription": "Niet gevonden waar je naar op zoek was? Klik hier om onze volledige databank van beschikbare leerpaden te doorzoeken."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
import type { QuestionId } from "@dwengo-1/common/dist/interfaces/question.ts";
|
||||
import { type MaybeRefOrGetter, toValue } from "vue";
|
||||
import { useMutation, type UseMutationReturnType, useQuery, type UseQueryReturnType } from "@tanstack/vue-query";
|
||||
import { computed, type MaybeRefOrGetter, toValue } from "vue";
|
||||
import {
|
||||
useMutation,
|
||||
type UseMutationReturnType,
|
||||
useQuery,
|
||||
type UseQueryReturnType,
|
||||
useQueryClient,
|
||||
} from "@tanstack/vue-query";
|
||||
import { AnswerController, type AnswerResponse, type AnswersResponse } from "@/controllers/answers.ts";
|
||||
import type { AnswerData } from "@dwengo-1/common/dist/interfaces/answer.ts";
|
||||
import type { AnswerData } from "@dwengo-1/common/interfaces/answer";
|
||||
import type { QuestionId } from "@dwengo-1/common/interfaces/question";
|
||||
|
||||
// TODO caching
|
||||
/** 🔑 Query keys */
|
||||
export function answersQueryKey(
|
||||
questionId: QuestionId,
|
||||
full: boolean,
|
||||
): [string, string, number, string, number, boolean] {
|
||||
const loId = questionId.learningObjectIdentifier;
|
||||
return ["answers", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber, full];
|
||||
}
|
||||
export function answerQueryKey(
|
||||
questionId: QuestionId,
|
||||
sequenceNumber: number,
|
||||
): [string, string, number, string, number, number] {
|
||||
const loId = questionId.learningObjectIdentifier;
|
||||
return ["answer", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber, sequenceNumber];
|
||||
}
|
||||
|
||||
export function useAnswersQuery(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<AnswersResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => answersQueryKey(toValue(questionId), toValue(full))),
|
||||
queryFn: async () => new AnswerController(toValue(questionId)).getAll(toValue(full)),
|
||||
enabled: () => Boolean(toValue(questionId)),
|
||||
});
|
||||
|
|
@ -21,31 +42,68 @@ export function useAnswerQuery(
|
|||
sequenceNumber: MaybeRefOrGetter<number>,
|
||||
): UseQueryReturnType<AnswerResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => answerQueryKey(toValue(questionId), toValue(sequenceNumber))),
|
||||
queryFn: async () => new AnswerController(toValue(questionId)).getBy(toValue(sequenceNumber)),
|
||||
enabled: () => Boolean(toValue(questionId)),
|
||||
enabled: () => Boolean(toValue(questionId)) && Boolean(toValue(sequenceNumber)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAnswerMutation(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseMutationReturnType<AnswerResponse, Error, AnswerData, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => new AnswerController(toValue(questionId)).create(data),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: answersQueryKey(toValue(questionId), true),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: answersQueryKey(toValue(questionId), false),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAnswerMutation(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseMutationReturnType<AnswerResponse, Error, number, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (seq) => new AnswerController(toValue(questionId)).remove(seq),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: answersQueryKey(toValue(questionId), true),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: answersQueryKey(toValue(questionId), false),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAnswerMutation(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseMutationReturnType<AnswerResponse, Error, { answerData: AnswerData; seq: number }, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data, seq) => new AnswerController(toValue(questionId)).update(seq, data),
|
||||
mutationFn: async ({ answerData, seq }) => new AnswerController(toValue(questionId)).update(seq, answerData),
|
||||
onSuccess: async (_, { seq }) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: answerQueryKey(toValue(questionId), seq),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: answersQueryKey(toValue(questionId), true),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: answersQueryKey(toValue(questionId), true),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: answersQueryKey(toValue(questionId), false),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
217
frontend/src/queries/assignments.ts
Normal file
217
frontend/src/queries/assignments.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { AssignmentController, type AssignmentResponse, type AssignmentsResponse } from "@/controllers/assignments";
|
||||
import type { QuestionsResponse } from "@/controllers/questions";
|
||||
import type { SubmissionsResponse } from "@/controllers/submissions";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationReturnType,
|
||||
type UseQueryReturnType,
|
||||
} from "@tanstack/vue-query";
|
||||
import { computed, toValue, type MaybeRefOrGetter } from "vue";
|
||||
import { groupsQueryKey, invalidateAllGroupKeys } from "./groups";
|
||||
import type { GroupsResponse } from "@/controllers/groups";
|
||||
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { invalidateAllSubmissionKeys } from "./submissions";
|
||||
|
||||
type AssignmentsQueryKey = ["assignments", string, boolean];
|
||||
|
||||
function assignmentsQueryKey(classid: string, full: boolean): AssignmentsQueryKey {
|
||||
return ["assignments", classid, full];
|
||||
}
|
||||
|
||||
type AssignmentQueryKey = ["assignment", string, number];
|
||||
|
||||
function assignmentQueryKey(classid: string, assignmentNumber: number): AssignmentQueryKey {
|
||||
return ["assignment", classid, assignmentNumber];
|
||||
}
|
||||
|
||||
type AssignmentSubmissionsQueryKey = ["assignment-submissions", string, number, boolean];
|
||||
|
||||
function assignmentSubmissionsQueryKey(
|
||||
classid: string,
|
||||
assignmentNumber: number,
|
||||
full: boolean,
|
||||
): AssignmentSubmissionsQueryKey {
|
||||
return ["assignment-submissions", classid, assignmentNumber, full];
|
||||
}
|
||||
|
||||
type AssignmentQuestionsQueryKey = ["assignment-questions", string, number, boolean];
|
||||
|
||||
function assignmentQuestionsQueryKey(
|
||||
classid: string,
|
||||
assignmentNumber: number,
|
||||
full: boolean,
|
||||
): AssignmentQuestionsQueryKey {
|
||||
return ["assignment-questions", classid, assignmentNumber, full];
|
||||
}
|
||||
|
||||
export async function invalidateAllAssignmentKeys(
|
||||
queryClient: QueryClient,
|
||||
classid?: string,
|
||||
assignmentNumber?: number,
|
||||
): Promise<void> {
|
||||
const keys = ["assignment", "assignment-submissions", "assignment-questions"];
|
||||
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const queryKey = [key, classid, assignmentNumber].filter((arg) => arg !== undefined);
|
||||
return queryClient.invalidateQueries({ queryKey: queryKey });
|
||||
}),
|
||||
);
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["assignments", classid].filter((arg) => arg !== undefined) });
|
||||
}
|
||||
|
||||
function checkEnabled(
|
||||
classid: string | undefined,
|
||||
assignmentNumber: number | undefined,
|
||||
groupNumber: number | undefined,
|
||||
): boolean {
|
||||
return Boolean(classid) && !isNaN(Number(groupNumber)) && !isNaN(Number(assignmentNumber));
|
||||
}
|
||||
|
||||
interface Values {
|
||||
cid: string | undefined;
|
||||
an: number | undefined;
|
||||
gn: number | undefined;
|
||||
f: boolean;
|
||||
}
|
||||
|
||||
function toValues(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean>,
|
||||
): Values {
|
||||
return { cid: toValue(classid), an: toValue(assignmentNumber), gn: toValue(groupNumber), f: toValue(full) };
|
||||
}
|
||||
|
||||
export function useAssignmentsQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<AssignmentsResponse, Error> {
|
||||
const { cid, f } = toValues(classid, 1, 1, full);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => assignmentsQueryKey(cid!, f)),
|
||||
queryFn: async () => new AssignmentController(cid!).getAll(f),
|
||||
enabled: () => checkEnabled(cid, 1, 1),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignmentQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
): UseQueryReturnType<AssignmentResponse, Error> {
|
||||
const { cid, an } = toValues(classid, assignmentNumber, 1, true);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => assignmentQueryKey(cid!, an!)),
|
||||
queryFn: async () => new AssignmentController(cid!).getByNumber(an!),
|
||||
enabled: () => checkEnabled(cid, an, 1),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAssignmentMutation(): UseMutationReturnType<
|
||||
AssignmentResponse,
|
||||
Error,
|
||||
{ cid: string; data: AssignmentDTO },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ cid, data }) => new AssignmentController(cid).createAssignment(data),
|
||||
onSuccess: async (_) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["assignments"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAssignmentMutation(): UseMutationReturnType<
|
||||
AssignmentResponse,
|
||||
Error,
|
||||
{ cid: string; an: number },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ cid, an }) => new AssignmentController(cid).deleteAssignment(an),
|
||||
onSuccess: async (response) => {
|
||||
const cid = response.assignment.within;
|
||||
const an = response.assignment.id;
|
||||
|
||||
await invalidateAllAssignmentKeys(queryClient, cid, an);
|
||||
await invalidateAllGroupKeys(queryClient, cid, an);
|
||||
await invalidateAllSubmissionKeys(queryClient, undefined, undefined, undefined, cid, an);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAssignmentMutation(): UseMutationReturnType<
|
||||
AssignmentResponse,
|
||||
Error,
|
||||
{ cid: string; an: number; data: Partial<AssignmentDTO> },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ cid, an, data }) => new AssignmentController(cid).updateAssignment(an, data),
|
||||
onSuccess: async (response) => {
|
||||
const cid = response.assignment.within;
|
||||
const an = response.assignment.id;
|
||||
|
||||
await invalidateAllGroupKeys(queryClient, cid, an);
|
||||
await queryClient.invalidateQueries({ queryKey: ["assignments"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignmentSubmissionsQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<SubmissionsResponse, Error> {
|
||||
const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)),
|
||||
queryFn: async () => new AssignmentController(cid!).getSubmissions(gn!, f),
|
||||
enabled: () => checkEnabled(cid, an, gn),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignmentQuestionsQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<QuestionsResponse, Error> {
|
||||
const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => assignmentQuestionsQueryKey(cid!, an!, f)),
|
||||
queryFn: async () => new AssignmentController(cid!).getQuestions(gn!, f),
|
||||
enabled: () => checkEnabled(cid, an, gn),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignmentGroupsQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<GroupsResponse, Error> {
|
||||
const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => groupsQueryKey(cid!, an!, f)),
|
||||
queryFn: async () => new AssignmentController(cid!).getQuestions(gn!, f),
|
||||
enabled: () => checkEnabled(cid, an, gn),
|
||||
});
|
||||
}
|
||||
245
frontend/src/queries/classes.ts
Normal file
245
frontend/src/queries/classes.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { ClassController, type ClassesResponse, type ClassResponse } from "@/controllers/classes";
|
||||
import type { StudentsResponse } from "@/controllers/students";
|
||||
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
|
||||
import {
|
||||
QueryClient,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationReturnType,
|
||||
type UseQueryReturnType,
|
||||
} from "@tanstack/vue-query";
|
||||
import { computed, toValue, type MaybeRefOrGetter } from "vue";
|
||||
import { invalidateAllAssignmentKeys } from "./assignments";
|
||||
import { invalidateAllGroupKeys } from "./groups";
|
||||
import { invalidateAllSubmissionKeys } from "./submissions";
|
||||
import type { TeachersResponse } from "@/controllers/teachers";
|
||||
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations";
|
||||
|
||||
const classController = new ClassController();
|
||||
|
||||
/* Query cache keys */
|
||||
type ClassesQueryKey = ["classes", boolean];
|
||||
|
||||
function classesQueryKey(full: boolean): ClassesQueryKey {
|
||||
return ["classes", full];
|
||||
}
|
||||
|
||||
type ClassQueryKey = ["class", string];
|
||||
|
||||
function classQueryKey(classid: string): ClassQueryKey {
|
||||
return ["class", classid];
|
||||
}
|
||||
|
||||
type ClassStudentsKey = ["class-students", string, boolean];
|
||||
|
||||
function classStudentsKey(classid: string, full: boolean): ClassStudentsKey {
|
||||
return ["class-students", classid, full];
|
||||
}
|
||||
|
||||
type ClassTeachersKey = ["class-teachers", string, boolean];
|
||||
|
||||
function classTeachersKey(classid: string, full: boolean): ClassTeachersKey {
|
||||
return ["class-teachers", classid, full];
|
||||
}
|
||||
|
||||
type ClassTeacherInvitationsKey = ["class-teacher-invitations", string, boolean];
|
||||
|
||||
function classTeacherInvitationsKey(classid: string, full: boolean): ClassTeacherInvitationsKey {
|
||||
return ["class-teacher-invitations", classid, full];
|
||||
}
|
||||
|
||||
type ClassAssignmentsKey = ["class-assignments", string, boolean];
|
||||
|
||||
function classAssignmentsKey(classid: string, full: boolean): ClassAssignmentsKey {
|
||||
return ["class-assignments", classid, full];
|
||||
}
|
||||
|
||||
export async function invalidateAllClassKeys(queryClient: QueryClient, classid?: string): Promise<void> {
|
||||
const keys = ["class", "class-students", "class-teachers", "class-teacher-invitations", "class-assignments"];
|
||||
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const queryKey = [key, classid].filter((arg) => arg !== undefined);
|
||||
return queryClient.invalidateQueries({ queryKey: queryKey });
|
||||
}),
|
||||
);
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["classes"] });
|
||||
}
|
||||
|
||||
/* Queries */
|
||||
export function useClassesQuery(full: MaybeRefOrGetter<boolean> = true): UseQueryReturnType<ClassesResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => classesQueryKey(toValue(full))),
|
||||
queryFn: async () => classController.getAll(toValue(full)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassQuery(id: MaybeRefOrGetter<string | undefined>): UseQueryReturnType<ClassResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => classQueryKey(toValue(id)!)),
|
||||
queryFn: async () => classController.getById(toValue(id)!),
|
||||
enabled: () => Boolean(toValue(id)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateClassMutation(): UseMutationReturnType<ClassResponse, Error, ClassDTO, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => classController.createClass(data),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["classes"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteClassMutation(): UseMutationReturnType<ClassResponse, Error, string, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id) => classController.deleteClass(id),
|
||||
onSuccess: async (data) => {
|
||||
await invalidateAllClassKeys(queryClient, data.class.id);
|
||||
await invalidateAllAssignmentKeys(queryClient, data.class.id);
|
||||
await invalidateAllGroupKeys(queryClient, data.class.id);
|
||||
await invalidateAllSubmissionKeys(queryClient, data.class.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateClassMutation(): UseMutationReturnType<
|
||||
ClassResponse,
|
||||
Error,
|
||||
{ cid: string; data: Partial<ClassDTO> },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ cid, data }) => classController.updateClass(cid, data),
|
||||
onSuccess: async (data) => {
|
||||
await invalidateAllClassKeys(queryClient, data.class.id);
|
||||
await invalidateAllAssignmentKeys(queryClient, data.class.id);
|
||||
await invalidateAllGroupKeys(queryClient, data.class.id);
|
||||
await invalidateAllSubmissionKeys(queryClient, data.class.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassStudentsQuery(
|
||||
id: MaybeRefOrGetter<string | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<StudentsResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => classStudentsKey(toValue(id)!, toValue(full))),
|
||||
queryFn: async () => classController.getStudents(toValue(id)!, toValue(full)),
|
||||
enabled: () => Boolean(toValue(id)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassAddStudentMutation(): UseMutationReturnType<
|
||||
ClassResponse,
|
||||
Error,
|
||||
{ id: string; username: string },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, username }) => classController.addStudent(id, username),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) });
|
||||
await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) });
|
||||
await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassDeleteStudentMutation(): UseMutationReturnType<
|
||||
ClassResponse,
|
||||
Error,
|
||||
{ id: string; username: string },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, username }) => classController.deleteStudent(id, username),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) });
|
||||
await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) });
|
||||
await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassTeachersQuery(
|
||||
id: MaybeRefOrGetter<string | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<TeachersResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => classTeachersKey(toValue(id)!, toValue(full))),
|
||||
queryFn: async () => classController.getTeachers(toValue(id)!, toValue(full)),
|
||||
enabled: () => Boolean(toValue(id)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassAddTeacherMutation(): UseMutationReturnType<
|
||||
ClassResponse,
|
||||
Error,
|
||||
{ id: string; username: string },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, username }) => classController.addTeacher(id, username),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) });
|
||||
await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, true) });
|
||||
await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, false) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassDeleteTeacherMutation(): UseMutationReturnType<
|
||||
ClassResponse,
|
||||
Error,
|
||||
{ id: string; username: string },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, username }) => classController.deleteTeacher(id, username),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) });
|
||||
await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, true) });
|
||||
await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, false) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassTeacherInvitationsQuery(
|
||||
id: MaybeRefOrGetter<string | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<TeacherInvitationsResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => classTeacherInvitationsKey(toValue(id)!, toValue(full))),
|
||||
queryFn: async () => classController.getTeacherInvitations(toValue(id)!, toValue(full)),
|
||||
enabled: () => Boolean(toValue(id)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClassAssignmentsQuery(
|
||||
id: MaybeRefOrGetter<string | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<StudentsResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => classAssignmentsKey(toValue(id)!, toValue(full))),
|
||||
queryFn: async () => classController.getAssignments(toValue(id)!, toValue(full)),
|
||||
enabled: () => Boolean(toValue(id)),
|
||||
});
|
||||
}
|
||||
219
frontend/src/queries/groups.ts
Normal file
219
frontend/src/queries/groups.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { GroupController, type GroupResponse, type GroupsResponse } from "@/controllers/groups";
|
||||
import type { QuestionsResponse } from "@/controllers/questions";
|
||||
import type { SubmissionsResponse } from "@/controllers/submissions";
|
||||
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
|
||||
import {
|
||||
QueryClient,
|
||||
useMutation,
|
||||
type UseMutationReturnType,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryReturnType,
|
||||
} from "@tanstack/vue-query";
|
||||
import { computed, toValue, type MaybeRefOrGetter } from "vue";
|
||||
import { invalidateAllSubmissionKeys } from "./submissions";
|
||||
|
||||
type GroupsQueryKey = ["groups", string, number, boolean];
|
||||
|
||||
export function groupsQueryKey(classid: string, assignmentNumber: number, full: boolean): GroupsQueryKey {
|
||||
return ["groups", classid, assignmentNumber, full];
|
||||
}
|
||||
|
||||
type GroupQueryKey = ["group", string, number, number];
|
||||
|
||||
function groupQueryKey(classid: string, assignmentNumber: number, groupNumber: number): GroupQueryKey {
|
||||
return ["group", classid, assignmentNumber, groupNumber];
|
||||
}
|
||||
|
||||
type GroupSubmissionsQueryKey = ["group-submissions", string, number, number, boolean];
|
||||
|
||||
function groupSubmissionsQueryKey(
|
||||
classid: string,
|
||||
assignmentNumber: number,
|
||||
groupNumber: number,
|
||||
full: boolean,
|
||||
): GroupSubmissionsQueryKey {
|
||||
return ["group-submissions", classid, assignmentNumber, groupNumber, full];
|
||||
}
|
||||
|
||||
type GroupQuestionsQueryKey = ["group-questions", string, number, number, boolean];
|
||||
|
||||
function groupQuestionsQueryKey(
|
||||
classid: string,
|
||||
assignmentNumber: number,
|
||||
groupNumber: number,
|
||||
full: boolean,
|
||||
): GroupQuestionsQueryKey {
|
||||
return ["group-questions", classid, assignmentNumber, groupNumber, full];
|
||||
}
|
||||
|
||||
export async function invalidateAllGroupKeys(
|
||||
queryClient: QueryClient,
|
||||
classid?: string,
|
||||
assignmentNumber?: number,
|
||||
groupNumber?: number,
|
||||
): Promise<void> {
|
||||
const keys = ["group", "group-submissions", "group-questions"];
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const queryKey = [key, classid, assignmentNumber, groupNumber].filter((arg) => arg !== undefined);
|
||||
return queryClient.invalidateQueries({ queryKey: queryKey });
|
||||
}),
|
||||
);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["groups", classid, assignmentNumber].filter((arg) => arg !== undefined),
|
||||
});
|
||||
}
|
||||
|
||||
function checkEnabled(
|
||||
classid: string | undefined,
|
||||
assignmentNumber: number | undefined,
|
||||
groupNumber: number | undefined,
|
||||
): boolean {
|
||||
return Boolean(classid) && !isNaN(Number(groupNumber)) && !isNaN(Number(assignmentNumber));
|
||||
}
|
||||
|
||||
interface Values {
|
||||
cid: string | undefined;
|
||||
an: number | undefined;
|
||||
gn: number | undefined;
|
||||
f: boolean;
|
||||
}
|
||||
|
||||
function toValues(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean>,
|
||||
): Values {
|
||||
return { cid: toValue(classid), an: toValue(assignmentNumber), gn: toValue(groupNumber), f: toValue(full) };
|
||||
}
|
||||
|
||||
export function useGroupsQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<GroupsResponse, Error> {
|
||||
const { cid, an, f } = toValues(classid, assignmentNumber, 1, full);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => groupsQueryKey(cid!, an!, f)),
|
||||
queryFn: async () => new GroupController(cid!, an!).getAll(f),
|
||||
enabled: () => checkEnabled(cid, an, 1),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
): UseQueryReturnType<GroupResponse, Error> {
|
||||
const { cid, an, gn } = toValues(classid, assignmentNumber, groupNumber, true);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => groupQueryKey(cid!, an!, gn!)),
|
||||
queryFn: async () => new GroupController(cid!, an!).getByNumber(gn!),
|
||||
enabled: () => checkEnabled(cid, an, gn),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGroupMutation(): UseMutationReturnType<
|
||||
GroupResponse,
|
||||
Error,
|
||||
{ cid: string; an: number; data: GroupDTO },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ cid, an, data }) => new GroupController(cid, an).createGroup(data),
|
||||
onSuccess: async (response) => {
|
||||
const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id;
|
||||
const an =
|
||||
typeof response.group.assignment === "number"
|
||||
? response.group.assignment
|
||||
: response.group.assignment.id;
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: groupsQueryKey(cid, an, true) });
|
||||
await queryClient.invalidateQueries({ queryKey: groupsQueryKey(cid, an, false) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGroupMutation(): UseMutationReturnType<
|
||||
GroupResponse,
|
||||
Error,
|
||||
{ cid: string; an: number; gn: number },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ cid, an, gn }) => new GroupController(cid, an).deleteGroup(gn),
|
||||
onSuccess: async (response) => {
|
||||
const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id;
|
||||
const an =
|
||||
typeof response.group.assignment === "number"
|
||||
? response.group.assignment
|
||||
: response.group.assignment.id;
|
||||
const gn = response.group.groupNumber;
|
||||
|
||||
await invalidateAllGroupKeys(queryClient, cid, an, gn);
|
||||
await invalidateAllSubmissionKeys(queryClient, undefined, undefined, undefined, cid, an, gn);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGroupMutation(): UseMutationReturnType<
|
||||
GroupResponse,
|
||||
Error,
|
||||
{ cid: string; an: number; gn: number; data: Partial<GroupDTO> },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ cid, an, gn, data }) => new GroupController(cid, an).updateGroup(gn, data),
|
||||
onSuccess: async (response) => {
|
||||
const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id;
|
||||
const an =
|
||||
typeof response.group.assignment === "number"
|
||||
? response.group.assignment
|
||||
: response.group.assignment.id;
|
||||
const gn = response.group.groupNumber;
|
||||
|
||||
await invalidateAllGroupKeys(queryClient, cid, an, gn);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupSubmissionsQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<SubmissionsResponse, Error> {
|
||||
const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => groupSubmissionsQueryKey(cid!, an!, gn!, f)),
|
||||
queryFn: async () => new GroupController(cid!, an!).getSubmissions(gn!, f),
|
||||
enabled: () => checkEnabled(cid, an, gn),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupQuestionsQuery(
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<QuestionsResponse, Error> {
|
||||
const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => groupQuestionsQueryKey(cid!, an!, gn!, f)),
|
||||
queryFn: async () => new GroupController(cid!, an!).getSubmissions(gn!, f),
|
||||
enabled: () => checkEnabled(cid, an, gn),
|
||||
});
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { getLearningObjectController } from "@/controllers/controllers.ts";
|
|||
import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts";
|
||||
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
|
||||
|
||||
const LEARNING_OBJECT_KEY = "learningObject";
|
||||
export const LEARNING_OBJECT_KEY = "learningObject";
|
||||
const learningObjectController = getLearningObjectController();
|
||||
|
||||
export function useLearningObjectMetadataQuery(
|
||||
|
|
|
|||
|
|
@ -4,19 +4,19 @@ import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query";
|
|||
import { getLearningPathController } from "@/controllers/controllers";
|
||||
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
|
||||
|
||||
const LEARNING_PATH_KEY = "learningPath";
|
||||
export const LEARNING_PATH_KEY = "learningPath";
|
||||
const learningPathController = getLearningPathController();
|
||||
|
||||
export function useGetLearningPathQuery(
|
||||
hruid: MaybeRefOrGetter<string>,
|
||||
language: MaybeRefOrGetter<Language>,
|
||||
options?: MaybeRefOrGetter<{ forGroup?: string; forStudent?: string }>,
|
||||
forGroup?: MaybeRefOrGetter<{ forGroup: number; assignmentNo: number; classId: string } | undefined>,
|
||||
): UseQueryReturnType<LearningPath, Error> {
|
||||
return useQuery({
|
||||
queryKey: [LEARNING_PATH_KEY, "get", hruid, language, options],
|
||||
queryKey: [LEARNING_PATH_KEY, "get", hruid, language, forGroup],
|
||||
queryFn: async () => {
|
||||
const [hruidVal, languageVal, optionsVal] = [toValue(hruid), toValue(language), toValue(options)];
|
||||
return learningPathController.getBy(hruidVal, languageVal, optionsVal);
|
||||
const [hruidVal, languageVal, forGroupVal] = [toValue(hruid), toValue(language), toValue(forGroup)];
|
||||
return learningPathController.getBy(hruidVal, languageVal, forGroupVal);
|
||||
},
|
||||
enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)),
|
||||
});
|
||||
|
|
@ -34,13 +34,28 @@ export function useGetAllLearningPathsByThemeQuery(
|
|||
|
||||
export function useSearchLearningPathQuery(
|
||||
query: MaybeRefOrGetter<string | undefined>,
|
||||
language: MaybeRefOrGetter<string | undefined>,
|
||||
): UseQueryReturnType<LearningPath[], Error> {
|
||||
return useQuery({
|
||||
queryKey: [LEARNING_PATH_KEY, "search", query],
|
||||
queryKey: [LEARNING_PATH_KEY, "search", query, language],
|
||||
queryFn: async () => {
|
||||
const queryVal = toValue(query)!;
|
||||
return learningPathController.search(queryVal);
|
||||
const languageVal = toValue(language)!;
|
||||
return learningPathController.search(queryVal, languageVal);
|
||||
},
|
||||
enabled: () => Boolean(toValue(query)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetAllLearningPaths(
|
||||
language: MaybeRefOrGetter<string | undefined>,
|
||||
): UseQueryReturnType<LearningPath[], Error> {
|
||||
return useQuery({
|
||||
queryKey: [LEARNING_PATH_KEY, "getAllLearningPaths", language],
|
||||
queryFn: async () => {
|
||||
const lang = toValue(language);
|
||||
return learningPathController.getAllLearningPaths(lang);
|
||||
},
|
||||
enabled: () => Boolean(toValue(language)),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ export function questionsQueryKey(
|
|||
loId: LearningObjectIdentifierDTO,
|
||||
full: boolean,
|
||||
): [string, string, number, string, boolean] {
|
||||
return ["questions", loId.hruid, loId.version, loId.language, full];
|
||||
return ["questions", loId.hruid, loId.version!, loId.language, full];
|
||||
}
|
||||
|
||||
export function questionQueryKey(questionId: QuestionId): [string, string, number, string, number] {
|
||||
const loId = questionId.learningObjectIdentifier;
|
||||
return ["question", loId.hruid, loId.version, loId.language, questionId.sequenceNumber];
|
||||
return ["question", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber];
|
||||
}
|
||||
|
||||
export function useQuestionsQuery(
|
||||
|
|
@ -39,7 +39,7 @@ export function useQuestionQuery(
|
|||
const loId = toValue(questionId).learningObjectIdentifier;
|
||||
const sequenceNumber = toValue(questionId).sequenceNumber;
|
||||
return useQuery({
|
||||
queryKey: computed(() => questionQueryKey(loId, sequenceNumber)),
|
||||
queryKey: computed(() => questionQueryKey(toValue(questionId))),
|
||||
queryFn: async () => new QuestionController(loId).getBy(sequenceNumber),
|
||||
enabled: () => Boolean(toValue(questionId)),
|
||||
});
|
||||
|
|
@ -55,6 +55,7 @@ export function useCreateQuestionMutation(
|
|||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), true) });
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), false) });
|
||||
await queryClient.invalidateQueries({ queryKey: ["answers"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -88,6 +89,8 @@ export function useDeleteQuestionMutation(
|
|||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), true) });
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), false) });
|
||||
await queryClient.invalidateQueries({ queryKey: questionQueryKey(toValue(questionId)) });
|
||||
await queryClient.invalidateQueries({ queryKey: ["answers"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["answer"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { computed, toValue } from "vue";
|
||||
import { computed, type Ref, toValue } from "vue";
|
||||
import type { MaybeRefOrGetter } from "vue";
|
||||
import {
|
||||
type QueryObserverResult,
|
||||
useMutation,
|
||||
type UseMutationReturnType,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryReturnType,
|
||||
|
|
@ -20,6 +22,7 @@ import type { GroupsResponse } from "@/controllers/groups.ts";
|
|||
import type { SubmissionsResponse } from "@/controllers/submissions.ts";
|
||||
import type { QuestionsResponse } from "@/controllers/questions.ts";
|
||||
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
|
||||
import { teacherClassJoinRequests } from "@/queries/teachers.ts";
|
||||
|
||||
const studentController = new StudentController();
|
||||
|
||||
|
|
@ -69,6 +72,20 @@ export function useStudentQuery(
|
|||
});
|
||||
}
|
||||
|
||||
export function useStudentsByUsernamesQuery(
|
||||
usernames: MaybeRefOrGetter<string[] | undefined>,
|
||||
): Ref<QueryObserverResult<StudentResponse>[]> {
|
||||
const resolvedUsernames = toValue(usernames) ?? [];
|
||||
|
||||
return useQueries({
|
||||
queries: resolvedUsernames?.map((username) => ({
|
||||
queryKey: computed(() => studentQueryKey(toValue(username))),
|
||||
queryFn: async () => studentController.getByUsername(toValue(username)),
|
||||
enabled: Boolean(toValue(username)),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStudentClassesQuery(
|
||||
username: MaybeRefOrGetter<string | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
|
|
@ -174,13 +191,13 @@ export function useCreateJoinRequestMutation(): UseMutationReturnType<
|
|||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ username, classId }) => studentController.createJoinRequest(username, classId),
|
||||
onSuccess: async (newJoinRequest) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: studentJoinRequestsQueryKey(newJoinRequest.request.requester.username),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: teacherClassJoinRequests(newJoinRequest.request.class) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -200,6 +217,7 @@ export function useDeleteJoinRequestMutation(): UseMutationReturnType<
|
|||
const classId = deletedJoinRequest.request.class;
|
||||
await queryClient.invalidateQueries({ queryKey: studentJoinRequestsQueryKey(username) });
|
||||
await queryClient.invalidateQueries({ queryKey: studentJoinRequestQueryKey(username, classId) });
|
||||
await queryClient.invalidateQueries({ queryKey: teacherClassJoinRequests(classId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
225
frontend/src/queries/submissions.ts
Normal file
225
frontend/src/queries/submissions.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { SubmissionController, type SubmissionResponse } from "@/controllers/submissions";
|
||||
import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission";
|
||||
import {
|
||||
QueryClient,
|
||||
useMutation,
|
||||
type UseMutationReturnType,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryReturnType,
|
||||
} from "@tanstack/vue-query";
|
||||
import { computed, type MaybeRefOrGetter, toValue } from "vue";
|
||||
import { LEARNING_PATH_KEY } from "@/queries/learning-paths.ts";
|
||||
import { LEARNING_OBJECT_KEY } from "@/queries/learning-objects.ts";
|
||||
import type { Language } from "@dwengo-1/common/util/language";
|
||||
|
||||
export const SUBMISSION_KEY = "submissions";
|
||||
|
||||
type SubmissionQueryKey = ["submission", string, Language | undefined, number, string, number, number, number];
|
||||
|
||||
function submissionQueryKey(
|
||||
hruid: string,
|
||||
language: Language,
|
||||
version: number,
|
||||
classid: string,
|
||||
assignmentNumber: number,
|
||||
groupNumber: number,
|
||||
submissionNumber: number,
|
||||
): SubmissionQueryKey {
|
||||
return ["submission", hruid, language, version, classid, assignmentNumber, groupNumber, submissionNumber];
|
||||
}
|
||||
|
||||
export async function invalidateAllSubmissionKeys(
|
||||
queryClient: QueryClient,
|
||||
hruid?: string,
|
||||
language?: Language,
|
||||
version?: number,
|
||||
classid?: string,
|
||||
assignmentNumber?: number,
|
||||
groupNumber?: number,
|
||||
submissionNumber?: number,
|
||||
): Promise<void> {
|
||||
const keys = ["submission"];
|
||||
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const queryKey = [
|
||||
key,
|
||||
hruid,
|
||||
language,
|
||||
version,
|
||||
classid,
|
||||
assignmentNumber,
|
||||
groupNumber,
|
||||
submissionNumber,
|
||||
].filter((arg) => arg !== undefined);
|
||||
return queryClient.invalidateQueries({ queryKey: queryKey });
|
||||
}),
|
||||
);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["submissions", hruid, language, version, classid, assignmentNumber, groupNumber].filter(
|
||||
(arg) => arg !== undefined,
|
||||
),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["group-submissions", hruid, language, version, classid, assignmentNumber, groupNumber].filter(
|
||||
(arg) => arg !== undefined,
|
||||
),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["assignment-submissions", hruid, language, version, classid, assignmentNumber].filter(
|
||||
(arg) => arg !== undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function checkEnabled(properties: MaybeRefOrGetter<unknown>[]): boolean {
|
||||
return properties.every((prop) => Boolean(toValue(prop)));
|
||||
}
|
||||
|
||||
export function useSubmissionsQuery(
|
||||
hruid: MaybeRefOrGetter<string | undefined>,
|
||||
language: MaybeRefOrGetter<Language | undefined>,
|
||||
version: MaybeRefOrGetter<number | undefined>,
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<SubmissionDTO[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ["submissions", hruid, language, version, classid, assignmentNumber, groupNumber, full],
|
||||
queryFn: async () => {
|
||||
const hruidVal = toValue(hruid);
|
||||
const languageVal = toValue(language);
|
||||
const versionVal = toValue(version);
|
||||
const classIdVal = toValue(classid);
|
||||
const assignmentNumberVal = toValue(assignmentNumber);
|
||||
const groupNumberVal = toValue(groupNumber);
|
||||
const fullVal = toValue(full);
|
||||
|
||||
const response = await new SubmissionController(hruidVal!).getAll(
|
||||
languageVal,
|
||||
versionVal!,
|
||||
classIdVal!,
|
||||
assignmentNumberVal!,
|
||||
groupNumberVal,
|
||||
fullVal,
|
||||
);
|
||||
return response ? (response.submissions as SubmissionDTO[]) : undefined;
|
||||
},
|
||||
enabled: () => checkEnabled([hruid, language, version, classid, assignmentNumber]),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmissionQuery(
|
||||
hruid: MaybeRefOrGetter<string | undefined>,
|
||||
language: MaybeRefOrGetter<Language | undefined>,
|
||||
version: MaybeRefOrGetter<number | undefined>,
|
||||
classid: MaybeRefOrGetter<string | undefined>,
|
||||
assignmentNumber: MaybeRefOrGetter<number | undefined>,
|
||||
groupNumber: MaybeRefOrGetter<number | undefined>,
|
||||
submissionNumber: MaybeRefOrGetter<number | undefined>,
|
||||
): UseQueryReturnType<SubmissionResponse, Error> {
|
||||
const hruidVal = toValue(hruid);
|
||||
const languageVal = toValue(language);
|
||||
const versionVal = toValue(version);
|
||||
const classIdVal = toValue(classid);
|
||||
const assignmentNumberVal = toValue(assignmentNumber);
|
||||
const groupNumberVal = toValue(groupNumber);
|
||||
const submissionNumberVal = toValue(submissionNumber);
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() =>
|
||||
submissionQueryKey(
|
||||
hruidVal!,
|
||||
languageVal,
|
||||
versionVal!,
|
||||
classIdVal!,
|
||||
assignmentNumberVal!,
|
||||
groupNumberVal!,
|
||||
submissionNumberVal!,
|
||||
),
|
||||
),
|
||||
queryFn: async () =>
|
||||
new SubmissionController(hruidVal!).getByNumber(
|
||||
languageVal,
|
||||
versionVal!,
|
||||
classIdVal!,
|
||||
assignmentNumberVal!,
|
||||
groupNumberVal!,
|
||||
submissionNumberVal!,
|
||||
),
|
||||
enabled: () =>
|
||||
Boolean(hruidVal) &&
|
||||
Boolean(languageVal) &&
|
||||
Boolean(versionVal) &&
|
||||
Boolean(classIdVal) &&
|
||||
Boolean(assignmentNumberVal) &&
|
||||
Boolean(submissionNumber),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSubmissionMutation(): UseMutationReturnType<
|
||||
SubmissionResponse,
|
||||
Error,
|
||||
{ data: SubmissionDTO },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ data }) =>
|
||||
new SubmissionController(data.learningObjectIdentifier.hruid).createSubmission(data),
|
||||
onSuccess: async (response) => {
|
||||
if (!response.submission.group) {
|
||||
await invalidateAllSubmissionKeys(queryClient);
|
||||
} else {
|
||||
const cls = response.submission.group.class;
|
||||
const assignment = response.submission.group.assignment;
|
||||
|
||||
const cid = typeof cls === "string" ? cls : cls.id;
|
||||
const an = typeof assignment === "number" ? assignment : assignment.id;
|
||||
const gn = response.submission.group.groupNumber;
|
||||
|
||||
const { hruid, language, version } = response.submission.learningObjectIdentifier;
|
||||
await invalidateAllSubmissionKeys(queryClient, hruid, language, version, cid, an, gn);
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY, "get"] });
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [LEARNING_OBJECT_KEY, "metadata", hruid, language, version],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSubmissionMutation(): UseMutationReturnType<
|
||||
SubmissionResponse,
|
||||
Error,
|
||||
{ cid: string; an: number; gn: number; sn: number },
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ cid, sn }) => new SubmissionController(cid).deleteSubmission(sn),
|
||||
onSuccess: async (response) => {
|
||||
if (!response.submission.group) {
|
||||
await invalidateAllSubmissionKeys(queryClient);
|
||||
} else {
|
||||
const cls = response.submission.group.class;
|
||||
const assignment = response.submission.group.assignment;
|
||||
|
||||
const cid = typeof cls === "string" ? cls : cls.id;
|
||||
const an = typeof assignment === "number" ? assignment : assignment.id;
|
||||
const gn = response.submission.group.groupNumber;
|
||||
|
||||
const { hruid, language, version } = response.submission.learningObjectIdentifier;
|
||||
|
||||
await invalidateAllSubmissionKeys(queryClient, hruid, language, version, cid, an, gn);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
135
frontend/src/queries/teacher-invitations.ts
Normal file
135
frontend/src/queries/teacher-invitations.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationReturnType,
|
||||
type UseQueryReturnType,
|
||||
} from "@tanstack/vue-query";
|
||||
import { toValue } from "vue";
|
||||
import type { MaybeRefOrGetter } from "vue";
|
||||
import {
|
||||
TeacherInvitationController,
|
||||
type TeacherInvitationResponse,
|
||||
type TeacherInvitationsResponse,
|
||||
} from "@/controllers/teacher-invitations";
|
||||
import type { TeacherInvitationData } from "@dwengo-1/common/interfaces/teacher-invitation";
|
||||
|
||||
const controller = new TeacherInvitationController();
|
||||
|
||||
/** 🔑 Query keys */
|
||||
export function teacherInvitationsSentQueryKey(username: string): [string, string, string] {
|
||||
return ["teacher-invitations", "sent", username];
|
||||
}
|
||||
|
||||
export function teacherInvitationsReceivedQueryKey(username: string): [string, string, string] {
|
||||
return ["teacher-invitations", "received", username];
|
||||
}
|
||||
|
||||
export function teacherInvitationQueryKey(data: TeacherInvitationData): [string, string, string, string] {
|
||||
return ["teacher-invitation", data.sender, data.receiver, data.class];
|
||||
}
|
||||
|
||||
/**
|
||||
* All the invitations the teacher sent
|
||||
*/
|
||||
export function useTeacherInvitationsSentQuery(
|
||||
username: MaybeRefOrGetter<string | undefined>,
|
||||
): UseQueryReturnType<TeacherInvitationsResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: teacherInvitationsSentQueryKey(toValue(username)!),
|
||||
queryFn: async () => controller.getAll(toValue(username)!, true),
|
||||
enabled: () => Boolean(toValue(username)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* All the pending invitations sent to this teacher
|
||||
*/
|
||||
export function useTeacherInvitationsReceivedQuery(
|
||||
username: MaybeRefOrGetter<string | undefined>,
|
||||
): UseQueryReturnType<TeacherInvitationsResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: teacherInvitationsReceivedQueryKey(toValue(username)!),
|
||||
queryFn: async () => controller.getAll(toValue(username)!, false),
|
||||
enabled: () => Boolean(toValue(username)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTeacherInvitationQuery(
|
||||
data: MaybeRefOrGetter<TeacherInvitationData | undefined>,
|
||||
): UseQueryReturnType<TeacherInvitationResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: teacherInvitationQueryKey(toValue(data)),
|
||||
queryFn: async () => controller.getBy(toValue(data)),
|
||||
enabled: () => Boolean(toValue(data)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTeacherInvitationMutation(): UseMutationReturnType<
|
||||
TeacherInvitationResponse,
|
||||
Error,
|
||||
TeacherInvitationData,
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => controller.create(data),
|
||||
onSuccess: async (_, data) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: teacherInvitationsSentQueryKey(data.sender),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: teacherInvitationsReceivedQueryKey(data.receiver),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRespondTeacherInvitationMutation(): UseMutationReturnType<
|
||||
TeacherInvitationResponse,
|
||||
Error,
|
||||
TeacherInvitationData,
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => controller.respond(data),
|
||||
onSuccess: async (_, data) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: teacherInvitationsSentQueryKey(data.sender),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: teacherInvitationsReceivedQueryKey(data.receiver),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: teacherInvitationQueryKey(data),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTeacherInvitationMutation(): UseMutationReturnType<
|
||||
TeacherInvitationResponse,
|
||||
Error,
|
||||
TeacherInvitationData,
|
||||
unknown
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => controller.remove(data),
|
||||
onSuccess: async (_, data) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: teacherInvitationsSentQueryKey(data.sender),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: teacherInvitationsReceivedQueryKey(data.receiver),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: teacherInvitationQueryKey(data),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -37,6 +37,10 @@ function teacherQuestionsQueryKey(username: string, full: boolean): [string, str
|
|||
return ["teacher-questions", username, full];
|
||||
}
|
||||
|
||||
export function teacherClassJoinRequests(classId: string): [string, string] {
|
||||
return ["teacher-class-join-requests", classId];
|
||||
}
|
||||
|
||||
export function useTeachersQuery(full: MaybeRefOrGetter<boolean> = false): UseQueryReturnType<TeachersResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => teachersQueryKey(toValue(full))),
|
||||
|
|
@ -92,7 +96,7 @@ export function useTeacherJoinRequestsQuery(
|
|||
classId: MaybeRefOrGetter<string | undefined>,
|
||||
): UseQueryReturnType<JoinRequestsResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => JOIN_REQUESTS_QUERY_KEY(toValue(username)!, toValue(classId)!)),
|
||||
queryKey: computed(() => teacherClassJoinRequests(toValue(classId)!)),
|
||||
queryFn: async () => teacherController.getStudentJoinRequests(toValue(username)!, toValue(classId)!),
|
||||
enabled: () => Boolean(toValue(username)) && Boolean(toValue(classId)),
|
||||
});
|
||||
|
|
@ -133,10 +137,11 @@ export function useUpdateJoinRequestMutation(): UseMutationReturnType<
|
|||
mutationFn: async ({ teacherUsername, classId, studentUsername, accepted }) =>
|
||||
teacherController.updateStudentJoinRequest(teacherUsername, classId, studentUsername, accepted),
|
||||
onSuccess: async (deletedJoinRequest) => {
|
||||
const username = deletedJoinRequest.request.requester;
|
||||
const username = deletedJoinRequest.request.requester.username;
|
||||
const classId = deletedJoinRequest.request.class;
|
||||
await queryClient.invalidateQueries({ queryKey: studentJoinRequestsQueryKey(username) });
|
||||
await queryClient.invalidateQueries({ queryKey: studentJoinRequestQueryKey(username, classId) });
|
||||
await queryClient.invalidateQueries({ queryKey: teacherClassJoinRequests(classId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query";
|
||||
import { type MaybeRefOrGetter, toValue } from "vue";
|
||||
import type { Theme } from "@dwengo-1/interfaces/theme";
|
||||
import { getThemeController } from "@/controllers/controllers.ts";
|
||||
import type { Theme } from "@dwengo-1/common/interfaces/theme";
|
||||
|
||||
const themeController = getThemeController();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,17 @@ import SingleAssignment from "@/views/assignments/SingleAssignment.vue";
|
|||
import SingleClass from "@/views/classes/SingleClass.vue";
|
||||
import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue";
|
||||
import NotFound from "@/components/errors/NotFound.vue";
|
||||
import CreateClass from "@/views/classes/CreateClass.vue";
|
||||
import CreateAssignment from "@/views/assignments/CreateAssignment.vue";
|
||||
import CreateDiscussion from "@/views/discussions/CreateDiscussion.vue";
|
||||
import CallbackPage from "@/views/CallbackPage.vue";
|
||||
import UserDiscussions from "@/views/discussions/UserDiscussions.vue";
|
||||
import UserClasses from "@/views/classes/UserClasses.vue";
|
||||
import UserAssignments from "@/views/classes/UserAssignments.vue";
|
||||
import authState from "@/services/auth/auth-service.ts";
|
||||
import UserAssignments from "@/views/assignments/UserAssignments.vue";
|
||||
import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue";
|
||||
import LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue";
|
||||
import UserHomePage from "@/views/homepage/UserHomePage.vue";
|
||||
import SingleTheme from "@/views/SingleTheme.vue";
|
||||
import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue";
|
||||
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
||||
import authService from "@/services/auth/auth-service";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -57,11 +55,12 @@ const router = createRouter({
|
|||
name: "UserClasses",
|
||||
component: UserClasses,
|
||||
},
|
||||
{
|
||||
path: "discussion",
|
||||
name: "UserDiscussions",
|
||||
component: UserDiscussions,
|
||||
},
|
||||
// TODO Re-enable this route when the discussion page is ready
|
||||
// {
|
||||
// Path: "discussion",
|
||||
// Name: "UserDiscussions",
|
||||
// Component: UserDiscussions,
|
||||
// },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -73,22 +72,20 @@ const router = createRouter({
|
|||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/assignment/create",
|
||||
name: "CreateAssigment",
|
||||
component: CreateAssignment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/assignment/:id",
|
||||
name: "SingleAssigment",
|
||||
component: SingleAssignment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/class/create",
|
||||
name: "CreateClass",
|
||||
component: CreateClass,
|
||||
path: "/assignment",
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: "create",
|
||||
name: "CreateAssigment",
|
||||
component: CreateAssignment,
|
||||
},
|
||||
{
|
||||
path: ":classId/:id",
|
||||
name: "SingleAssigment",
|
||||
component: SingleAssignment,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/class/:id",
|
||||
|
|
@ -145,9 +142,8 @@ const router = createRouter({
|
|||
router.beforeEach(async (to, _from, next) => {
|
||||
// Verify if user is logged in before accessing certain routes
|
||||
if (to.meta.requiresAuth) {
|
||||
if (!authState.isLoggedIn.value) {
|
||||
//Next("/login");
|
||||
next();
|
||||
if (!authService.isLoggedIn.value && !(await authService.loadUser())) {
|
||||
next("/login");
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ async function getUserManagers(): Promise<UserManagersForRoles> {
|
|||
const authState = reactive<AuthState>({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
activeRole: authStorage.getActiveRole() || null,
|
||||
activeRole: authStorage.getActiveRole() ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -37,19 +37,52 @@ async function loadUser(): Promise<User | null> {
|
|||
if (!activeRole) {
|
||||
return null;
|
||||
}
|
||||
const user = await (await getUserManagers())[activeRole].getUser();
|
||||
authState.user = user;
|
||||
authState.accessToken = user?.access_token || null;
|
||||
authState.activeRole = activeRole || null;
|
||||
|
||||
const userManager = (await getUserManagers())[activeRole];
|
||||
let user = await userManager.getUser(); // Load the user from the local storage.
|
||||
if (!user) {
|
||||
// If the user is not in the local storage, he could still be authenticated in Keycloak.
|
||||
try {
|
||||
user = await userManager.signinSilent();
|
||||
} catch (_: unknown) {
|
||||
// When the user was previously logged in and then logged out, signinSilent throws an error.
|
||||
// In that case, the user is not authenticated anymore, so we set him to null.
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
|
||||
setUserAuthInfo(user);
|
||||
authState.activeRole = activeRole ?? null;
|
||||
return user;
|
||||
}
|
||||
|
||||
const isLoggedIn = computed(() => authState.user !== null);
|
||||
|
||||
/**
|
||||
* Clears all the cached information about the current authentication.
|
||||
*/
|
||||
function clearAuthState(): void {
|
||||
authStorage.deleteActiveRole();
|
||||
authState.accessToken = null;
|
||||
authState.user = null;
|
||||
authState.activeRole = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the information about the currently logged-in user in the cache.
|
||||
*/
|
||||
function setUserAuthInfo(newUser: User | null): void {
|
||||
authState.user = newUser;
|
||||
authState.accessToken = newUser?.access_token ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user to the login page where he/she can choose whether to log in as a student or teacher.
|
||||
*/
|
||||
async function initiateLogin(): Promise<void> {
|
||||
if (isLoggedIn.value) {
|
||||
clearAuthState();
|
||||
}
|
||||
await router.push(loginRoute);
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +105,7 @@ async function handleLoginCallback(): Promise<void> {
|
|||
throw new Error("Login callback received, but the user is not logging in!");
|
||||
}
|
||||
authState.user = (await (await getUserManagers())[activeRole].signinCallback()) || null;
|
||||
await apiClient.post("/auth/hello");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,14 +114,14 @@ async function handleLoginCallback(): Promise<void> {
|
|||
async function renewToken(): Promise<User | null> {
|
||||
const activeRole = authStorage.getActiveRole();
|
||||
if (!activeRole) {
|
||||
// FIXME console.log("Can't renew the token: Not logged in!");
|
||||
await initiateLogin();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await (await getUserManagers())[activeRole].signinSilent();
|
||||
} catch (_error) {
|
||||
// FIXME console.log("Can't renew the token: " + error);
|
||||
const userManagerForRole = (await getUserManagers())[activeRole];
|
||||
const user = await userManagerForRole.signinSilent();
|
||||
setUserAuthInfo(user);
|
||||
} catch (_error: unknown) {
|
||||
await initiateLogin();
|
||||
}
|
||||
return null;
|
||||
|
|
@ -101,6 +135,7 @@ async function logout(): Promise<void> {
|
|||
if (activeRole) {
|
||||
await (await getUserManagers())[activeRole].signoutRedirect();
|
||||
authStorage.deleteActiveRole();
|
||||
clearAuthState();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,13 +154,15 @@ apiClient.interceptors.request.use(
|
|||
// Registering interceptor to refresh the token when a request failed because it was expired.
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<{ message?: string }>) => {
|
||||
async (error: AxiosError<{ message?: string; inner?: { message?: string } }>) => {
|
||||
if (error.response?.status === 401) {
|
||||
if (error.response.data.message === "token_expired") {
|
||||
// FIXME console.log("Access token expired, trying to refresh...");
|
||||
// If the user should already be logged in, his token is probably just expired.
|
||||
if (isLoggedIn.value) {
|
||||
await renewToken();
|
||||
return apiClient(error.config!); // Retry the request
|
||||
} // Apparently, the user got a 401 because he was not logged in yet at all. Redirect him to login.
|
||||
}
|
||||
|
||||
// Apparently, the user got a 401 because he was not logged in yet at all. Redirect him to login.
|
||||
await initiateLogin();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
|
|
|||
5
frontend/src/utils/array-utils.ts
Normal file
5
frontend/src/utils/array-utils.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function copyArrayWith<T>(index: number, newValue: T, array: T[]): T[] {
|
||||
const copy = [...array];
|
||||
copy[index] = newValue;
|
||||
return copy;
|
||||
}
|
||||
76
frontend/src/utils/assignment-rules.ts
Normal file
76
frontend/src/utils/assignment-rules.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Validation rule for the assignment title.
|
||||
*
|
||||
* Ensures that the title is not empty.
|
||||
*/
|
||||
export const assignmentTitleRules = [
|
||||
(value: string): string | boolean => {
|
||||
if (value?.length >= 1) {
|
||||
return true;
|
||||
} // Title must not be empty
|
||||
return "Title cannot be empty.";
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation rule for the learning path selection.
|
||||
*
|
||||
* Ensures that a valid learning path is selected.
|
||||
*/
|
||||
export const learningPathRules = [
|
||||
(value: { hruid: string; title: string }): string | boolean => {
|
||||
if (value && value.hruid) {
|
||||
return true; // Valid if hruid is present
|
||||
}
|
||||
return "You must select a learning path.";
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation rule for the classes selection.
|
||||
*
|
||||
* Ensures that at least one class is selected.
|
||||
*/
|
||||
export const classRules = [
|
||||
(value: string): string | boolean => {
|
||||
if (value) {
|
||||
return true;
|
||||
}
|
||||
return "You must select at least one class.";
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation rule for the deadline field.
|
||||
*
|
||||
* Ensures that a valid deadline is selected and is in the future.
|
||||
*/
|
||||
export const deadlineRules = [
|
||||
(value: string): string | boolean => {
|
||||
if (!value) {
|
||||
return "You must set a deadline.";
|
||||
}
|
||||
|
||||
const selectedDateTime = new Date(value);
|
||||
const now = new Date();
|
||||
|
||||
if (isNaN(selectedDateTime.getTime())) {
|
||||
return "Invalid date or time.";
|
||||
}
|
||||
|
||||
if (selectedDateTime <= now) {
|
||||
return "The deadline must be in the future.";
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
];
|
||||
|
||||
export const descriptionRules = [
|
||||
(value: string): string | boolean => {
|
||||
if (!value || value.trim() === "") {
|
||||
return "Description cannot be empty.";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
];
|
||||
29
frontend/src/utils/deep-equals.ts
Normal file
29
frontend/src/utils/deep-equals.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export function deepEquals<T>(a: T, b: T): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) !== Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return a.every((val, i) => deepEquals(val, b[i]));
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a) as (keyof T)[];
|
||||
const keysB = Object.keys(b) as (keyof T)[];
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return keysA.every((key) => deepEquals(a[key], b[key]));
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { onMounted, ref, type Ref } from "vue";
|
||||
import auth from "../services/auth/auth-service.ts";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const errorMessage: Ref<string | null> = ref(null);
|
||||
|
|
@ -12,14 +15,34 @@
|
|||
await auth.handleLoginCallback();
|
||||
await router.replace("/user"); // Redirect to theme page
|
||||
} catch (error) {
|
||||
errorMessage.value = `OIDC callback error: ${error}`;
|
||||
errorMessage.value = `${t("loginUnexpectedError")}: ${error}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="!errorMessage">Logging you in...</p>
|
||||
<p v-else>{{ errorMessage }}</p>
|
||||
<div class="callback">
|
||||
<div
|
||||
class="callback-loading"
|
||||
v-if="!errorMessage"
|
||||
>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
<p>{{ t("callbackLoading") }}</p>
|
||||
</div>
|
||||
<v-alert
|
||||
icon="mdi-alert-circle"
|
||||
type="error"
|
||||
variant="elevated"
|
||||
v-if="errorMessage"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.callback {
|
||||
text-align: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,258 @@
|
|||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import GroupSelector from "@/components/assignments/GroupSelector.vue";
|
||||
import { assignmentTitleRules, classRules, descriptionRules, learningPathRules } from "@/utils/assignment-rules.ts";
|
||||
import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue";
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
import { useTeacherClassesQuery } from "@/queries/teachers.ts";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useGetAllLearningPaths } from "@/queries/learning-paths.ts";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
|
||||
import type { ClassesResponse } from "@/controllers/classes.ts";
|
||||
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
|
||||
import { useCreateAssignmentMutation } from "@/queries/assignments.ts";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const role = ref(auth.authState.activeRole);
|
||||
const username = ref<string>("");
|
||||
|
||||
onMounted(async () => {
|
||||
// Redirect student
|
||||
if (role.value === "student") {
|
||||
await router.push("/user");
|
||||
}
|
||||
|
||||
// Get the user's username
|
||||
const user = await auth.loadUser();
|
||||
username.value = user?.profile?.preferred_username ?? "";
|
||||
});
|
||||
|
||||
const language = computed(() => locale.value);
|
||||
const form = ref();
|
||||
|
||||
//Fetch all learning paths
|
||||
const learningPathsQueryResults = useGetAllLearningPaths(language);
|
||||
|
||||
// Fetch and store all the teacher's classes
|
||||
const classesQueryResults = useTeacherClassesQuery(username, true);
|
||||
|
||||
const selectedClass = ref(undefined);
|
||||
|
||||
const assignmentTitle = ref("");
|
||||
const selectedLearningPath = ref(route.query.hruid || undefined);
|
||||
|
||||
// Disable combobox when learningPath prop is passed
|
||||
const lpIsSelected = route.query.hruid !== undefined;
|
||||
const deadline = ref(null);
|
||||
const description = ref("");
|
||||
const groups = ref<string[][]>([]);
|
||||
|
||||
// New group is added to the list
|
||||
function addGroupToList(students: string[]): void {
|
||||
if (students.length) {
|
||||
groups.value = [...groups.value, students];
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedClass, () => {
|
||||
groups.value = [];
|
||||
});
|
||||
|
||||
const { mutate, data, isSuccess } = useCreateAssignmentMutation();
|
||||
|
||||
watch([isSuccess, data], async ([success, newData]) => {
|
||||
if (success && newData?.assignment) {
|
||||
await router.push(`/assignment/${newData.assignment.within}/${newData.assignment.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
async function submitFormHandler(): Promise<void> {
|
||||
const { valid } = await form.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
let lp = selectedLearningPath.value;
|
||||
if (!lpIsSelected) {
|
||||
lp = selectedLearningPath.value?.hruid;
|
||||
}
|
||||
|
||||
const assignmentDTO: AssignmentDTO = {
|
||||
id: 0,
|
||||
within: selectedClass.value?.id || "",
|
||||
title: assignmentTitle.value,
|
||||
description: description.value,
|
||||
learningPath: lp || "",
|
||||
language: language.value,
|
||||
groups: groups.value,
|
||||
};
|
||||
|
||||
mutate({ cid: assignmentDTO.within, data: assignmentDTO });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
<div class="main-container">
|
||||
<h1 class="title">{{ t("new-assignment") }}</h1>
|
||||
<v-card class="form-card">
|
||||
<v-form
|
||||
ref="form"
|
||||
class="form-container"
|
||||
validate-on="submit lazy"
|
||||
@submit.prevent="submitFormHandler"
|
||||
>
|
||||
<v-container class="step-container">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="assignmentTitle"
|
||||
:label="t('title')"
|
||||
:rules="assignmentTitleRules"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
required
|
||||
></v-text-field>
|
||||
</v-card-text>
|
||||
|
||||
<using-query-result
|
||||
:query-result="learningPathsQueryResults"
|
||||
v-slot="{ data }: { data: LearningPath[] }"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-combobox
|
||||
v-model="selectedLearningPath"
|
||||
:items="data"
|
||||
:label="t('choose-lp')"
|
||||
:rules="learningPathRules"
|
||||
variant="outlined"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
append-inner-icon="mdi-magnify"
|
||||
item-title="title"
|
||||
item-value="hruid"
|
||||
required
|
||||
:disabled="lpIsSelected"
|
||||
:filter="
|
||||
(item, query: string) => item.title.toLowerCase().includes(query.toLowerCase())
|
||||
"
|
||||
></v-combobox>
|
||||
</v-card-text>
|
||||
</using-query-result>
|
||||
|
||||
<using-query-result
|
||||
:query-result="classesQueryResults"
|
||||
v-slot="{ data }: { data: ClassesResponse }"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-combobox
|
||||
v-model="selectedClass"
|
||||
:items="data?.classes ?? []"
|
||||
:label="t('pick-class')"
|
||||
:rules="classRules"
|
||||
variant="outlined"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
append-inner-icon="mdi-magnify"
|
||||
item-title="displayName"
|
||||
item-value="id"
|
||||
required
|
||||
></v-combobox>
|
||||
</v-card-text>
|
||||
</using-query-result>
|
||||
|
||||
<GroupSelector
|
||||
:classId="selectedClass?.id"
|
||||
:groups="groups"
|
||||
@groupCreated="addGroupToList"
|
||||
/>
|
||||
|
||||
<!-- Counter for created groups -->
|
||||
<v-card-text v-if="groups.length">
|
||||
<strong>Created Groups: {{ groups.length }}</strong>
|
||||
</v-card-text>
|
||||
<DeadlineSelector v-model:deadline="deadline" />
|
||||
<v-card-text>
|
||||
<v-textarea
|
||||
v-model="description"
|
||||
:label="t('description')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
auto-grow
|
||||
rows="3"
|
||||
:rules="descriptionRules"
|
||||
></v-textarea>
|
||||
</v-card-text>
|
||||
<v-card-text>
|
||||
<v-btn
|
||||
class="mt-2"
|
||||
color="secondary"
|
||||
type="submit"
|
||||
block
|
||||
>{{ t("submit") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
to="/user/assignment"
|
||||
color="grey"
|
||||
block
|
||||
>{{ t("cancel") }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-container>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 55%;
|
||||
/*padding: 1%;*/
|
||||
}
|
||||
|
||||
.form-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.form-card {
|
||||
width: 70%;
|
||||
padding: 1%;
|
||||
}
|
||||
|
||||
.step-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.form-card {
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,75 @@
|
|||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
import { computed, type Ref, ref, watchEffect } from "vue";
|
||||
import StudentAssignment from "@/views/assignments/StudentAssignment.vue";
|
||||
import TeacherAssignment from "@/views/assignments/TeacherAssignment.vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import type { Language } from "@/data-objects/language.ts";
|
||||
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
|
||||
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
|
||||
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
|
||||
|
||||
const role = auth.authState.activeRole;
|
||||
const isTeacher = computed(() => role === "teacher");
|
||||
|
||||
const route = useRoute();
|
||||
const classId = ref<string>(route.params.classId as string);
|
||||
const assignmentId = ref(Number(route.params.id));
|
||||
|
||||
function useGroupsWithProgress(
|
||||
groups: Ref<GroupDTO[]>,
|
||||
hruid: Ref<string>,
|
||||
language: Ref<string>,
|
||||
): { groupProgressMap: Map<number, number> } {
|
||||
const groupProgressMap: Map<number, number> = new Map<number, number>();
|
||||
|
||||
watchEffect(() => {
|
||||
// Clear existing entries to avoid stale data
|
||||
groupProgressMap.clear();
|
||||
|
||||
const lang = ref(language.value as Language);
|
||||
|
||||
groups.value.forEach((group) => {
|
||||
const groupKey = group.groupNumber;
|
||||
const forGroup = ref({
|
||||
forGroup: groupKey,
|
||||
assignmentNo: assignmentId,
|
||||
classId: classId,
|
||||
});
|
||||
|
||||
const query = useGetLearningPathQuery(hruid.value, lang, forGroup);
|
||||
|
||||
const data = query.data.value;
|
||||
|
||||
groupProgressMap.set(groupKey, data ? calculateProgress(data) : 0);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
groupProgressMap,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateProgress(lp: LearningPath): number {
|
||||
return ((lp.amountOfNodes - lp.amountOfNodesLeft) / lp.amountOfNodes) * 100;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
<TeacherAssignment
|
||||
:class-id="classId"
|
||||
:assignment-id="assignmentId"
|
||||
:use-groups-with-progress="useGroupsWithProgress"
|
||||
v-if="isTeacher"
|
||||
>
|
||||
</TeacherAssignment>
|
||||
<StudentAssignment
|
||||
:class-id="classId"
|
||||
:assignment-id="assignmentId"
|
||||
:use-groups-with-progress="useGroupsWithProgress"
|
||||
v-else
|
||||
>
|
||||
</StudentAssignment>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
167
frontend/src/views/assignments/StudentAssignment.vue
Normal file
167
frontend/src/views/assignments/StudentAssignment.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, type Ref } from "vue";
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useAssignmentQuery } from "@/queries/assignments.ts";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import type { AssignmentResponse } from "@/controllers/assignments.ts";
|
||||
import { asyncComputed } from "@vueuse/core";
|
||||
import { useStudentsByUsernamesQuery } from "@/queries/students.ts";
|
||||
import { useGroupsQuery } from "@/queries/groups.ts";
|
||||
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
|
||||
import type { Language } from "@/data-objects/language.ts";
|
||||
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
|
||||
|
||||
const props = defineProps<{
|
||||
classId: string;
|
||||
assignmentId: number;
|
||||
useGroupsWithProgress: (
|
||||
groups: Ref<GroupDTO[]>,
|
||||
hruid: Ref<string>,
|
||||
language: Ref<Language>,
|
||||
) => { groupProgressMap: Map<number, number> };
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const language = ref<Language>(locale.value as Language);
|
||||
const learningPath = ref();
|
||||
// Get the user's username/id
|
||||
const username = asyncComputed(async () => {
|
||||
const user = await auth.loadUser();
|
||||
return user?.profile?.preferred_username ?? undefined;
|
||||
});
|
||||
|
||||
const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId);
|
||||
learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath;
|
||||
|
||||
const submitted = ref(false); //TODO: update by fetching submissions and check if group submitted
|
||||
|
||||
const lpQueryResult = useGetLearningPathQuery(
|
||||
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""),
|
||||
computed(() => language.value),
|
||||
);
|
||||
|
||||
const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true);
|
||||
const group = computed(() =>
|
||||
groupsQueryResult?.data.value?.groups.find((group) =>
|
||||
group.members?.some((m) => m.username === username.value),
|
||||
),
|
||||
);
|
||||
|
||||
const _groupArray = computed(() => (group.value ? [group.value] : []));
|
||||
const progressValue = ref(0);
|
||||
/* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar
|
||||
Const {groupProgressMap} = props.useGroupsWithProgress(
|
||||
groupArray,
|
||||
learningPath,
|
||||
language
|
||||
);
|
||||
*/
|
||||
|
||||
// Assuming group.value.members is a list of usernames TODO: case when it's StudentDTO's
|
||||
const studentQueries = useStudentsByUsernamesQuery(() => group.value?.members as string[]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<using-query-result
|
||||
:query-result="assignmentQueryResult"
|
||||
v-slot="{ data }: { data: AssignmentResponse }"
|
||||
>
|
||||
<v-card
|
||||
v-if="data"
|
||||
class="assignment-card"
|
||||
>
|
||||
<div class="top-buttons">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="back-btn"
|
||||
to="/user/assignment"
|
||||
>
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-chip
|
||||
v-if="submitted"
|
||||
class="ma-2 top-right-btn"
|
||||
label
|
||||
color="success"
|
||||
>
|
||||
{{ t("submitted") }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-card-title class="text-h4 assignmentTopTitle">{{ data.assignment.title }}</v-card-title>
|
||||
|
||||
<v-card-subtitle class="subtitle-section">
|
||||
<using-query-result
|
||||
:query-result="lpQueryResult"
|
||||
v-slot="{ data: lpData }"
|
||||
>
|
||||
<v-btn
|
||||
v-if="lpData"
|
||||
:to="`/learningPath/${lpData.hruid}/${language}/${lpData.startNode.learningobjectHruid}?forGroup=${group?.groupNumber}&assignmentNo=${assignmentId}&classId=${classId}`"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
>
|
||||
{{ t("learning-path") }}
|
||||
</v-btn>
|
||||
</using-query-result>
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-card-text class="description">
|
||||
{{ data.assignment.description }}
|
||||
</v-card-text>
|
||||
<v-card-text>
|
||||
<v-row
|
||||
align="center"
|
||||
no-gutters
|
||||
>
|
||||
<v-col cols="auto">
|
||||
<span class="progress-label">{{ t("progress") + ": " }}</span>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-progress-linear
|
||||
:model-value="progressValue"
|
||||
color="primary"
|
||||
height="20"
|
||||
class="progress-bar"
|
||||
>
|
||||
<template v-slot:default="{ value }">
|
||||
<strong>{{ Math.ceil(value) }}%</strong>
|
||||
</template>
|
||||
</v-progress-linear>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="group-section">
|
||||
<h3>{{ t("group") }}</h3>
|
||||
<div v-if="studentQueries">
|
||||
<ul>
|
||||
<li
|
||||
v-for="student in group?.members"
|
||||
:key="student.username"
|
||||
>
|
||||
{{ student.firstName + " " + student.lastName }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</using-query-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "@/assets/assignment.css";
|
||||
|
||||
.progress-label {
|
||||
font-weight: bold;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 40%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
234
frontend/src/views/assignments/TeacherAssignment.vue
Normal file
234
frontend/src/views/assignments/TeacherAssignment.vue
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, type Ref, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useAssignmentQuery, useDeleteAssignmentMutation } from "@/queries/assignments.ts";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import { useGroupsQuery } from "@/queries/groups.ts";
|
||||
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
|
||||
import type { Language } from "@/data-objects/language.ts";
|
||||
import type { AssignmentResponse } from "@/controllers/assignments.ts";
|
||||
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
|
||||
|
||||
const props = defineProps<{
|
||||
classId: string;
|
||||
assignmentId: number;
|
||||
useGroupsWithProgress: (
|
||||
groups: Ref<GroupDTO[]>,
|
||||
hruid: Ref<string>,
|
||||
language: Ref<Language>,
|
||||
) => { groupProgressMap: Map<number, number> };
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const language = computed(() => locale.value);
|
||||
const groups = ref();
|
||||
const learningPath = ref();
|
||||
|
||||
const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId);
|
||||
learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath;
|
||||
// Get learning path object
|
||||
const lpQueryResult = useGetLearningPathQuery(
|
||||
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""),
|
||||
computed(() => language.value as Language),
|
||||
);
|
||||
|
||||
// Get all the groups withing the assignment
|
||||
const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true);
|
||||
groups.value = groupsQueryResult.data.value?.groups;
|
||||
|
||||
/* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar
|
||||
Const {groupProgressMap} = props.useGroupsWithProgress(
|
||||
groups,
|
||||
learningPath,
|
||||
language
|
||||
);
|
||||
*/
|
||||
|
||||
const allGroups = computed(() => {
|
||||
const groups = groupsQueryResult.data.value?.groups;
|
||||
if (!groups) return [];
|
||||
|
||||
return groups.map((group) => ({
|
||||
name: `${t("group")} ${group.groupNumber}`,
|
||||
progress: 0, //GroupProgressMap[group.groupNumber],
|
||||
members: group.members,
|
||||
submitted: false, //TODO: fetch from submission
|
||||
}));
|
||||
});
|
||||
|
||||
const dialog = ref(false);
|
||||
const selectedGroup = ref({});
|
||||
|
||||
function openGroupDetails(group): void {
|
||||
selectedGroup.value = group;
|
||||
dialog.value = true;
|
||||
}
|
||||
|
||||
const headers = computed(() => [
|
||||
{ title: t("group"), align: "start", key: "name" },
|
||||
{ title: t("progress"), align: "center", key: "progress" },
|
||||
{ title: t("submission"), align: "center", key: "submission" },
|
||||
]);
|
||||
|
||||
const { mutate } = useDeleteAssignmentMutation();
|
||||
|
||||
async function deleteAssignment(num: number, clsId: string): Promise<void> {
|
||||
mutate(
|
||||
{ cid: clsId, an: num },
|
||||
{
|
||||
onSuccess: () => {
|
||||
window.location.href = "/user/assignment";
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<using-query-result
|
||||
:query-result="assignmentQueryResult"
|
||||
v-slot="{ data }: { data: AssignmentResponse }"
|
||||
>
|
||||
<v-card
|
||||
v-if="data"
|
||||
class="assignment-card"
|
||||
>
|
||||
<div class="top-buttons">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="back-btn"
|
||||
to="/user/assignment"
|
||||
>
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="top-right-btn"
|
||||
@click="deleteAssignment(data.assignment.id, data.assignment.within)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-card-title class="text-h4 assignmentTopTitle">{{ data.assignment.title }}</v-card-title>
|
||||
<v-card-subtitle class="subtitle-section">
|
||||
<using-query-result
|
||||
:query-result="lpQueryResult"
|
||||
v-slot="{ data: lpData }"
|
||||
>
|
||||
<v-btn
|
||||
v-if="lpData"
|
||||
:to="`/learningPath/${lpData.hruid}/${language}/${lpData.startNode.learningobjectHruid}?assignmentNo=${assignmentId}&classId=${classId}`"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
>
|
||||
{{ t("learning-path") }}
|
||||
</v-btn>
|
||||
</using-query-result>
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-card-text class="description">
|
||||
{{ data.assignment.description }}
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="group-section">
|
||||
<h3>{{ t("groups") }}</h3>
|
||||
<div class="table-scroll">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="allGroups"
|
||||
item-key="id"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<v-btn
|
||||
@click="openGroupDetails(item)"
|
||||
variant="text"
|
||||
color="primary"
|
||||
>
|
||||
{{ item.name }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template #[`item.progress`]="{ item }">
|
||||
<v-progress-linear
|
||||
:model-value="item.progress"
|
||||
color="blue-grey"
|
||||
height="25"
|
||||
>
|
||||
<template v-slot:default="{ value }">
|
||||
<strong>{{ Math.ceil(value) }}%</strong>
|
||||
</template>
|
||||
</v-progress-linear>
|
||||
</template>
|
||||
|
||||
<template #[`item.submission`]="{ item }">
|
||||
<v-btn
|
||||
:to="item.submitted ? `${props.assignmentId}/submissions/` : undefined"
|
||||
:color="item.submitted ? 'green' : 'red'"
|
||||
variant="text"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ item.submitted ? t("see-submission") : t("no-submission") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
max-width="50%"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">{{ t("members") }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="(member, index) in selectedGroup.members"
|
||||
:key="index"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title
|
||||
>{{ member.firstName + " " + member.lastName }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="dialog = false"
|
||||
>Close</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<!--
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn
|
||||
size="large"
|
||||
color="success"
|
||||
variant="text"
|
||||
>
|
||||
{{ t("view-submissions") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
-->
|
||||
</v-card>
|
||||
</using-query-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "@/assets/assignment.css";
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
188
frontend/src/views/assignments/UserAssignments.vue
Normal file
188
frontend/src/views/assignments/UserAssignments.vue
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
import { useTeacherClassesQuery } from "@/queries/teachers.ts";
|
||||
import { useStudentClassesQuery } from "@/queries/students.ts";
|
||||
import { ClassController } from "@/controllers/classes.ts";
|
||||
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
|
||||
import { asyncComputed } from "@vueuse/core";
|
||||
import { useDeleteAssignmentMutation } from "@/queries/assignments.ts";
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const role = ref(auth.authState.activeRole);
|
||||
const username = ref<string>("");
|
||||
|
||||
const isTeacher = computed(() => role.value === "teacher");
|
||||
|
||||
// Fetch and store all the teacher's classes
|
||||
let classesQueryResults = undefined;
|
||||
|
||||
if (isTeacher.value) {
|
||||
classesQueryResults = useTeacherClassesQuery(username, true);
|
||||
} else {
|
||||
classesQueryResults = useStudentClassesQuery(username, true);
|
||||
}
|
||||
|
||||
//TODO: remove later
|
||||
const classController = new ClassController();
|
||||
|
||||
//TODO: replace by query that fetches all user's assignment
|
||||
const assignments = asyncComputed(async () => {
|
||||
const classes = classesQueryResults?.data?.value?.classes;
|
||||
if (!classes) return [];
|
||||
const result = await Promise.all(
|
||||
(classes as ClassDTO[]).map(async (cls) => {
|
||||
const { assignments } = await classController.getAssignments(cls.id);
|
||||
return assignments.map((a) => ({
|
||||
id: a.id,
|
||||
class: cls,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
learningPath: a.learningPath,
|
||||
language: a.language,
|
||||
groups: a.groups,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
return result.flat();
|
||||
}, []);
|
||||
|
||||
async function goToCreateAssignment(): Promise<void> {
|
||||
await router.push("/assignment/create");
|
||||
}
|
||||
|
||||
async function goToAssignmentDetails(id: number, clsId: string): Promise<void> {
|
||||
await router.push(`/assignment/${clsId}/${id}`);
|
||||
}
|
||||
|
||||
const { mutate, data, isSuccess } = useDeleteAssignmentMutation();
|
||||
|
||||
watch([isSuccess, data], async ([success, oldData]) => {
|
||||
if (success && oldData?.assignment) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
async function goToDeleteAssignment(num: number, clsId: string): Promise<void> {
|
||||
mutate({ cid: clsId, an: num });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await auth.loadUser();
|
||||
username.value = user?.profile?.preferred_username ?? "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="assignments-container">
|
||||
<h1>{{ t("assignments") }}</h1>
|
||||
|
||||
<v-btn
|
||||
v-if="isTeacher"
|
||||
color="primary"
|
||||
class="mb-4 center-btn"
|
||||
@click="goToCreateAssignment"
|
||||
>
|
||||
{{ t("new-assignment") }}
|
||||
</v-btn>
|
||||
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="assignment in assignments"
|
||||
:key="assignment.id"
|
||||
cols="12"
|
||||
>
|
||||
<v-card class="assignment-card">
|
||||
<div class="top-content">
|
||||
<div class="assignment-title">{{ assignment.title }}</div>
|
||||
<div class="assignment-class">
|
||||
{{ t("class") }}:
|
||||
<span class="class-name">
|
||||
{{ assignment.class.displayName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<div class="button-row">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="goToAssignmentDetails(assignment.id, assignment.class.id)"
|
||||
>
|
||||
{{ t("view-assignment") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="isTeacher"
|
||||
color="red"
|
||||
variant="text"
|
||||
@click="goToDeleteAssignment(assignment.id, assignment.class.id)"
|
||||
>
|
||||
{{ t("delete") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assignments-container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2% 4%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.center-btn {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.assignment-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.top-content {
|
||||
margin-bottom: 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assignment-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assignment-class {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.class-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,135 +1,358 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import authState from "@/services/auth/auth-service.ts";
|
||||
import { onMounted, ref } from "vue";
|
||||
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
|
||||
import { onMounted, ref, watchEffect } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ClassController, type ClassResponse } from "@/controllers/classes";
|
||||
import type { StudentsResponse } from "@/controllers/students";
|
||||
import type { ClassResponse } from "@/controllers/classes";
|
||||
import type { JoinRequestsResponse, StudentsResponse } from "@/controllers/students";
|
||||
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import { useTeacherJoinRequestsQuery, useUpdateJoinRequestMutation } from "@/queries/teachers";
|
||||
import type { ClassJoinRequestDTO } from "@dwengo-1/common/interfaces/class-join-request";
|
||||
import { useClassDeleteStudentMutation, useClassQuery, useClassStudentsQuery } from "@/queries/classes";
|
||||
import { useCreateTeacherInvitationMutation } from "@/queries/teacher-invitations";
|
||||
import type { TeacherInvitationData } from "@dwengo-1/common/interfaces/teacher-invitation";
|
||||
import { useDisplay } from "vuetify";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Username of logged in teacher
|
||||
const username = ref<string | undefined>(undefined);
|
||||
const classController: ClassController = new ClassController();
|
||||
|
||||
// Find class id from route
|
||||
const route = useRoute();
|
||||
const classId: string = route.params.id as string;
|
||||
const username = ref<string | undefined>(undefined);
|
||||
const isLoading = ref(false);
|
||||
const isError = ref(false);
|
||||
const errorMessage = ref<string>("");
|
||||
const usernameTeacher = ref<string | undefined>(undefined);
|
||||
|
||||
const isLoading = ref(true);
|
||||
const currentClass = ref<ClassDTO | undefined>(undefined);
|
||||
const students = ref<StudentDTO[]>([]);
|
||||
// Queries used to access the backend and catch loading or errors
|
||||
|
||||
// Find the username of the logged in user so it can be used to fetch other information
|
||||
// When loading the page
|
||||
// Gets the class a teacher wants to manage
|
||||
const getClass = useClassQuery(classId);
|
||||
// Get all students part of the class
|
||||
const getStudents = useClassStudentsQuery(classId);
|
||||
// Get all join requests for this class
|
||||
const joinRequestsQuery = useTeacherJoinRequestsQuery(username, classId);
|
||||
// Handle accepting or rejecting join requests
|
||||
const { mutate } = useUpdateJoinRequestMutation();
|
||||
// Handle deletion of a student from the class
|
||||
const { mutate: deleteStudentMutation } = useClassDeleteStudentMutation();
|
||||
// Handle creation of teacher invites
|
||||
const { mutate: sentInviteMutation } = useCreateTeacherInvitationMutation();
|
||||
|
||||
// Load current user before rendering the page
|
||||
onMounted(async () => {
|
||||
const userObject = await authState.loadUser();
|
||||
username.value = userObject?.profile?.preferred_username ?? undefined;
|
||||
|
||||
// Get class of which information should be shown
|
||||
const classResponse: ClassResponse = await classController.getById(classId);
|
||||
if (classResponse && classResponse.class) {
|
||||
currentClass.value = classResponse.class;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const userObject = await authState.loadUser();
|
||||
username.value = userObject!.profile.preferred_username;
|
||||
} catch (error) {
|
||||
isError.value = true;
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
// Fetch all students of the class
|
||||
const studentsResponse: StudentsResponse = await classController.getStudents(classId);
|
||||
if (studentsResponse && studentsResponse.students) students.value = studentsResponse.students as StudentDTO[];
|
||||
});
|
||||
|
||||
// TODO: Boolean that handles visibility for dialogs
|
||||
// Popup to verify removing student
|
||||
// Used to set the visibility of the dialog
|
||||
const dialog = ref(false);
|
||||
// Student selected for deletion
|
||||
const selectedStudent = ref<StudentDTO | null>(null);
|
||||
|
||||
// Let the teacher verify deletion of a student
|
||||
function showPopup(s: StudentDTO): void {
|
||||
selectedStudent.value = s;
|
||||
dialog.value = true;
|
||||
}
|
||||
|
||||
// Remove student from class
|
||||
function removeStudentFromclass(): void {
|
||||
dialog.value = false;
|
||||
async function removeStudentFromclass(): Promise<void> {
|
||||
// Delete student from class
|
||||
deleteStudentMutation(
|
||||
{ id: classId, username: selectedStudent.value!.username },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
dialog.value = false;
|
||||
await getStudents.refetch();
|
||||
showSnackbar(t("success"), "success");
|
||||
},
|
||||
onError: (e) => {
|
||||
dialog.value = false;
|
||||
showSnackbar(t("failed") + ": " + e.message, "error");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleJoinRequest(c: ClassJoinRequestDTO, accepted: boolean): void {
|
||||
// Handle acception or rejection of a join request
|
||||
mutate(
|
||||
{
|
||||
teacherUsername: username.value!,
|
||||
studentUsername: c.requester.username,
|
||||
classId: c.class,
|
||||
accepted: accepted,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
if (accepted) {
|
||||
await joinRequestsQuery.refetch();
|
||||
await getStudents.refetch();
|
||||
|
||||
showSnackbar(t("accepted"), "success");
|
||||
} else {
|
||||
await joinRequestsQuery.refetch();
|
||||
showSnackbar(t("rejected"), "success");
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
showSnackbar(t("failed") + ": " + e.message, "error");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function sentInvite(): void {
|
||||
if (!usernameTeacher.value) {
|
||||
showSnackbar(t("please enter a valid username"), "error");
|
||||
return;
|
||||
}
|
||||
const data: TeacherInvitationData = {
|
||||
sender: username.value!,
|
||||
receiver: usernameTeacher.value,
|
||||
class: classId,
|
||||
};
|
||||
sentInviteMutation(data, {
|
||||
onSuccess: () => {
|
||||
usernameTeacher.value = "";
|
||||
},
|
||||
onError: (e) => {
|
||||
showSnackbar(t("failed") + ": " + e.message, "error");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Default of snackbar values
|
||||
const snackbar = ref({
|
||||
visible: false,
|
||||
message: "",
|
||||
color: "success",
|
||||
});
|
||||
|
||||
// Function to show snackbar on success or failure
|
||||
function showSnackbar(message: string, color: string): void {
|
||||
snackbar.value.message = message;
|
||||
snackbar.value.color = color;
|
||||
snackbar.value.visible = true;
|
||||
}
|
||||
|
||||
// Custom breakpoints
|
||||
const customBreakpoints = {
|
||||
xs: 0,
|
||||
sm: 500,
|
||||
md: 1370,
|
||||
lg: 1400,
|
||||
xl: 1600,
|
||||
};
|
||||
|
||||
// Logic for small screens
|
||||
const display = useDisplay();
|
||||
|
||||
// Reactive variables to hold custom logic based on breakpoints
|
||||
const isSmAndDown = ref(false);
|
||||
const isMdAndDown = ref(false);
|
||||
|
||||
watchEffect(() => {
|
||||
// Custom breakpoint logic
|
||||
isSmAndDown.value = display.width.value < customBreakpoints.sm;
|
||||
isMdAndDown.value = display.width.value < customBreakpoints.md;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<div
|
||||
class="loading-div"
|
||||
v-if="isLoading"
|
||||
class="text-center py-10"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<p>Loading...</p>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h1 class="title">{{ currentClass!.displayName }}</h1>
|
||||
<v-container
|
||||
fluid
|
||||
class="ma-4"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
fluid
|
||||
<div v-if="isError">
|
||||
<v-empty-state
|
||||
icon="mdi-alert-circle-outline"
|
||||
:text="errorMessage"
|
||||
:title="t('error_title')"
|
||||
></v-empty-state>
|
||||
</div>
|
||||
<using-query-result
|
||||
:query-result="getClass"
|
||||
v-slot="classResponse: { data: ClassResponse }"
|
||||
>
|
||||
<div>
|
||||
<h1 class="title">{{ classResponse.data.class.displayName }}</h1>
|
||||
<using-query-result
|
||||
:query-result="getStudents"
|
||||
v-slot="studentsResponse: { data: StudentsResponse }"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
<v-container
|
||||
fluid
|
||||
class="ma-4"
|
||||
>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("students") }}</th>
|
||||
<th class="header"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in students"
|
||||
:key="s.id"
|
||||
<v-row
|
||||
no-gutters
|
||||
fluid
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("students") }}</th>
|
||||
<th class="header"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in studentsResponse.data.students as StudentDTO[]"
|
||||
:key="s.id"
|
||||
>
|
||||
<td>
|
||||
{{ s.firstName + " " + s.lastName }}
|
||||
</td>
|
||||
<td>
|
||||
<v-btn @click="showPopup(s)"> {{ t("remove") }} </v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
<using-query-result
|
||||
:query-result="joinRequestsQuery"
|
||||
v-slot="joinRequests: { data: JoinRequestsResponse }"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
>
|
||||
<td>
|
||||
{{ s.firstName + " " + s.lastName }}
|
||||
</td>
|
||||
<td>
|
||||
<v-btn @click="showPopup"> {{ t("remove") }} </v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</div>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
max-width="400px"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">{{ t("areusure") }}</v-card-title>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("classJoinRequests") }}</th>
|
||||
<th class="header">{{ t("accept") + "/" + t("reject") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="jr in joinRequests.data.joinRequests as ClassJoinRequestDTO[]"
|
||||
:key="(jr.class, jr.requester, jr.status)"
|
||||
>
|
||||
<td>
|
||||
{{ jr.requester.firstName + " " + jr.requester.lastName }}
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="!isSmAndDown && !isMdAndDown">
|
||||
<v-btn
|
||||
@click="handleJoinRequest(jr, true)"
|
||||
class="mr-2"
|
||||
color="green"
|
||||
>
|
||||
{{ t("accept") }}</v-btn
|
||||
>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
text
|
||||
@click="dialog = false"
|
||||
<v-btn
|
||||
@click="handleJoinRequest(jr, false)"
|
||||
class="mr-2"
|
||||
color="red"
|
||||
>
|
||||
{{ t("reject") }}
|
||||
</v-btn>
|
||||
</span>
|
||||
<span v-else>
|
||||
<v-btn
|
||||
@click="handleJoinRequest(jr, true)"
|
||||
icon="mdi-check-circle"
|
||||
class="mr-2"
|
||||
color="green"
|
||||
variant="text"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
@click="handleJoinRequest(jr, false)"
|
||||
icon="mdi-close-circle"
|
||||
class="mr-2"
|
||||
color="red"
|
||||
variant="text"
|
||||
></v-btn>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</using-query-result>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</using-query-result>
|
||||
</div>
|
||||
<div>
|
||||
<div class="join">
|
||||
<h2>{{ t("invitations") }}</h2>
|
||||
<p>{{ t("enterUsername") }}</p>
|
||||
|
||||
<v-sheet
|
||||
class="pa-4 sheet"
|
||||
max-width="400"
|
||||
>
|
||||
{{ t("cancel") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
@click="removeStudentFromclass"
|
||||
>{{ t("yes") }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-form @submit.prevent>
|
||||
<v-text-field
|
||||
:label="`${t('username')}`"
|
||||
v-model="usernameTeacher"
|
||||
:placeholder="`${t('username')}`"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
class="mt-4"
|
||||
color="#f6faf2"
|
||||
type="submit"
|
||||
@click="sentInvite"
|
||||
block
|
||||
>{{ t("invite") }}</v-btn
|
||||
>
|
||||
</v-form>
|
||||
</v-sheet>
|
||||
</div>
|
||||
</div>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
max-width="400px"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">{{ t("areusure") }}</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
text
|
||||
@click="dialog = false"
|
||||
>
|
||||
{{ t("cancel") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
@click="removeStudentFromclass"
|
||||
>{{ t("yes") }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-snackbar
|
||||
v-model="snackbar.visible"
|
||||
:color="snackbar.color"
|
||||
timeout="3000"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
</using-query-result>
|
||||
</main>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import authState from "@/services/auth/auth-service.ts";
|
||||
import { computed, onMounted, ref, type ComputedRef } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { validate, version } from "uuid";
|
||||
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
|
||||
import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students";
|
||||
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
|
||||
import { StudentController } from "@/controllers/students";
|
||||
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
|
||||
import { TeacherController } from "@/controllers/teachers";
|
||||
import type { ClassesResponse } from "@/controllers/classes";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import { useClassStudentsQuery, useClassTeachersQuery } from "@/queries/classes";
|
||||
import type { StudentsResponse } from "@/controllers/students";
|
||||
import type { TeachersResponse } from "@/controllers/teachers";
|
||||
|
||||
const { t } = useI18n();
|
||||
const studentController: StudentController = new StudentController();
|
||||
const teacherController: TeacherController = new TeacherController();
|
||||
|
||||
// Username of logged in student
|
||||
const username = ref<string | undefined>(undefined);
|
||||
|
||||
// Find the username of the logged in user so it can be used to fetch other information
|
||||
// When loading the page
|
||||
onMounted(async () => {
|
||||
const userObject = await authState.loadUser();
|
||||
username.value = userObject?.profile?.preferred_username ?? undefined;
|
||||
});
|
||||
|
||||
// Fetch all classes of the logged in student
|
||||
const { data: classesResponse, isLoading, error } = useStudentClassesQuery(username);
|
||||
|
||||
// Empty list when classes are not yet loaded, else the list of classes of the user
|
||||
const classes: ComputedRef<ClassDTO[]> = computed(() => {
|
||||
// The classes are not yet fetched
|
||||
if (!classesResponse.value) {
|
||||
return [];
|
||||
}
|
||||
// The user has no classes
|
||||
if (classesResponse.value.classes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return classesResponse.value.classes as ClassDTO[];
|
||||
});
|
||||
const isLoading = ref(false);
|
||||
const isError = ref(false);
|
||||
const errorMessage = ref<string>("");
|
||||
|
||||
// Students of selected class are shown when logged in student presses on the member count
|
||||
const selectedClass = ref<ClassDTO | null>(null);
|
||||
const students = ref<StudentDTO[]>([]);
|
||||
const teachers = ref<TeacherDTO[]>([]);
|
||||
const getStudents = ref(false);
|
||||
|
||||
// Load current user before rendering the page
|
||||
onMounted(async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const userObject = await authState.loadUser();
|
||||
username.value = userObject!.profile.preferred_username;
|
||||
} catch (error) {
|
||||
isError.value = true;
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch all classes of the logged in student
|
||||
const classesQuery = useStudentClassesQuery(username);
|
||||
// Fetch all students of the class
|
||||
const getStudentsQuery = useClassStudentsQuery(computed(() => selectedClass.value?.id));
|
||||
// Fetch all teachers of the class
|
||||
const getTeachersQuery = useClassTeachersQuery(computed(() => selectedClass.value?.id));
|
||||
|
||||
// Boolean that handles visibility for dialogs
|
||||
// Clicking on membercount will show a dialog with all members
|
||||
const dialog = ref(false);
|
||||
|
|
@ -54,48 +54,19 @@
|
|||
async function openStudentDialog(c: ClassDTO): Promise<void> {
|
||||
selectedClass.value = c;
|
||||
|
||||
// Clear previous value
|
||||
// Let the component know it should show the students in a class
|
||||
getStudents.value = true;
|
||||
students.value = [];
|
||||
await getStudentsQuery.refetch();
|
||||
dialog.value = true;
|
||||
|
||||
// Fetch students from their usernames to display their full names
|
||||
const studentDTOs: (StudentDTO | null)[] = await Promise.all(
|
||||
c.students.map(async (uid) => {
|
||||
try {
|
||||
const res = await studentController.getByUsername(uid);
|
||||
return res.student;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Only show students that are not fetched ass *null*
|
||||
students.value = studentDTOs.filter(Boolean) as StudentDTO[];
|
||||
}
|
||||
|
||||
async function openTeacherDialog(c: ClassDTO): Promise<void> {
|
||||
selectedClass.value = c;
|
||||
|
||||
// Clear previous value
|
||||
// Let the component know it should show teachers of a class
|
||||
getStudents.value = false;
|
||||
teachers.value = [];
|
||||
await getTeachersQuery.refetch();
|
||||
dialog.value = true;
|
||||
|
||||
// Fetch names of teachers
|
||||
const teacherDTOs: (TeacherDTO | null)[] = await Promise.all(
|
||||
c.teachers.map(async (uid) => {
|
||||
try {
|
||||
const res = await teacherController.getByUsername(uid);
|
||||
return res.teacher;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
teachers.value = teacherDTOs.filter(Boolean) as TeacherDTO[];
|
||||
}
|
||||
|
||||
// Hold the code a student gives in to join a class
|
||||
|
|
@ -151,100 +122,111 @@
|
|||
<template>
|
||||
<main>
|
||||
<div
|
||||
class="loading-div"
|
||||
v-if="isLoading"
|
||||
class="text-center py-10"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<p>Loading...</p>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="text-center py-10 text-error"
|
||||
>
|
||||
<v-icon large>mdi-alert-circle</v-icon>
|
||||
<p>Error loading: {{ error.message }}</p>
|
||||
<div v-if="isError">
|
||||
<v-empty-state
|
||||
icon="mdi-alert-circle-outline"
|
||||
:text="errorMessage"
|
||||
:title="t('error_title')"
|
||||
></v-empty-state>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h1 class="title">{{ t("classes") }}</h1>
|
||||
<v-container
|
||||
fluid
|
||||
class="ma-4"
|
||||
<using-query-result
|
||||
:query-result="classesQuery"
|
||||
v-slot="classResponse: { data: ClassesResponse }"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
<v-container
|
||||
fluid
|
||||
class="ma-4"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
<v-row
|
||||
no-gutters
|
||||
fluid
|
||||
>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("classes") }}</th>
|
||||
<th class="header">{{ t("teachers") }}</th>
|
||||
<th class="header">{{ t("members") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="c in classes"
|
||||
:key="c.id"
|
||||
>
|
||||
<td>{{ c.displayName }}</td>
|
||||
<td
|
||||
class="link"
|
||||
@click="openTeacherDialog(c)"
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("classes") }}</th>
|
||||
<th class="header">{{ t("teachers") }}</th>
|
||||
<th class="header">{{ t("members") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="c in classResponse.data.classes as ClassDTO[]"
|
||||
:key="c.id"
|
||||
>
|
||||
{{ c.teachers.length }}
|
||||
</td>
|
||||
<td
|
||||
class="link"
|
||||
@click="openStudentDialog(c)"
|
||||
>
|
||||
{{ c.students.length }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<td>{{ c.displayName }}</td>
|
||||
<td
|
||||
class="link"
|
||||
@click="openTeacherDialog(c)"
|
||||
>
|
||||
{{ c.teachers.length }}
|
||||
</td>
|
||||
<td
|
||||
class="link"
|
||||
@click="openStudentDialog(c)"
|
||||
>
|
||||
{{ c.students.length }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</using-query-result>
|
||||
|
||||
<v-dialog
|
||||
v-if="selectedClass"
|
||||
v-model="dialog"
|
||||
width="400"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title> {{ selectedClass?.displayName }} </v-card-title>
|
||||
<v-card-title> {{ selectedClass!.displayName }} </v-card-title>
|
||||
<v-card-text>
|
||||
<ul v-if="getStudents">
|
||||
<li
|
||||
v-for="student in students"
|
||||
:key="student.username"
|
||||
<using-query-result
|
||||
:query-result="getStudentsQuery"
|
||||
v-slot="studentsResponse: { data: StudentsResponse }"
|
||||
>
|
||||
{{ student.firstName + " " + student.lastName }}
|
||||
</li>
|
||||
<li
|
||||
v-for="student in studentsResponse.data.students as StudentDTO[]"
|
||||
:key="student.username"
|
||||
>
|
||||
{{ student.firstName + " " + student.lastName }}
|
||||
</li>
|
||||
</using-query-result>
|
||||
</ul>
|
||||
<ul v-else>
|
||||
<li
|
||||
v-for="teacher in teachers"
|
||||
:key="teacher.username"
|
||||
<using-query-result
|
||||
:query-result="getTeachersQuery"
|
||||
v-slot="teachersResponse: { data: TeachersResponse }"
|
||||
>
|
||||
{{ teacher.firstName + " " + teacher.lastName }}
|
||||
</li>
|
||||
<li
|
||||
v-for="teacher in teachersResponse.data.teachers as TeacherDTO[]"
|
||||
:key="teacher.username"
|
||||
>
|
||||
{{ teacher.firstName + " " + teacher.lastName }}
|
||||
</li>
|
||||
</using-query-result>
|
||||
</ul>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="dialog = false"
|
||||
>Close</v-btn
|
||||
>{{ t("close") }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import authState from "@/services/auth/auth-service.ts";
|
||||
import { computed, onMounted, ref, type ComputedRef } from "vue";
|
||||
import { onMounted, ref, watchEffect } from "vue";
|
||||
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
|
||||
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
|
||||
import type { TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation";
|
||||
import type { TeacherInvitationData, TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation";
|
||||
import { useTeacherClassesQuery } from "@/queries/teachers";
|
||||
import { ClassController, type ClassResponse } from "@/controllers/classes";
|
||||
import type { ClassesResponse } from "@/controllers/classes";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import { useClassesQuery, useCreateClassMutation } from "@/queries/classes";
|
||||
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations";
|
||||
import {
|
||||
useRespondTeacherInvitationMutation,
|
||||
useTeacherInvitationsReceivedQuery,
|
||||
} from "@/queries/teacher-invitations";
|
||||
import { useDisplay } from "vuetify";
|
||||
|
||||
const { t } = useI18n();
|
||||
const classController = new ClassController();
|
||||
|
||||
// Username of logged in teacher
|
||||
const username = ref<string | undefined>(undefined);
|
||||
const isLoading = ref(false);
|
||||
const isError = ref(false);
|
||||
const errorMessage = ref<string>("");
|
||||
|
||||
// Find the username of the logged in user so it can be used to fetch other information
|
||||
// When loading the page
|
||||
// Load current user before rendering the page
|
||||
onMounted(async () => {
|
||||
const userObject = await authState.loadUser();
|
||||
username.value = userObject?.profile?.preferred_username ?? undefined;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const userObject = await authState.loadUser();
|
||||
username.value = userObject!.profile.preferred_username;
|
||||
} catch (error) {
|
||||
isError.value = true;
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch all classes of the logged in teacher
|
||||
const { data: classesResponse, isLoading, error, refetch } = useTeacherClassesQuery(username, true);
|
||||
|
||||
// Empty list when classes are not yet loaded, else the list of classes of the user
|
||||
const classes: ComputedRef<ClassDTO[]> = computed(() => {
|
||||
// The classes are not yet fetched
|
||||
if (!classesResponse.value) {
|
||||
return [];
|
||||
}
|
||||
// The user has no classes
|
||||
if (classesResponse.value.classes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return classesResponse.value.classes as ClassDTO[];
|
||||
});
|
||||
const classesQuery = useTeacherClassesQuery(username, true);
|
||||
const allClassesQuery = useClassesQuery();
|
||||
const { mutate } = useCreateClassMutation();
|
||||
const getInvitationsQuery = useTeacherInvitationsReceivedQuery(username);
|
||||
const { mutate: respondToInvitation } = useRespondTeacherInvitationMutation();
|
||||
|
||||
// Boolean that handles visibility for dialogs
|
||||
// Creating a class will generate a popup with the generated code
|
||||
|
|
@ -44,19 +52,26 @@
|
|||
// Code generated when new class was created
|
||||
const code = ref<string>("");
|
||||
|
||||
// TODO: waiting on frontend controllers
|
||||
const invitations = ref<TeacherInvitationDTO[]>([]);
|
||||
// Function to handle an invitation request
|
||||
function handleInvitation(ti: TeacherInvitationDTO, accepted: boolean): void {
|
||||
const data: TeacherInvitationData = {
|
||||
sender: (ti.sender as TeacherDTO).id,
|
||||
receiver: (ti.receiver as TeacherDTO).id,
|
||||
class: ti.classId,
|
||||
accepted: accepted,
|
||||
};
|
||||
respondToInvitation(data, {
|
||||
onSuccess: async () => {
|
||||
if (accepted) {
|
||||
await classesQuery.refetch();
|
||||
}
|
||||
|
||||
// Function to handle a accepted invitation request
|
||||
function acceptRequest(): void {
|
||||
//TODO: avoid linting issues when merging by filling the function
|
||||
invitations.value = [];
|
||||
}
|
||||
|
||||
// Function to handle a denied invitation request
|
||||
function denyRequest(): void {
|
||||
//TODO: avoid linting issues when merging by filling the function
|
||||
invitations.value = [];
|
||||
await getInvitationsQuery.refetch();
|
||||
},
|
||||
onError: (e) => {
|
||||
showSnackbar(t("failed") + ": " + e.message, "error");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Teacher should be able to set a displayname when making a class
|
||||
|
|
@ -76,25 +91,25 @@
|
|||
async function createClass(): Promise<void> {
|
||||
// Check if the class name is valid
|
||||
if (className.value && className.value.length > 0 && /^[a-zA-Z0-9_-]+$/.test(className.value)) {
|
||||
try {
|
||||
const classDto: ClassDTO = {
|
||||
id: "",
|
||||
displayName: className.value,
|
||||
teachers: [username.value!],
|
||||
students: [],
|
||||
joinRequests: [],
|
||||
};
|
||||
const classResponse: ClassResponse = await classController.createClass(classDto);
|
||||
const createdClass: ClassDTO = classResponse.class;
|
||||
code.value = createdClass.id;
|
||||
dialog.value = true;
|
||||
showSnackbar(t("created"), "success");
|
||||
const classDto: ClassDTO = {
|
||||
id: "",
|
||||
displayName: className.value,
|
||||
teachers: [username.value!],
|
||||
students: [],
|
||||
};
|
||||
|
||||
// Reload the table with classes so the new class appears
|
||||
await refetch();
|
||||
} catch (_) {
|
||||
showSnackbar(t("wrong"), "error");
|
||||
}
|
||||
mutate(classDto, {
|
||||
onSuccess: async (classResponse) => {
|
||||
showSnackbar(t("classCreated"), "success");
|
||||
const createdClass: ClassDTO = classResponse.class;
|
||||
code.value = createdClass.id;
|
||||
await classesQuery.refetch();
|
||||
},
|
||||
onError: (err) => {
|
||||
showSnackbar(t("creationFailed") + ": " + err.message, "error");
|
||||
},
|
||||
});
|
||||
dialog.value = true;
|
||||
}
|
||||
if (!className.value || className.value === "") {
|
||||
showSnackbar(t("name is mandatory"), "error");
|
||||
|
|
@ -121,187 +136,272 @@
|
|||
await navigator.clipboard.writeText(code.value);
|
||||
copied.value = true;
|
||||
}
|
||||
|
||||
// Custom breakpoints
|
||||
const customBreakpoints = {
|
||||
xs: 0,
|
||||
sm: 500,
|
||||
md: 1370,
|
||||
lg: 1400,
|
||||
xl: 1600,
|
||||
};
|
||||
|
||||
// Logic for small screens
|
||||
const display = useDisplay();
|
||||
|
||||
// Reactive variables to hold custom logic based on breakpoints
|
||||
const isMdAndDown = ref(false);
|
||||
const isSmAndDown = ref(false);
|
||||
|
||||
watchEffect(() => {
|
||||
// Custom breakpoint logic
|
||||
isMdAndDown.value = display.width.value < customBreakpoints.md;
|
||||
isSmAndDown.value = display.width.value < customBreakpoints.sm;
|
||||
});
|
||||
|
||||
// Code display dialog logic
|
||||
const viewCodeDialog = ref(false);
|
||||
const selectedCode = ref("");
|
||||
function openCodeDialog(codeToView: string): void {
|
||||
selectedCode.value = codeToView;
|
||||
viewCodeDialog.value = true;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<div
|
||||
class="loading-div"
|
||||
v-if="isLoading"
|
||||
class="text-center py-10"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<p>Loading...</p>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="text-center py-10 text-error"
|
||||
>
|
||||
<v-icon large>mdi-alert-circle</v-icon>
|
||||
<p>Error loading: {{ error.message }}</p>
|
||||
<div v-if="isError">
|
||||
<v-empty-state
|
||||
icon="mdi-alert-circle-outline"
|
||||
:text="errorMessage"
|
||||
:title="t('error_title')"
|
||||
></v-empty-state>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h1 class="title">{{ t("classes") }}</h1>
|
||||
<v-container
|
||||
fluid
|
||||
class="ma-4"
|
||||
<using-query-result
|
||||
:query-result="classesQuery"
|
||||
v-slot="classesResponse: { data: ClassesResponse }"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
<v-container
|
||||
fluid
|
||||
class="ma-4"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
<v-row
|
||||
no-gutters
|
||||
class="custom-breakpoint"
|
||||
>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("classes") }}</th>
|
||||
<th class="header">
|
||||
{{ t("code") }}
|
||||
</th>
|
||||
<th class="header">{{ t("members") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="c in classes"
|
||||
:key="c.id"
|
||||
>
|
||||
<td>
|
||||
<v-btn
|
||||
:to="`/class/${c.id}`"
|
||||
variant="text"
|
||||
>
|
||||
{{ c.displayName }}
|
||||
<v-icon end> mdi-menu-right </v-icon>
|
||||
</v-btn>
|
||||
</td>
|
||||
<td>{{ c.id }}</td>
|
||||
<td>{{ c.students.length }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
>
|
||||
<div>
|
||||
<h2>{{ t("createClass") }}</h2>
|
||||
|
||||
<v-sheet
|
||||
class="pa-4 sheet"
|
||||
max-width="600px"
|
||||
>
|
||||
<p>{{ t("createClassInstructions") }}</p>
|
||||
<v-form @submit.prevent>
|
||||
<v-text-field
|
||||
class="mt-4"
|
||||
:label="`${t('classname')}`"
|
||||
v-model="className"
|
||||
:placeholder="`${t('EnterNameOfClass')}`"
|
||||
:rules="nameRules"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
class="mt-4"
|
||||
color="#f6faf2"
|
||||
type="submit"
|
||||
@click="createClass"
|
||||
block
|
||||
>{{ t("create") }}</v-btn
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
class="responsive-col"
|
||||
>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("classes") }}</th>
|
||||
<th class="header">
|
||||
{{ t("code") }}
|
||||
</th>
|
||||
<th class="header">{{ t("members") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="c in classesResponse.data.classes as ClassDTO[]"
|
||||
:key="c.id"
|
||||
>
|
||||
</v-form>
|
||||
</v-sheet>
|
||||
<v-container>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
max-width="400px"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">code</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="code"
|
||||
readonly
|
||||
append-inner-icon="mdi-content-copy"
|
||||
@click:append-inner="copyToClipboard"
|
||||
></v-text-field>
|
||||
<v-slide-y-transition>
|
||||
<div
|
||||
v-if="copied"
|
||||
class="text-center mt-2"
|
||||
>
|
||||
{{ t("copied") }}
|
||||
</div>
|
||||
</v-slide-y-transition>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<td>
|
||||
<v-btn
|
||||
text
|
||||
@click="
|
||||
dialog = false;
|
||||
copied = false;
|
||||
"
|
||||
:to="`/class/${c.id}`"
|
||||
variant="text"
|
||||
>
|
||||
{{ t("close") }}
|
||||
{{ c.displayName }}
|
||||
<v-icon end> mdi-menu-right </v-icon>
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="!isMdAndDown">{{ c.id }}</span>
|
||||
<span
|
||||
v-else
|
||||
style="cursor: pointer"
|
||||
@click="openCodeDialog(c.id)"
|
||||
><v-icon icon="mdi-eye"></v-icon
|
||||
></span>
|
||||
</td>
|
||||
|
||||
<td>{{ c.students.length }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="6"
|
||||
class="responsive-col"
|
||||
>
|
||||
<div>
|
||||
<h2>{{ t("createClass") }}</h2>
|
||||
|
||||
<v-sheet
|
||||
class="pa-4 sheet"
|
||||
max-width="600px"
|
||||
>
|
||||
<p>{{ t("createClassInstructions") }}</p>
|
||||
<v-form @submit.prevent>
|
||||
<v-text-field
|
||||
class="mt-4"
|
||||
:label="`${t('classname')}`"
|
||||
v-model="className"
|
||||
:placeholder="`${t('EnterNameOfClass')}`"
|
||||
:rules="nameRules"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
class="mt-4"
|
||||
color="#f6faf2"
|
||||
type="submit"
|
||||
@click="createClass"
|
||||
block
|
||||
>{{ t("create") }}</v-btn
|
||||
>
|
||||
</v-form>
|
||||
</v-sheet>
|
||||
<v-container>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
max-width="400px"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">code</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="code"
|
||||
readonly
|
||||
append-inner-icon="mdi-content-copy"
|
||||
@click:append-inner="copyToClipboard"
|
||||
></v-text-field>
|
||||
<v-slide-y-transition>
|
||||
<div
|
||||
v-if="copied"
|
||||
class="text-center mt-2"
|
||||
>
|
||||
{{ t("copied") }}
|
||||
</div>
|
||||
</v-slide-y-transition>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
text
|
||||
@click="
|
||||
dialog = false;
|
||||
copied = false;
|
||||
"
|
||||
>
|
||||
{{ t("close") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</using-query-result>
|
||||
|
||||
<h1 class="title">
|
||||
{{ t("invitations") }}
|
||||
</h1>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("class") }}</th>
|
||||
<th class="header">{{ t("sender") }}</th>
|
||||
<th class="header"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="i in invitations"
|
||||
:key="(i.class as ClassDTO).id"
|
||||
>
|
||||
<td>
|
||||
{{ (i.class as ClassDTO).displayName }}
|
||||
</td>
|
||||
<td>{{ (i.sender as TeacherDTO).firstName + " " + (i.sender as TeacherDTO).lastName }}</td>
|
||||
<td class="text-right">
|
||||
<div>
|
||||
<v-btn
|
||||
color="green"
|
||||
@click="acceptRequest"
|
||||
class="mr-2"
|
||||
<v-container
|
||||
fluid
|
||||
class="ma-4"
|
||||
>
|
||||
<v-table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header">{{ t("class") }}</th>
|
||||
<th class="header">{{ t("sender") }}</th>
|
||||
<th class="header">{{ t("accept") + "/" + t("reject") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<using-query-result
|
||||
:query-result="getInvitationsQuery"
|
||||
v-slot="invitationsResponse: { data: TeacherInvitationsResponse }"
|
||||
>
|
||||
<using-query-result
|
||||
:query-result="allClassesQuery"
|
||||
v-slot="classesResponse: { data: ClassesResponse }"
|
||||
>
|
||||
<tr
|
||||
v-for="i in invitationsResponse.data.invitations as TeacherInvitationDTO[]"
|
||||
:key="i.classId"
|
||||
>
|
||||
{{ t("accept") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="red"
|
||||
@click="denyRequest"
|
||||
>
|
||||
{{ t("deny") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<td>
|
||||
{{
|
||||
(classesResponse.data.classes as ClassDTO[]).filter(
|
||||
(c) => c.id == i.classId,
|
||||
)[0].displayName
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
(i.sender as TeacherDTO).firstName + " " + (i.sender as TeacherDTO).lastName
|
||||
}}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<span v-if="!isSmAndDown">
|
||||
<div>
|
||||
<v-btn
|
||||
color="green"
|
||||
@click="handleInvitation(i, true)"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ t("accept") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="red"
|
||||
@click="handleInvitation(i, false)"
|
||||
>
|
||||
{{ t("deny") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else>
|
||||
<div>
|
||||
<v-btn
|
||||
@click="handleInvitation(i, true)"
|
||||
class="mr-2"
|
||||
icon="mdi-check-circle"
|
||||
color="green"
|
||||
variant="text"
|
||||
>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="handleInvitation(i, false)"
|
||||
class="mr-2"
|
||||
icon="mdi-close-circle"
|
||||
color="red"
|
||||
variant="text"
|
||||
>
|
||||
</v-btn></div
|
||||
></span>
|
||||
</td>
|
||||
</tr>
|
||||
</using-query-result>
|
||||
</using-query-result>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-container>
|
||||
</div>
|
||||
<v-snackbar
|
||||
v-model="snackbar.visible"
|
||||
|
|
@ -310,6 +410,42 @@
|
|||
>
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
<v-dialog
|
||||
v-model="viewCodeDialog"
|
||||
max-width="400px"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">{{ t("code") }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="selectedCode"
|
||||
readonly
|
||||
append-inner-icon="mdi-content-copy"
|
||||
@click:append-inner="copyToClipboard"
|
||||
></v-text-field>
|
||||
<v-slide-y-transition>
|
||||
<div
|
||||
v-if="copied"
|
||||
class="text-center mt-2"
|
||||
>
|
||||
{{ t("copied") }}
|
||||
</div>
|
||||
</v-slide-y-transition>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
text
|
||||
@click="
|
||||
viewCodeDialog = false;
|
||||
copied = false;
|
||||
"
|
||||
>
|
||||
{{ t("close") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</main>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
|
@ -377,7 +513,7 @@
|
|||
margin-left: 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
@media screen and (max-width: 850px) {
|
||||
h1 {
|
||||
text-align: center;
|
||||
padding-left: 0;
|
||||
|
|
@ -400,5 +536,18 @@
|
|||
justify-content: center;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.custom-breakpoint {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.responsive-col {
|
||||
max-width: 100% !important;
|
||||
flex-basis: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Language } from "@/data-objects/language.ts";
|
||||
import type { UseQueryReturnType } from "@tanstack/vue-query";
|
||||
import { useLearningObjectHTMLQuery } from "@/queries/learning-objects.ts";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
|
||||
const props = defineProps<{ hruid: string; language: Language; version: number }>();
|
||||
|
||||
const learningObjectHtmlQueryResult: UseQueryReturnType<Document, Error> = useLearningObjectHTMLQuery(
|
||||
() => props.hruid,
|
||||
() => props.language,
|
||||
() => props.version,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<using-query-result
|
||||
:query-result="learningObjectHtmlQueryResult as UseQueryReturnType<Document, Error>"
|
||||
v-slot="learningPathHtml: { data: Document }"
|
||||
>
|
||||
<div
|
||||
class="learning-object-container"
|
||||
v-html="learningPathHtml.data.body.innerHTML"
|
||||
></div>
|
||||
</using-query-result>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.learning-object-container {
|
||||
padding: 20px;
|
||||
}
|
||||
:deep(hr) {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
:deep(li) {
|
||||
margin-left: 30px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
:deep(img) {
|
||||
max-width: 80%;
|
||||
}
|
||||
:deep(h2),
|
||||
:deep(h3),
|
||||
:deep(h4),
|
||||
:deep(h5),
|
||||
:deep(h6) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import { useGroupsQuery } from "@/queries/groups.ts";
|
||||
import type { GroupsResponse } from "@/controllers/groups.ts";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
classId: string;
|
||||
assignmentNumber: number;
|
||||
}>();
|
||||
|
||||
const model = defineModel<number | undefined>({ default: undefined });
|
||||
|
||||
const groupsQuery = useGroupsQuery(props.classId, props.assignmentNumber, true);
|
||||
|
||||
interface GroupSelectorOption {
|
||||
groupNumber: number | undefined;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function groupOptions(groups: GroupDTO[]): GroupSelectorOption[] {
|
||||
return [...groups]
|
||||
.sort((a, b) => a.groupNumber - b.groupNumber)
|
||||
.map((group, index) => ({
|
||||
groupNumber: group.groupNumber,
|
||||
label: `${index + 1}`,
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<using-query-result
|
||||
:query-result="groupsQuery"
|
||||
v-slot="{ data }: { data: GroupsResponse }"
|
||||
>
|
||||
<v-select
|
||||
:label="t('viewAsGroup')"
|
||||
:items="groupOptions(data.groups)"
|
||||
v-model="model"
|
||||
item-title="label"
|
||||
class="group-selector-cb"
|
||||
variant="outlined"
|
||||
clearable
|
||||
></v-select>
|
||||
</using-query-result>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group-selector-cb {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
|
||||
import { computed, type ComputedRef, ref } from "vue";
|
||||
import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts";
|
||||
import { useRoute } from "vue-router";
|
||||
import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LearningPathSearchField from "@/components/LearningPathSearchField.vue";
|
||||
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
|
||||
|
|
@ -12,30 +12,38 @@
|
|||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import authService from "@/services/auth/auth-service.ts";
|
||||
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts";
|
||||
import LearningPathGroupSelector from "@/views/learning-paths/LearningPathGroupSelector.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ hruid: string; language: Language; learningObjectHruid?: string }>();
|
||||
const props = defineProps<{
|
||||
hruid: string;
|
||||
language: Language;
|
||||
learningObjectHruid?: string;
|
||||
}>();
|
||||
|
||||
interface Personalization {
|
||||
forStudent?: string;
|
||||
interface LearningPathPageQuery {
|
||||
forGroup?: string;
|
||||
assignmentNo?: string;
|
||||
classId?: string;
|
||||
}
|
||||
|
||||
const personalization = computed(() => {
|
||||
if (route.query.forStudent || route.query.forGroup) {
|
||||
const query = computed(() => route.query as LearningPathPageQuery);
|
||||
|
||||
const forGroup = computed(() => {
|
||||
if (query.value.forGroup && query.value.assignmentNo && query.value.classId) {
|
||||
return {
|
||||
forStudent: route.query.forStudent,
|
||||
forGroup: route.query.forGroup,
|
||||
} as Personalization;
|
||||
forGroup: parseInt(query.value.forGroup),
|
||||
assignmentNo: parseInt(query.value.assignmentNo),
|
||||
classId: query.value.classId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
forStudent: authService.authState.user?.profile?.preferred_username,
|
||||
} as Personalization;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, personalization);
|
||||
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, forGroup);
|
||||
|
||||
const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data);
|
||||
|
||||
|
|
@ -98,6 +106,25 @@
|
|||
}
|
||||
return "notCompleted";
|
||||
}
|
||||
|
||||
const forGroupQueryParam = computed<number | undefined>({
|
||||
get: () => route.query.forGroup,
|
||||
set: async (value: number | undefined) => {
|
||||
const query = structuredClone(route.query);
|
||||
query.forGroup = value;
|
||||
await router.push({ query });
|
||||
},
|
||||
});
|
||||
|
||||
async function assign(): Promise<void> {
|
||||
await router.push({
|
||||
path: "/assignment/create",
|
||||
query: {
|
||||
hruid: props.hruid,
|
||||
language: props.language,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -109,64 +136,87 @@
|
|||
v-model="navigationDrawerShown"
|
||||
:width="350"
|
||||
>
|
||||
<v-list-item>
|
||||
<template v-slot:title>
|
||||
<div class="learning-path-title">{{ learningPath.data.title }}</div>
|
||||
</template>
|
||||
<template v-slot:subtitle>
|
||||
<div>{{ learningPath.data.description }}</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<template v-slot:subtitle>
|
||||
<p>
|
||||
<v-icon
|
||||
:color="COLORS.notCompleted"
|
||||
:icon="ICONS.notCompleted"
|
||||
></v-icon>
|
||||
{{ t("legendNotCompletedYet") }}
|
||||
</p>
|
||||
<p>
|
||||
<v-icon
|
||||
:color="COLORS.completed"
|
||||
:icon="ICONS.completed"
|
||||
></v-icon>
|
||||
{{ t("legendCompleted") }}
|
||||
</p>
|
||||
<p>
|
||||
<v-icon
|
||||
:color="COLORS.teacherExclusive"
|
||||
:icon="ICONS.teacherExclusive"
|
||||
></v-icon>
|
||||
{{ t("legendTeacherExclusive") }}
|
||||
</p>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
<div v-if="props.learningObjectHruid">
|
||||
<using-query-result
|
||||
:query-result="learningObjectListQueryResult"
|
||||
v-slot="learningObjects: { data: LearningObject[] }"
|
||||
>
|
||||
<template v-for="node in learningObjects.data">
|
||||
<v-list-item
|
||||
link
|
||||
:to="{ path: node.key, query: route.query }"
|
||||
:title="node.title"
|
||||
:active="node.key === props.learningObjectHruid"
|
||||
:key="node.key"
|
||||
v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon
|
||||
:color="COLORS[getNavItemState(node)]"
|
||||
:icon="ICONS[getNavItemState(node)]"
|
||||
></v-icon>
|
||||
</template>
|
||||
<template v-slot:append> {{ node.estimatedTime }}' </template>
|
||||
</v-list-item>
|
||||
<div class="d-flex flex-column h-100">
|
||||
<v-list-item>
|
||||
<template v-slot:title>
|
||||
<div class="learning-path-title">{{ learningPath.data.title }}</div>
|
||||
</template>
|
||||
</using-query-result>
|
||||
<template v-slot:subtitle>
|
||||
<div>{{ learningPath.data.description }}</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<template v-slot:subtitle>
|
||||
<p>
|
||||
<v-icon
|
||||
:color="COLORS.notCompleted"
|
||||
:icon="ICONS.notCompleted"
|
||||
></v-icon>
|
||||
{{ t("legendNotCompletedYet") }}
|
||||
</p>
|
||||
<p>
|
||||
<v-icon
|
||||
:color="COLORS.completed"
|
||||
:icon="ICONS.completed"
|
||||
></v-icon>
|
||||
{{ t("legendCompleted") }}
|
||||
</p>
|
||||
<p>
|
||||
<v-icon
|
||||
:color="COLORS.teacherExclusive"
|
||||
:icon="ICONS.teacherExclusive"
|
||||
></v-icon>
|
||||
{{ t("legendTeacherExclusive") }}
|
||||
</p>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="query.classId && query.assignmentNo && authService.authState.activeRole === 'teacher'"
|
||||
>
|
||||
<template v-slot:default>
|
||||
<learning-path-group-selector
|
||||
:class-id="query.classId"
|
||||
:assignment-number="parseInt(query.assignmentNo)"
|
||||
v-model="forGroupQueryParam"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
<div v-if="props.learningObjectHruid">
|
||||
<using-query-result
|
||||
:query-result="learningObjectListQueryResult"
|
||||
v-slot="learningObjects: { data: LearningObject[] }"
|
||||
>
|
||||
<template v-for="node in learningObjects.data">
|
||||
<v-list-item
|
||||
link
|
||||
:to="{ path: node.key, query: route.query }"
|
||||
:title="node.title"
|
||||
:active="node.key === props.learningObjectHruid"
|
||||
:key="node.key"
|
||||
v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon
|
||||
:color="COLORS[getNavItemState(node)]"
|
||||
:icon="ICONS[getNavItemState(node)]"
|
||||
></v-icon>
|
||||
</template>
|
||||
<template v-slot:append> {{ node.estimatedTime }}' </template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</using-query-result>
|
||||
</div>
|
||||
<v-spacer></v-spacer>
|
||||
<v-list-item v-if="authService.authState.activeRole === 'teacher'">
|
||||
<template v-slot:default>
|
||||
<v-btn
|
||||
class="button-in-nav"
|
||||
@click="assign()"
|
||||
>{{ t("assignLearningPath") }}</v-btn
|
||||
>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
<div class="control-bar-above-content">
|
||||
|
|
@ -180,12 +230,15 @@
|
|||
<learning-path-search-field></learning-path-search-field>
|
||||
</div>
|
||||
</div>
|
||||
<learning-object-view
|
||||
:hruid="currentNode.learningobjectHruid"
|
||||
:language="currentNode.language"
|
||||
:version="currentNode.version"
|
||||
v-if="currentNode"
|
||||
></learning-object-view>
|
||||
<div class="learning-object-view-container">
|
||||
<learning-object-view
|
||||
:hruid="currentNode.learningobjectHruid"
|
||||
:language="currentNode.language"
|
||||
:version="currentNode.version"
|
||||
:group="forGroup"
|
||||
v-if="currentNode"
|
||||
></learning-object-view>
|
||||
</div>
|
||||
<div class="navigation-buttons-container">
|
||||
<v-btn
|
||||
prepend-icon="mdi-chevron-left"
|
||||
|
|
@ -221,9 +274,18 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.learning-object-view-container {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.navigation-buttons-container {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.button-in-nav {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@
|
|||
import LearningPathsGrid from "@/components/LearningPathsGrid.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const query = computed(() => route.query.query as string | undefined);
|
||||
|
||||
const searchQueryResults = useSearchLearningPathQuery(query);
|
||||
const searchQueryResults = useSearchLearningPathQuery(query, locale);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
export const essayQuestionAdapter: GiftAdapter = {
|
||||
questionType: "Essay",
|
||||
|
||||
installListener(
|
||||
questionElement: Element,
|
||||
answerUpdateCallback: (newAnswer: string | number | object) => void,
|
||||
): void {
|
||||
const textArea = questionElement.querySelector("textarea")!;
|
||||
textArea.addEventListener("input", () => {
|
||||
answerUpdateCallback(textArea.value);
|
||||
});
|
||||
},
|
||||
|
||||
setAnswer(questionElement: Element, answer: string | number | object): void {
|
||||
const textArea = questionElement.querySelector("textarea")!;
|
||||
textArea.value = String(answer);
|
||||
},
|
||||
};
|
||||
8
frontend/src/views/learning-paths/gift-adapters/gift-adapter.d.ts
vendored
Normal file
8
frontend/src/views/learning-paths/gift-adapters/gift-adapter.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
interface GiftAdapter {
|
||||
questionType: string;
|
||||
installListener(
|
||||
questionElement: Element,
|
||||
answerUpdateCallback: (newAnswer: string | number | object) => void,
|
||||
): void;
|
||||
setAnswer(questionElement: Element, answer: string | number | object): void;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { multipleChoiceQuestionAdapter } from "@/views/learning-paths/gift-adapters/multiple-choice-question-adapter.ts";
|
||||
import { essayQuestionAdapter } from "@/views/learning-paths/gift-adapters/essay-question-adapter.ts";
|
||||
|
||||
export const giftAdapters = [multipleChoiceQuestionAdapter, essayQuestionAdapter];
|
||||
|
||||
export function getGiftAdapterForType(questionType: string): GiftAdapter | undefined {
|
||||
return giftAdapters.find((it) => it.questionType === questionType);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
export const multipleChoiceQuestionAdapter: GiftAdapter = {
|
||||
questionType: "MC",
|
||||
|
||||
installListener(
|
||||
questionElement: Element,
|
||||
answerUpdateCallback: (newAnswer: string | number | object) => void,
|
||||
): void {
|
||||
questionElement.querySelectorAll("input[type=radio]").forEach((element) => {
|
||||
const input = element as HTMLInputElement;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
answerUpdateCallback(parseInt(input.value));
|
||||
});
|
||||
// Optional: initialize value if already selected
|
||||
if (input.checked) {
|
||||
answerUpdateCallback(parseInt(input.value));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setAnswer(questionElement: Element, answer: string | number | object): void {
|
||||
questionElement.querySelectorAll("input[type=radio]").forEach((element) => {
|
||||
const input = element as HTMLInputElement;
|
||||
input.checked = String(answer) === String(input.value);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<script setup lang="ts">
|
||||
import { Language } from "@/data-objects/language.ts";
|
||||
import type { UseQueryReturnType } from "@tanstack/vue-query";
|
||||
import { useLearningObjectHTMLQuery } from "@/queries/learning-objects.ts";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import authService from "@/services/auth/auth-service.ts";
|
||||
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
|
||||
import LearningObjectContentView from "@/views/learning-paths/learning-object/content/LearningObjectContentView.vue";
|
||||
import LearningObjectSubmissionsView from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsView.vue";
|
||||
|
||||
const _isStudent = computed(() => authService.authState.activeRole === "student");
|
||||
|
||||
const props = defineProps<{
|
||||
hruid: string;
|
||||
language: Language;
|
||||
version: number;
|
||||
group?: { forGroup: number; assignmentNo: number; classId: string };
|
||||
}>();
|
||||
|
||||
const learningObjectHtmlQueryResult: UseQueryReturnType<Document, Error> = useLearningObjectHTMLQuery(
|
||||
() => props.hruid,
|
||||
() => props.language,
|
||||
() => props.version,
|
||||
);
|
||||
const currentSubmission = ref<SubmissionData>([]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<using-query-result
|
||||
:query-result="learningObjectHtmlQueryResult as UseQueryReturnType<Document, Error>"
|
||||
v-slot="learningPathHtml: { data: Document }"
|
||||
>
|
||||
<learning-object-content-view
|
||||
:learning-object-content="learningPathHtml.data"
|
||||
v-model:submission-data="currentSubmission"
|
||||
/>
|
||||
<div class="content-submissions-spacer" />
|
||||
<learning-object-submissions-view
|
||||
v-if="props.group"
|
||||
:group="props.group"
|
||||
:hruid="props.hruid"
|
||||
:language="props.language"
|
||||
:version="props.version"
|
||||
v-model:submission-data="currentSubmission"
|
||||
/>
|
||||
</using-query-result>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(hr) {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
:deep(li) {
|
||||
margin-left: 30px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
:deep(img) {
|
||||
max-width: 80%;
|
||||
}
|
||||
:deep(h2),
|
||||
:deep(h3),
|
||||
:deep(h4),
|
||||
:deep(h5),
|
||||
:deep(h6) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.content-submissions-spacer {
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<script setup lang="ts">
|
||||
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
|
||||
import { getGiftAdapterForType } from "@/views/learning-paths/gift-adapters/gift-adapters.ts";
|
||||
import { computed, nextTick, onMounted, watch } from "vue";
|
||||
import { copyArrayWith } from "@/utils/array-utils.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
learningObjectContent: Document;
|
||||
submissionData?: SubmissionData;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<(e: "update:submissionData", value: SubmissionData) => void>();
|
||||
|
||||
const submissionData = computed<SubmissionData | undefined>({
|
||||
get: () => props.submissionData,
|
||||
set: (v?: SubmissionData): void => {
|
||||
if (v) emit("update:submissionData", v);
|
||||
},
|
||||
});
|
||||
|
||||
function forEachQuestion(
|
||||
doAction: (questionIndex: number, questionName: string, questionType: string, questionElement: Element) => void,
|
||||
): void {
|
||||
const questions = document.querySelectorAll(".gift-question");
|
||||
questions.forEach((question) => {
|
||||
const name = question.id.match(/gift-q(\d+)/)?.[1];
|
||||
const questionType = question.className
|
||||
.split(" ")
|
||||
.find((it) => it.startsWith("gift-question-type"))
|
||||
?.match(/gift-question-type-([^ ]*)/)?.[1];
|
||||
|
||||
if (!name || isNaN(parseInt(name)) || !questionType) return;
|
||||
|
||||
const index = parseInt(name) - 1;
|
||||
|
||||
doAction(index, name, questionType, question);
|
||||
});
|
||||
}
|
||||
|
||||
function attachQuestionListeners(): void {
|
||||
forEachQuestion((index, _name, type, element) => {
|
||||
getGiftAdapterForType(type)?.installListener(element, (newAnswer) => {
|
||||
submissionData.value = copyArrayWith(index, newAnswer, submissionData.value ?? []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setAnswers(answers: SubmissionData): void {
|
||||
forEachQuestion((index, _name, type, element) => {
|
||||
const answer = answers[index];
|
||||
if (answer !== null && answer !== undefined) {
|
||||
getGiftAdapterForType(type)?.setAnswer(element, answer);
|
||||
} else if (answer === undefined) {
|
||||
answers[index] = null;
|
||||
}
|
||||
});
|
||||
submissionData.value = answers;
|
||||
}
|
||||
|
||||
onMounted(async () =>
|
||||
nextTick(() => {
|
||||
attachQuestionListeners();
|
||||
setAnswers(props.submissionData ?? []);
|
||||
}),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.learningObjectContent,
|
||||
async () => {
|
||||
await nextTick();
|
||||
attachQuestionListeners();
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.submissionData,
|
||||
async () => {
|
||||
await nextTick();
|
||||
setAnswers(props.submissionData ?? []);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="learning-object-container"
|
||||
v-html="learningObjectContent.body.innerHTML"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
1
frontend/src/views/learning-paths/learning-object/submission-data.d.ts
vendored
Normal file
1
frontend/src/views/learning-paths/learning-object/submission-data.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type SubmissionData = (string | number | object | null)[];
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<script setup lang="ts">
|
||||
import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
allSubmissions: SubmissionDTO[];
|
||||
}>();
|
||||
const emit = defineEmits<(e: "submission-selected", submission: SubmissionDTO) => void>();
|
||||
|
||||
const headers = computed(() => [
|
||||
{ title: "#", value: "submissionNo", width: "50px" },
|
||||
{ title: t("submittedBy"), value: "submittedBy" },
|
||||
{ title: t("timestamp"), value: "timestamp" },
|
||||
{ title: "", key: "action", width: "70px", sortable: false },
|
||||
]);
|
||||
|
||||
const data = computed(() =>
|
||||
[...props.allSubmissions]
|
||||
.sort((a, b) => (a.submissionNumber ?? 0) - (b.submissionNumber ?? 0))
|
||||
.map((submission, index) => ({
|
||||
submissionNo: index + 1,
|
||||
submittedBy: `${submission.submitter.firstName} ${submission.submitter.lastName}`,
|
||||
timestamp: submission.time ? new Date(submission.time).toLocaleString() : "-",
|
||||
dto: submission,
|
||||
})),
|
||||
);
|
||||
|
||||
function selectSubmission(submission: SubmissionDTO): void {
|
||||
emit("submission-selected", submission);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>{{ t("groupSubmissions") }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="data"
|
||||
density="compact"
|
||||
hide-default-footer
|
||||
:no-data-text="t('noSubmissionsYet')"
|
||||
>
|
||||
<template v-slot:[`item.action`]="{ item }">
|
||||
<v-btn
|
||||
density="compact"
|
||||
variant="plain"
|
||||
@click="selectSubmission(item.dto)"
|
||||
>
|
||||
{{ t("loadSubmission") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<script setup lang="ts">
|
||||
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
|
||||
import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission";
|
||||
import { Language } from "@/data-objects/language.ts";
|
||||
import { useSubmissionsQuery } from "@/queries/submissions.ts";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import SubmitButton from "@/views/learning-paths/learning-object/submissions/SubmitButton.vue";
|
||||
import { computed, watch } from "vue";
|
||||
import LearningObjectSubmissionsTable from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsTable.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
submissionData?: SubmissionData;
|
||||
hruid: string;
|
||||
language: Language;
|
||||
version: number;
|
||||
group: { forGroup: number; assignmentNo: number; classId: string };
|
||||
}>();
|
||||
const emit = defineEmits<(e: "update:submissionData", value: SubmissionData) => void>();
|
||||
|
||||
const submissionQuery = useSubmissionsQuery(
|
||||
() => props.hruid,
|
||||
() => props.language,
|
||||
() => props.version,
|
||||
() => props.group.classId,
|
||||
() => props.group.assignmentNo,
|
||||
() => props.group.forGroup,
|
||||
() => true,
|
||||
);
|
||||
|
||||
function emitSubmissionData(submissionData: SubmissionData): void {
|
||||
emit("update:submissionData", submissionData);
|
||||
}
|
||||
|
||||
function emitSubmission(submission: SubmissionDTO): void {
|
||||
emitSubmissionData(JSON.parse(submission.content));
|
||||
}
|
||||
|
||||
watch(submissionQuery.data, () => {
|
||||
const submissions = submissionQuery.data.value;
|
||||
if (submissions && submissions.length > 0) {
|
||||
emitSubmission(submissions[submissions.length - 1]);
|
||||
} else {
|
||||
emitSubmissionData([]);
|
||||
}
|
||||
});
|
||||
|
||||
const lastSubmission = computed<SubmissionData>(() => {
|
||||
const submissions = submissionQuery.data.value;
|
||||
if (!submissions || submissions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(submissions[submissions.length - 1].content);
|
||||
});
|
||||
|
||||
const showSubmissionTable = computed(() => props.submissionData !== undefined && props.submissionData.length > 0);
|
||||
|
||||
const showIsDoneMessage = computed(() => lastSubmission.value !== undefined && lastSubmission.value.length === 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<using-query-result
|
||||
:query-result="submissionQuery"
|
||||
v-slot="submissions: { data: SubmissionDTO[] }"
|
||||
>
|
||||
<submit-button
|
||||
:hruid="props.hruid"
|
||||
:language="props.language"
|
||||
:version="props.version"
|
||||
:group="props.group"
|
||||
:submission-data="props.submissionData"
|
||||
:submissions="submissions.data"
|
||||
/>
|
||||
<div class="submit-submissions-spacer"></div>
|
||||
<v-alert
|
||||
icon="mdi-check"
|
||||
:text="t('taskCompleted')"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
v-if="showIsDoneMessage"
|
||||
></v-alert>
|
||||
<learning-object-submissions-table
|
||||
v-if="submissionQuery.data && showSubmissionTable"
|
||||
:all-submissions="submissions.data"
|
||||
@submission-selected="emitSubmission"
|
||||
/>
|
||||
</using-query-result>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.submit-submissions-spacer {
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import authService from "@/services/auth/auth-service.ts";
|
||||
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
|
||||
import { Language } from "@/data-objects/language.ts";
|
||||
import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission";
|
||||
import { useCreateSubmissionMutation } from "@/queries/submissions.ts";
|
||||
import { deepEquals } from "@/utils/deep-equals.ts";
|
||||
import type { UserProfile } from "oidc-client-ts";
|
||||
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
|
||||
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
|
||||
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
submissionData?: SubmissionData;
|
||||
submissions: SubmissionDTO[];
|
||||
hruid: string;
|
||||
language: Language;
|
||||
version: number;
|
||||
group: { forGroup: number; assignmentNo: number; classId: string };
|
||||
}>();
|
||||
|
||||
const {
|
||||
isPending: submissionIsPending,
|
||||
// - isError: submissionFailed,
|
||||
// - error: submissionError,
|
||||
// - isSuccess: submissionSuccess,
|
||||
mutate: submitSolution,
|
||||
} = useCreateSubmissionMutation();
|
||||
|
||||
const isStudent = computed(() => authService.authState.activeRole === "student");
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
if (!props.submissionData || props.submissions === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (props.submissionData.some((answer) => answer === null)) {
|
||||
return false;
|
||||
}
|
||||
if (props.submissions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return deepEquals(JSON.parse(props.submissions[props.submissions.length - 1].content), props.submissionData);
|
||||
});
|
||||
|
||||
function submitCurrentAnswer(): void {
|
||||
const { forGroup, assignmentNo, classId } = props.group;
|
||||
const currentUser: UserProfile = authService.authState.user!.profile;
|
||||
const learningObjectIdentifier: LearningObjectIdentifierDTO = {
|
||||
hruid: props.hruid,
|
||||
language: props.language,
|
||||
version: props.version,
|
||||
};
|
||||
const submitter: StudentDTO = {
|
||||
id: currentUser.preferred_username!,
|
||||
username: currentUser.preferred_username!,
|
||||
firstName: currentUser.given_name!,
|
||||
lastName: currentUser.family_name!,
|
||||
};
|
||||
const group: GroupDTO = {
|
||||
class: classId,
|
||||
assignment: assignmentNo,
|
||||
groupNumber: forGroup,
|
||||
};
|
||||
const submission: SubmissionDTO = {
|
||||
learningObjectIdentifier,
|
||||
submitter,
|
||||
group,
|
||||
content: JSON.stringify(props.submissionData),
|
||||
};
|
||||
submitSolution({ data: submission });
|
||||
}
|
||||
|
||||
const buttonText = computed(() => {
|
||||
if (props.submissionData && props.submissionData.length === 0) {
|
||||
return t("markAsDone");
|
||||
}
|
||||
return t(props.submissions.length > 0 ? "submitNewSolution" : "submitSolution");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
v-if="isStudent && !isSubmitDisabled"
|
||||
prepend-icon="mdi-check"
|
||||
variant="elevated"
|
||||
:loading="submissionIsPending"
|
||||
:disabled="isSubmitDisabled"
|
||||
@click="submitCurrentAnswer()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
19
frontend/tests/controllers/student-controller.test.ts
Normal file
19
frontend/tests/controllers/student-controller.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { StudentController } from "../../src/controllers/students";
|
||||
import { expect, it, describe, afterAll, beforeAll } from "vitest";
|
||||
import { setup, teardown } from "../setup-backend.js";
|
||||
|
||||
describe("Test controller students", () => {
|
||||
beforeAll(async () => {
|
||||
await setup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardown();
|
||||
});
|
||||
|
||||
it("Get students", async () => {
|
||||
const controller = new StudentController();
|
||||
const data = await controller.getAll(true);
|
||||
expect(data.students).to.have.length.greaterThan(0);
|
||||
});
|
||||
});
|
||||
40
frontend/tests/setup-backend.ts
Normal file
40
frontend/tests/setup-backend.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { spawn } from "child_process";
|
||||
import { ChildProcess, spawnSync } from "node:child_process";
|
||||
|
||||
let backendProcess: ChildProcess;
|
||||
|
||||
async function waitForEndpoint(url: string, delay = 1000, retries = 60): Promise<void> {
|
||||
try {
|
||||
await fetch(url);
|
||||
} catch {
|
||||
// Endpoint is not ready yet
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
// Retry
|
||||
await waitForEndpoint(url, delay, retries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
// Precompile needed packages
|
||||
spawnSync("npx", ["tsc", "--build", "tsconfig.json"], {
|
||||
cwd: `../common`,
|
||||
});
|
||||
|
||||
// Spin up the backend
|
||||
backendProcess = spawn("npx", ["tsx", "--env-file=.env.test", "tool/startTestApp.ts"], {
|
||||
cwd: `../backend`,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// Wait until you can curl the backend
|
||||
await waitForEndpoint("http://localhost:9876/api");
|
||||
}
|
||||
|
||||
export async function teardown(): Promise<void> {
|
||||
if (backendProcess) {
|
||||
backendProcess.kill();
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,13 @@ export default mergeConfig(
|
|||
statements: 60,
|
||||
},
|
||||
},
|
||||
|
||||
/*
|
||||
* The test-backend server can be started for each test-file individually using `beforeAll(() => setup())`,
|
||||
* or for all tests once using:
|
||||
globalSetup: ["./tests/setup-backend.ts"],
|
||||
* In this project, the backend server is started for each test-file individually.
|
||||
*/
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Reference in a new issue