Listen to document changes instead of polling

This commit is contained in:
Gabriel Tofvesson 2022-10-22 14:10:10 +02:00
parent 8457ce9979
commit 038457f02d
8 changed files with 274 additions and 67 deletions

View File

@ -1,6 +1,10 @@
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
match /state/{document=**} {
allow read, write: if true;
}
match /{document=**} { match /{document=**} {
allow read, write: if false; allow read, write: if false;
} }

View File

@ -4,8 +4,9 @@ import {initializeApp} from "firebase-admin/app";
initializeApp(); initializeApp();
import votingRoute, {activeVoteState} from "./route/voting"; import votingRoute, {activeVoteState} from "./route/voting";
import adminRoute from "./route/admin"; import adminRoute, {getAllVotesCall} from "./route/admin";
export const vote = functions.https.onRequest(votingRoute); export const vote = functions.https.onRequest(votingRoute);
export const admin = functions.https.onRequest(adminRoute); export const admin = functions.https.onRequest(adminRoute);
export const activeVote = activeVoteState; export const activeVote = activeVoteState;
export const getAllVotes = getAllVotesCall;

View File

@ -1,6 +1,8 @@
import * as functions from "firebase-functions";
import express, {Request, Response} from "express"; import express, {Request, Response} from "express";
import cors from "cors"; import cors from "cors";
import {createVote, setActiveVote} from "../types/vote"; import {createVote, setActiveVote, getAllVotes} from "../types/vote";
import { setState, updateState } from "../types/state";
const app = express(); const app = express();
@ -26,7 +28,12 @@ app.post("/create", async (req: Request, res: Response) => {
return; return;
} }
res.json({id: await createVote(prompt, description, options)}); const id = await createVote(prompt, description, options);
await setState(id);
console.log("set state")
res.json({id});
}); });
app.put("/setActive", async (req: Request, res: Response) => { app.put("/setActive", async (req: Request, res: Response) => {
@ -37,7 +44,12 @@ app.put("/setActive", async (req: Request, res: Response) => {
return; return;
} }
res.json({success: await setActiveVote(voteId)}); const success = await setActiveVote(voteId);
if (success) {
await updateState();
}
res.json({success});
}); });
app.put("/closeVote", async (req: Request, res: Response) => { app.put("/closeVote", async (req: Request, res: Response) => {
@ -48,7 +60,19 @@ app.put("/closeVote", async (req: Request, res: Response) => {
return; return;
} }
res.json({success: await setActiveVote(voteId, false)}); const success = await setActiveVote(voteId, false);
if (success) {
await updateState();
}
res.json({success});
}); });
export const getAllVotesCall = functions.https.onCall(async () =>
(await getAllVotes()).docs.map((vote) => ({
...vote.data(),
id: vote.id,
}))
);
export default app; export default app;

View File

@ -10,6 +10,7 @@ import {
getVote, getVote,
getActiveVote, getActiveVote,
} from "../types/vote"; } from "../types/vote";
import { updateVoteCount } from "../types/state";
const app = express(); const app = express();
@ -47,11 +48,13 @@ app.post("/", async (req: Request, res: Response) => {
const entry = await getVoteEntry(vote.ref, voter); const entry = await getVoteEntry(vote.ref, voter);
if (!entry) { if (!entry) {
await makeVoteEntry(vote.ref, voter, voteIndex); await makeVoteEntry(vote.ref, voter, voteIndex);
await updateVoteCount();
res.json({success: true}); res.json({success: true});
return; return;
} }
await updateVoteEntry(entry, voteIndex); await updateVoteEntry(entry, voteIndex);
await updateVoteCount();
res.json({success: true}); res.json({success: true});
}); });

View File

@ -0,0 +1,27 @@
import {
FieldValue,
getFirestore,
} from "firebase-admin/firestore";
const db = getFirestore();
export type State = {
currentVote: string | null
voteChanges: number
changes: number
};
const stateCollection = db.collection("state");
export const setState = (currentVote: string | null) =>
stateCollection.doc("currentVote").set({
currentVote,
changes: 0,
voteChanges: 0
});
export const updateState = () =>
stateCollection.doc("currentVote").update({changes: FieldValue.increment(1)});
export const updateVoteCount = () =>
stateCollection.doc("currentVote").update({voteChanges: FieldValue.increment(1)});

View File

