diff --git a/package.json b/package.json
index 068025c..adfa39e 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"javascript-lp-solver": "^0.4.24",
"jsonpath": "^1.1.1",
"localforage": "^1.10.0",
+ "lodash": "^4.17.21",
"match-sorter": "^6.3.1",
"mathjs": "^12.2.1",
"mdui": "^2.1.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2cd3f3a..7bc156d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
localforage:
specifier: ^1.10.0
version: 1.10.0
+ lodash:
+ specifier: ^4.17.21
+ version: 4.17.21
match-sorter:
specifier: ^6.3.1
version: 6.4.0
diff --git a/src/App.jsx b/src/App.jsx
index 1380de9..561dc75 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -124,7 +124,7 @@ function App() {
export default App;
-export async function loader() {
+App.loader = async function loader() {
const votes = await getDocs(collection(db, "/votes"));
const activeVotes = [];
const expiredVotes = [];
@@ -162,4 +162,4 @@ export async function loader() {
});
return { activeVotes, expiredVotes, scheduledVotes };
-}
+};
diff --git a/src/Gateway.jsx b/src/Gateway.jsx
index f35525b..c9337ba 100644
--- a/src/Gateway.jsx
+++ b/src/Gateway.jsx
@@ -36,7 +36,7 @@ export default function Gateway() {
return null;
}
-export async function loader({ params }) {
+Gateway.loader = async function loader({ params }) {
const vote = await getDoc(doc(db, `/votes/${params.id}`));
if (!vote.exists()) {
new Response("Not Found", {
@@ -47,4 +47,4 @@ export async function loader({ params }) {
const voteData = { id: vote.id, ...vote.data() };
return { voteData };
-}
+};
diff --git a/src/Vote.jsx b/src/Vote.jsx
index e847f06..c29b2e4 100644
--- a/src/Vote.jsx
+++ b/src/Vote.jsx
@@ -411,7 +411,7 @@ export default function Vote() {
);
}
-export async function loader({ params }) {
+Vote.loader = async function loader({ params }) {
const vote = await getDoc(doc(db, `/votes/${params.id}`));
if (!vote.exists()) {
throw new Response("Document not found.", {
@@ -424,4 +424,4 @@ export async function loader({ params }) {
const optionsData = options.docs.map((e) => ({ id: e.id, ...e.data() }));
return { vote: voteData, options: optionsData };
-}
+};
diff --git a/src/admin/NewVote.jsx b/src/admin/NewVote.jsx
index 03f30fd..f049d49 100644
--- a/src/admin/NewVote.jsx
+++ b/src/admin/NewVote.jsx
@@ -54,34 +54,43 @@ export default function NewVote() {
const berlinStartTime = moment.tz(startTime, "Europe/Berlin").toDate();
const berlinEndTime = moment.tz(endTime, "Europe/Berlin").toDate();
- const vote = await setDoc(doc(db, "/votes", id), {
- title: title,
- description: description,
- selectCount: selectCount,
- startTime: Timestamp.fromDate(berlinStartTime),
- endTime: Timestamp.fromDate(berlinEndTime),
- active: true,
- version: 3,
- });
- const option = options.map(async (e, index) => {
- return addDoc(collection(db, `/votes/${id}/options`), {
- title: e.title,
- max: e.max,
- teacher: e.teacher,
- description: e.description,
+ try {
+ await setDoc(doc(db, "/votes", id), {
+ title: title,
+ description: description,
+ selectCount: selectCount,
+ startTime: Timestamp.fromDate(berlinStartTime),
+ endTime: Timestamp.fromDate(berlinEndTime),
+ active: true,
+ version: 3,
+ extraFields: extraFields,
+ });
+ const option = options.map(async (e) => {
+ return addDoc(collection(db, `/votes/${id}/options`), {
+ title: e.title,
+ max: e.max,
+ teacher: e.teacher,
+ description: e.description,
+ });
});
- });
- await Promise.all(option);
+ await Promise.all(option);
- console.log("Vote created successfully.");
+ console.log("Vote created successfully.");
- snackbar({
- message: "Wahl erfolgreich erstellt.",
- timeout: 5000,
- });
+ snackbar({
+ message: "Wahl erfolgreich erstellt.",
+ timeout: 5000,
+ });
- navigate(`/admin/${id}`);
+ navigate(`/admin/${id}`);
+ } catch (e) {
+ console.error(e);
+ snackbar({
+ message: "Fehler beim Erstellen der Wahl.",
+ timeout: 5000,
+ });
+ }
}
const submitDisabled = () => {
@@ -268,6 +277,7 @@ export default function NewVote() {
)}
{options.map((e, i) => (
@@ -84,24 +92,15 @@ export default function Overview() {
- {/* navigate("/admin/new")}
- style={{ position: "fixed", bottom: "20px", right: "20px" }}
- extended
- variant="surface"
- >
- Neue Wahl
- */}
);
}
-export async function loader() {
+Overview.loader = async function loader() {
const votes = await getDocs(collection(db, "votes"));
return {
votes: votes.docs.map((e) => {
return { id: e.id, ...e.data() };
}),
};
-}
+};
diff --git a/src/admin/Students.tsx b/src/admin/Students.tsx
index 9a4ef86..35a6d36 100644
--- a/src/admin/Students.tsx
+++ b/src/admin/Students.tsx
@@ -413,7 +413,7 @@ export default function Students() {
);
}
-export async function loader() {
+Students.loader = async function loader() {
const classes = await getDocs(collection(db, "class"));
return {
classes: classes.docs.map((e) => {
@@ -423,4 +423,4 @@ export async function loader() {
};
}),
};
-}
+};
diff --git a/src/admin/utils.js b/src/admin/utils.js
index 55ec2f3..023b160 100644
--- a/src/admin/utils.js
+++ b/src/admin/utils.js
@@ -1,9 +1,9 @@
-export function generateRandomHash() {
+export function generateRandomHash(length = 4) {
let hash = "";
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- for (let i = 0; i < 4; i++) {
+ for (let i = 0; i < length; i++) {
hash += characters.charAt(Math.floor(Math.random() * characters.length));
}
diff --git a/src/admin/vote/Answers.jsx b/src/admin/vote/Answers.jsx
index 025b2f9..e077d75 100644
--- a/src/admin/vote/Answers.jsx
+++ b/src/admin/vote/Answers.jsx
@@ -378,7 +378,7 @@ export default function Answers() {
);
}
-export async function loader({ params }) {
+Answers.loader = async function loader({ params }) {
const { id } = params;
const vote = await getDoc(doc(db, `/votes/${id}`));
const voteData = vote.data();
diff --git a/src/admin/vote/Assign.jsx b/src/admin/vote/Assign.jsx
index b3ee7b7..7c19439 100644
--- a/src/admin/vote/Assign.jsx
+++ b/src/admin/vote/Assign.jsx
@@ -800,7 +800,7 @@ export default function Assign() {
);
}
-export async function loader({ params }) {
+Assign.loader = async function loader({ params }) {
const { id } = params;
const vote = await getDoc(doc(db, `/votes/${id}`));
diff --git a/src/admin/vote/Edit.jsx b/src/admin/vote/Edit.jsx
index ad89f62..63342a4 100644
--- a/src/admin/vote/Edit.jsx
+++ b/src/admin/vote/Edit.jsx
@@ -1,13 +1,215 @@
+import {
+ collection,
+ deleteDoc,
+ doc,
+ getDoc,
+ getDocs,
+ setDoc,
+} from "firebase/firestore";
+import { useLoaderData } from "react-router-dom";
+import { db } from "../../firebase";
+
+import _ from "lodash";
+
+import { confirm, snackbar } from "mdui";
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import { generateRandomHash } from "../utils";
+
export default function Edit() {
+ const { vote, options: loadedOptions } = useLoaderData();
+
+ const [title, setTitle] = React.useState(vote.title);
+ const [description, setDescription] = React.useState(vote.description);
+ const [selectCount] = React.useState(vote.selectCount);
+
+ const [extraFields, setExtraFields] = React.useState(vote.extraFields || []);
+
+ const [options, setOptions] = React.useState(loadedOptions);
+
+ const [name, setName] = React.useState("");
+ const [teacher, setTeacher] = React.useState("");
+ const [optionDescription, setOptionDescription] = React.useState("");
+ const [max, setMax] = React.useState();
+ const [optionId, setOptionId] = React.useState(generateRandomHash(20));
+
+ const navigate = useNavigate();
+
+ function addOption() {
+ setOptions((options) => [
+ ...options,
+ {
+ title: name,
+ max: max,
+ teacher: teacher,
+ description: optionDescription,
+ id: optionId,
+ },
+ ]);
+ setName("");
+ setTeacher("");
+ setOptionDescription("");
+ setMax("");
+ setOptionId(generateRandomHash(20));
+ }
+
+ function editOption(index) {
+ setName(options[index].title);
+ setTeacher(options[index].teacher);
+ setOptionDescription(options[index].description);
+ setMax(options[index].max);
+ setOptionId(options[index].id);
+ setOptions((options) => options.filter((_, i) => i !== index));
+ }
+
+ async function update() {
+ try {
+ console.log("Publishing vote with id: " + vote.id);
+
+ await setDoc(
+ doc(db, "/votes", vote.id),
+ {
+ title,
+ description: description || "",
+ extraFields: extraFields.length > 0 ? extraFields : [],
+ },
+ { merge: true }
+ );
+ const removedOptions = loadedOptions.filter(
+ (loaded) => !options.some((current) => current.id === loaded.id)
+ );
+ removedOptions.map((opt) =>
+ confirm({
+ headline: "Option löschen",
+ description: `Sind Sie sicher, dass Sie die Option "${opt.title}" löschen möchten? Wenn SchülerInnen diese Option bereits gewählt haben, kann das Dashboard abstürzen.`,
+ cancelText: "Abbrechen",
+ confirmText: "Trotzdem löschen",
+ onConfirm: async () => {
+ await deleteDoc(doc(db, `/votes/${vote.id}/options/${opt.id}`));
+ console.log("Option deleted successfully.");
+ },
+ })
+ );
+ const optionsPromises = options.map(async (e) => {
+ return setDoc(doc(db, `/votes/${vote.id}/options/${e.id}`), {
+ title: e.title,
+ max: e.max,
+ teacher: e.teacher,
+ description: e.description,
+ });
+ });
+
+ await Promise.all([...optionsPromises]);
+
+ console.log("Vote created successfully.");
+
+ snackbar({
+ message: "Wahl erfolgreich aktualisiert.",
+ timeout: 5000,
+ });
+
+ navigate(`/admin/${vote.id}`);
+ } catch (error) {
+ console.error("Failed to update vote:", error);
+ snackbar({
+ message: "Fehler beim Aktualisieren der Wahl.",
+ timeout: 5000,
+ });
+ }
+ }
+
+ const isVoteUnchanged = () => {
+ const newVote = {
+ title,
+ description,
+ selectCount,
+ version: 3,
+ extraFields: extraFields.length > 0 ? extraFields : [],
+ };
+
+ // check if vote has changed
+ const changes = _.reduce(
+ newVote,
+ function (result, value, key) {
+ if (!_.isEqual(value, vote[key])) {
+ result[key] = [vote[key], value];
+ }
+ return result;
+ },
+ {}
+ );
+
+ if (!_.isEmpty(changes)) {
+ // log the changes
+ console.log("Vote has changed", changes);
+
+ return false;
+ }
+
+ // check if options have changed
+ if (options.length !== loadedOptions.length) {
+ return false;
+ }
+
+ for (let i = 0; i < options.length; i++) {
+ const changes = _.reduce(
+ options[i],
+ function (result, value, key) {
+ const loadedOption = loadedOptions.find(
+ (opt) => opt.id === options[i].id
+ );
+ if (!_.isEqual(value, loadedOption[key])) {
+ result[key] = [loadedOption[key], value];
+ }
+ return result;
+ },
+ {}
+ );
+
+ if (!_.isEmpty(changes)) {
+ // log the changes
+ console.log("Option has changed", changes);
+
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ const submitDisabled = () => {
+ if (!title || !selectCount || options.length === 0 || isVoteUnchanged()) {
+ return true;
+ }
+
+ return false;
+ };
+
+ function addOptionDisabled() {
+ return !name || !max;
+ }
+
+ function editExtraField(index, value) {
+ const newValues = [...extraFields];
+ newValues[index] = value;
+ setExtraFields(newValues);
+ }
+
+ function removeExtraField(index) {
+ setExtraFields((extraFields) => extraFields.filter((_, i) => i !== index));
+ }
+
return (
Bearbeiten
{
+ navigate("../schedule");
+ }}
>
-
Wahl bearbeiten
-
+ Zeitplan ändern
+
-
Noch nicht verfügbar
- Bearbeiten Sie die Optionen, Metadaten und Einstellungen der Wahl.
- Diese Funktion kommt in einem späteren Update.
+
+
+
+
+
+
+ {submitDisabled() ? (
+
+ Aktualisieren
+
+ ) : (
+
+ Aktualisieren
+
+ )}
+
+
+
+ setTitle(e.target.value)}
+ />
+ setDescription(e.target.value)}
+ >
+
+ {extraFields.map((e, i) => (
+
+
+ editExtraField(i, e.target.value)}
+ >
+ removeExtraField(i)}
+ />
+
+
+
+
+ ))}
+
+
+ setExtraFields([...extraFields, ""])}
+ variant="text"
+ >
+ Extrafeld hinzufügen
+
+
+
+ Erweitert
+
+
+
+
+
+
+
+
+ Bitte beachten Sie: das Löschen von Optionen kann zum Absturz des
+ Dashboards führen, wenn SchülerInnen diese Option bereits gewählt
+ haben. Überprüfen Sie vor dem Aktualisieren der Daten, ob alle
+ Optionen noch vorhanden sind.
+
+
+
+
+
+ {options.length === 0 && (
+
+ Keine Optionen
+
+ Fügen Sie rechts eine neue Option hinzu.
+
+
+ )}
+ {options
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .map((e, i) => (
+
{
+ editOption(i);
+ }}
+ >
+ {e.title}
+ {e.teacher}
+ {e.description}
+ max. {e.max} SchülerInnen
+
+ ))}
+
+
+
setName(e.target.value)}
+ >
+
setMax(Number(e.target.value))}
+ >
+
+
+
setTeacher(e.target.value)}
+ >
+
setOptionDescription(e.target.value)}
+ >
+ {addOptionDisabled() ? (
+
+ Hinzufügen
+
+ ) : (
+
+ Hinzufügen
+
+ )}
+
+
+
);
}
+
+Edit.loader = async function loader({ params }) {
+ try {
+ const { id } = params;
+ const vote = await getDoc(doc(db, `/votes/${id}`));
+
+ if (!vote.exists()) {
+ throw new Error(`Vote with id ${id} not found`);
+ }
+
+ const voteData = { id, ...vote.data() };
+ console.log("Loaded vote:", voteData);
+ const options = await getDocs(collection(db, `/votes/${id}/options`));
+ const optionData = options.docs.map((doc) => ({
+ id: doc.id,
+ ...doc.data(),
+ }));
+
+ return {
+ vote: voteData,
+ options: optionData,
+ };
+ } catch (error) {
+ console.error("Failed to load vote:", error);
+ throw error;
+ }
+};
diff --git a/src/admin/vote/Match.tsx b/src/admin/vote/Match.tsx
index ebcdafa..f6ddf6f 100644
--- a/src/admin/vote/Match.tsx
+++ b/src/admin/vote/Match.tsx
@@ -150,7 +150,7 @@ export default function Match() {
);
}
-export async function loader({ params }) {
+Match.loader = async function loader({ params }) {
const { id } = params;
const choices = await getDocs(collection(db, `/votes/${id}/choices`));
const choiceData = choices.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
diff --git a/src/admin/vote/Results.jsx b/src/admin/vote/Results.jsx
index ede1757..c7a17ef 100644
--- a/src/admin/vote/Results.jsx
+++ b/src/admin/vote/Results.jsx
@@ -482,7 +482,7 @@ export default function Results() {
);
}
-export async function loader({ params }) {
+Results.loader = async function loader({ params }) {
const { id } = params;
const vote = (await getDoc(doc(db, `/votes/${id}`))).data();
const options = (
diff --git a/src/admin/vote/Schedule.jsx b/src/admin/vote/Schedule.jsx
index ea17560..225e89d 100644
--- a/src/admin/vote/Schedule.jsx
+++ b/src/admin/vote/Schedule.jsx
@@ -81,7 +81,7 @@ export default function Schedule() {
)}
-
navigate(`/admin/${id}`)}>
+ navigate(-1)}>
Abbrechen
Speichern
diff --git a/src/admin/vote/index.jsx b/src/admin/vote/index.jsx
index a8d7ccf..08791de 100644
--- a/src/admin/vote/index.jsx
+++ b/src/admin/vote/index.jsx
@@ -141,7 +141,7 @@ export default function AdminVote() {
);
}
-export async function loader({ params }) {
+AdminVote.loader = async function loader({ params }) {
const { id } = params;
const vote = await getDoc(doc(db, `/votes/${id}`));
if (!vote.exists()) {
@@ -163,4 +163,4 @@ export async function loader({ params }) {
options: optionData,
results: resultData,
};
-}
+};
diff --git a/src/main.jsx b/src/main.jsx
index a58da0c..8147ba2 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -8,26 +8,26 @@ import "mdui";
import { setColorScheme, setTheme } from "mdui";
import "mdui/mdui.css";
-import App, { loader as appLoader } from "./App";
-import Gateway, { loader as gatewayLoader } from "./Gateway";
+import App from "./App";
+import Gateway from "./Gateway";
import Result from "./Result";
import Scheduled from "./Scheduled";
import Submitted from "./Submitted";
-import Vote, { loader as voteLoader } from "./Vote";
+import Vote from "./Vote";
import Admin from "./admin/index";
import NewVote from "./admin/NewVote";
-import Overview, { loader as overviewLoader } from "./admin/Overview";
+import Overview from "./admin/Overview";
import Settings from "./admin/Settings";
-import Students, { loader as studentsLoader } from "./admin/Students";
-import Answers, { loader as answersLoader } from "./admin/vote/Answers";
-import Assign, { loader as assignLoader } from "./admin/vote/Assign";
+import Students from "./admin/Students";
+import Answers from "./admin/vote/Answers";
+import Assign from "./admin/vote/Assign";
import Delete from "./admin/vote/Delete";
import Edit from "./admin/vote/Edit";
import Export from "./admin/vote/Export";
-import AdminVote, { loader as adminVoteLoader } from "./admin/vote/index";
-import Match, { loader as matchLoader } from "./admin/vote/Match";
-import Results, { loader as resultsLoader } from "./admin/vote/Results";
+import AdminVote from "./admin/vote/index";
+import Match from "./admin/vote/Match";
+import Results from "./admin/vote/Results";
import Schedule from "./admin/vote/Schedule";
import Share from "./admin/vote/Share";
@@ -42,32 +42,32 @@ const routes = [
{
path: "/",
element: ,
- loader: appLoader,
+ loader: App.loader,
},
{
path: "/:id",
element: ,
- loader: gatewayLoader,
+ loader: Gateway.loader,
},
{
path: "/v/:id",
element: ,
- loader: voteLoader,
+ loader: Vote.loader,
},
{
path: "/r/:id",
element: ,
- loader: voteLoader,
+ loader: Vote.loader,
},
{
path: "/x/:id",
element: ,
- loader: voteLoader,
+ loader: Vote.loader,
},
{
path: "/s/:id",
element: ,
- loader: voteLoader,
+ loader: Vote.loader,
},
{
path: "/admin/*",
@@ -76,7 +76,7 @@ const routes = [
{
path: "",
element: ,
- loader: overviewLoader,
+ loader: Overview.loader,
},
{
path: "new",
@@ -85,7 +85,7 @@ const routes = [
{
path: "students/:classId/:edit?",
element: ,
- loader: studentsLoader,
+ loader: Students.loader,
},
{
path: "settings",
@@ -97,16 +97,17 @@ const routes = [
{
path: "",
element: ,
- loader: adminVoteLoader,
+ loader: AdminVote.loader,
},
{
path: "edit",
element: ,
+ loader: Edit.loader,
},
{
path: "answers",
element: ,
- loader: answersLoader,
+ loader: Answers.loader,
},
{
path: "share",
@@ -115,32 +116,32 @@ const routes = [
{
path: "delete",
element: ,
- loader: adminVoteLoader,
+ loader: AdminVote.loader,
},
{
path: "schedule",
element: ,
- loader: adminVoteLoader,
+ loader: AdminVote.loader,
},
{
path: "match",
element: ,
- loader: matchLoader,
+ loader: Match.loader,
},
{
path: "assign",
element: ,
- loader: assignLoader,
+ loader: Assign.loader,
},
{
path: "export",
element: ,
- loader: adminVoteLoader,
+ loader: AdminVote.loader,
},
{
path: "results",
element: ,
- loader: resultsLoader,
+ loader: Results.loader,
},
],
},