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, }, ], },