@ -28,6 +28,7 @@ export type Vote = {
export const getEntriesCollection = (vote: DocumentReference<Vote>) => export const getEntriesCollection = (vote: DocumentReference<Vote>) =>
vote.collection(entriesCollectionName); vote.collection(entriesCollectionName);
export const getAllVotes = async () => await votesCollection.get();
export const getVoteSnapshot = async (id: string) => export const getVoteSnapshot = async (id: string) =>
votesCollection votesCollection

View File

@ -23,6 +23,13 @@
<script defer src="/__/firebase/init.js?useEmulator=true"></script> <script defer src="/__/firebase/init.js?useEmulator=true"></script>
<style media="screen"> <style media="screen">
:root {
--voteCardColor: #e5e5e5;
}
#createVote {
display: flex;
width: 50%;
}
.form-inputs { .form-inputs {
padding: 40px; padding: 40px;
flex: 60%; flex: 60%;
@ -85,52 +92,95 @@
*:focus { *:focus {
outline: none; outline: none;
} }
#splitpage {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 100vh;
}
#votes {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
width: 50%;
}
.btn-activate {
margin: 20px;
background-color: #f6fe00;
color: black;
padding: 10px 40px;
font-weight: 700;
border: none;
cursor: pointer;
}
.btn-close {
margin: 20px;
background-color: red;
color: black;
padding: 10px 40px;
font-weight: 700;
border: none;
cursor: pointer;
}
</style> </style>
</head> </head>
<body> <body>
<h1>Controls</h1> <h1>Controls</h1>
<div id="splitpage">
<!-- Form for creating a vote with a prompt and between 2 and 4 options --> <!-- Form for creating a vote with a prompt and between 2 and 4 options -->
<div class="form-inputs"> <div id="createVote" class="form-inputs">
<form action="" method="POST" id="createVoteForm"> <form action="" method="POST" id="createVoteForm">
<div class="form-row"> <div class="form-row">
<span class="form-row-number"></span> <span class="form-row-number"></span>
<input type="text" class="form-row-field-input" placeholder=" " name="POST-prompt"> <input type="text" class="form-row-field-input" placeholder=" " name="POST-prompt">
<label for="POST-prompt" class="form-row-field">Prompt</label> <label for="POST-prompt" class="form-row-field">Prompt</label>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="form-row-number"></span> <span class="form-row-number"></span>
<input type="text" class="form-row-field-input" placeholder=" " name="POST-description"> <input type="text" class="form-row-field-input" placeholder=" " name="POST-description">
<label for="POST-prompt" class="form-row-field">Description</label> <label for="POST-prompt" class="form-row-field">Description</label>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="form-row-number"></span> <span class="form-row-number"></span>
<input type="text" class="form-row-field-input" placeholder=" " name="POST-opt1"> <input type="text" class="form-row-field-input" placeholder=" " name="POST-opt1">
<label for="POST-opt1" class="form-row-field">Option 1</label> <label for="POST-opt1" class="form-row-field">Option 1</label>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="form-row-number"></span> <span class="form-row-number"></span>
<input type="text" class="form-row-field-input" placeholder=" " name="POST-opt2"> <input type="text" class="form-row-field-input" placeholder=" " name="POST-opt2">
<label for="POST-opt2" class="form-row-field">Option 2</label> <label for="POST-opt2" class="form-row-field">Option 2</label>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="form-row-number"></span> <span class="form-row-number"></span>
<input type="text" class="form-row-field-input" placeholder=" " name="POST-opt3"> <input type="text" class="form-row-field-input" placeholder=" " name="POST-opt3">
<label for="POST-opt3" class="form-row-field">Option 3</label> <label for="POST-opt3" class="form-row-field">Option 3</label>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="form-row-number"></span> <span class="form-row-number"></span>
<input type="text" class="form-row-field-input" placeholder=" " name="POST-opt4"> <input type="text" class="form-row-field-input" placeholder=" " name="POST-opt4">
<label for="POST-opt4" class="form-row-field">Option 4</label> <label for="POST-opt4" class="form-row-field">Option 4</label>
</div> </div>
<input class="form-btn" type="submit" value="Create vote"> <input class="form-btn" type="submit" value="Create vote">
</form> </form>
</div>
<div id="votes">
<ul id="votelist">
</ul>
</div>
</div> </div>
<script> <script>
@ -140,6 +190,88 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const createVoteForm = document.querySelector('#createVoteForm'); const createVoteForm = document.querySelector('#createVoteForm');
function createVoteEntry(vote) {
const li = document.createElement('li');
li.style.display = "flex";
li.style.flexDirection = "column";
li.style.width = "100%";
li.style.margin = "20px";
li.style.backgroundColor = "var(--voteCardColor)";
li.style.borderRadius = "10px";
li.style.padding = "20px";
const title = document.createElement('h3');
title.innerText = vote.prompt;
li.appendChild(title);
const ul = document.createElement('ul');
ul.style.listStyleType = "none";
for (let i = 0; i < vote.options.length; i++) {
const option = vote.options[i];
const li = document.createElement('li');
li.innerText = option;
ul.appendChild(li);
}
li.appendChild(ul);
const div = document.createElement('div');
div.style.display = "flex";
div.style.flexDirection = "row";
div.style.justifyContent = "space-between";
const buttonSetActive = document.createElement('button');
buttonSetActive.innerText = "Set active";
buttonSetActive.addEventListener('click', () => {
fetch(target + 'admin/setActive', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
voteId: vote.id
})
});
});
buttonSetActive.classList.add('btn-activate');
div.appendChild(buttonSetActive);
const buttonClose = document.createElement('button');
buttonClose.innerText = "Close";
buttonClose.addEventListener('click', () => {
fetch(target + 'admin/closeVote', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
voteId: vote.id
})
});
});
buttonClose.classList.add('btn-close');
div.appendChild(buttonClose);
li.appendChild(div);
return li;
}
const getAllVotes = firebase.functions().httpsCallable('getAllVotes');
function updateVoteList() {
getAllVotes().then((result) => {
const votelist = document.querySelector('#votelist');
while (votelist.firstChild) {
votelist.removeChild(votelist.firstChild);
}
for (let i = 0; i < result.data.length; i++) {
votelist.appendChild(createVoteEntry(result.data[i]));
}
});
}
function processCreateVote(e) { function processCreateVote(e) {
e.preventDefault(); e.preventDefault();
@ -171,15 +303,6 @@
} }
const req = new XMLHttpRequest(); const req = new XMLHttpRequest();
req.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
alert(this.responseText);
} else {
console.log(this.readyState, this.status);
}
};
req.open("POST", target + "admin/create"); req.open("POST", target + "admin/create");
req.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); req.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
req.send(JSON.stringify({ req.send(JSON.stringify({
@ -195,6 +318,8 @@
createVoteForm.addEventListener("submit", processCreateVote); createVoteForm.addEventListener("submit", processCreateVote);
} }
updateVoteList();
//const loadEl = document.querySelector('#load'); //const loadEl = document.querySelector('#load');
// // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥 // // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
// // The Firebase SDK is initialized and available here! // // The Firebase SDK is initialized and available here!
@ -211,6 +336,19 @@
// //
// // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥 // // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
let currentState = undefined;
function onChange(change) {
change.docs.forEach(doc => {
const changeData = doc.data();
if (currentState === undefined || changeData.currentVote !== currentState.currentVote || changeData.changes !== currentState.changes)
updateVoteList();
currentState = changeData;
});
}
firebase.firestore().collection('state').onSnapshot(onChange);
try { try {
let app = firebase.app(); let app = firebase.app();
/* /*

View File

@ -61,7 +61,7 @@
text-align: center; text-align: center;
font-size: 14px; font-size: 14px;
z-index: 100; z-index: 100;
height: 33%; height: 35%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
background-color: #ffffff; background-color: #ffffff;
@ -136,7 +136,7 @@
#voteprompt { #voteprompt {
margin-bottom: 1px; margin-bottom: 1px;
margin-top: 5px; margin-top: 2px;
} }
#votes { #votes {
@ -147,6 +147,7 @@
padding: 0px; padding: 0px;
width: 100%; width: 100%;
height: 35px; height: 35px;
margin: 5px 0px;
overflow: hidden; overflow: hidden;
/* background-color: #ffa100; */ /* background-color: #ffa100; */
} }
@ -223,28 +224,31 @@
flex-wrap: wrap; flex-wrap: wrap;
display: flex; display: flex;
list-style: none; list-style: none;
align-items: center; align-items: stretch;
justify-content: space-evenly; justify-content: space-evenly;
padding: 0px; padding: 0px;
} }
#voteopts > li { #voteopts > li {
flex: 1 0 50%; flex: 1 0 35%;
padding: 0px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 1%;
border-color: black;
border-style: solid;
border-width: 2px;
} }
#voteopts > li > strong { #voteopts > li > strong {
width: auto; width: auto;
margin: 0px; margin: 5px 0px;
padding: 4px 25%; padding: 2px 0px;
color: var(--titleColor); color: var(--titleColor);
} }
/* General styles */ /* General styles */
#voteopts > li > strong, .outlinebox { .outlinebox {
outline-color: black; outline-color: black;
outline-style: solid; outline-style: solid;
outline-width: 2px; outline-width: 2px;
@ -301,8 +305,10 @@
<p style="text-align: start; color: var(--infoColor);">Vote by typing one of the following options in chat.</p> <p style="text-align: start; color: var(--infoColor);">Vote by typing one of the following options in chat.</p>
<div id="optsandinfo"> <div id="optsandinfo">
<ul id="voteopts"> <ul id="voteopts">
<!--
<li><strong>/Yes</strong></li> <li><strong>/Yes</strong></li>
<li><strong>/No</strong></li> <li><strong>/No</strong></li>
-->
</ul> </ul>
<p style="margin: 0px; align-self: center; color: var(--infoColor);">23 votes registered</p> <p style="margin: 0px; align-self: center; color: var(--infoColor);">23 votes registered</p>
</div> </div>
@ -406,22 +412,25 @@
function updateVote() { function updateVote() {
activeVote().then((data) => { activeVote().then((data) => {
if (data && data.data) { if (data && data.data) {
if (!activeVoteData || activeVoteData.id !== data.id) { console.log(activeVoteData, data);
if (!activeVoteData || activeVoteData.voteId !== data.data.voteId) {
activeVoteData = data.data; activeVoteData = data.data;
console.log("Full update");
updateVoteUI(true); updateVoteUI(true);
} else { } else {
activeVoteData = data.data; activeVoteData = data.data;
console.log("Partial update");
updateVoteUI(false); updateVoteUI(false);
} }
} }
setTimeout(updateVote, voteUpdateInterval);
}); });
} }
updateVoteUI(true); updateVoteUI(true);
updateVote(); updateVote();
firebase.firestore().collection('state').onSnapshot(updateVote);
try { try {
let app = firebase.app(); let app = firebase.app();
/* /*