Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 76 additions & 168 deletions front-end/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,155 +2,70 @@
import { onMount } from "svelte";

import logo from "/logo.svg";
import RadioGroup from "./lib/RadioGroup.svelte";
import {
createAnswer,
getAnswer,
getQuestion,
getSummary,
updateAnswer,
waitUntilLive,
type Answer,
type Problem,
type Question,
type Summary,
type UpdateAnswerPayload,
} from "./lib/api";
import { getQuestion, waitUntilLive, type Question } from "./lib/api";
import Loading from "./lib/Loading.svelte";
import Error from "./lib/Error.svelte";
import Submit from "./lib/Submit.svelte";
import Comment from "./lib/Comment.svelte";
import BarChart from "./lib/BarChart.svelte";
import Footer from "./lib/Footer.svelte";
import Share from "./views/Share.svelte";

let loading = $state(true);
let view = $state<string>("form");
let error = $state<Problem | null>(null);
import { initializePath, parsePath, updatePath } from "./lib/path.svelte";
import Link from "./lib/Link.svelte";
import { getStatus, setError, setLoading } from "./lib/status.svelte";
import Summary from "./views/Summary.svelte";
import Form from "./views/Form.svelte";

let { loading, error } = $derived.by(getStatus);
let view = $derived.by(() => parsePath().view);
let question = $state<Question | null>(null);
let answer = $state<Answer | null>(null);
let summary = $state<Summary | null>(null);

onMount(() => {
const initAnswer = async () => {
try {
// Wait until the server has started.
await waitUntilLive();

// Parse the question key from URL path or URL query parameters.
const pathComponents = window.location.pathname.split("/").slice(1);
const query = new URLSearchParams(window.location.search);

const key = pathComponents[0] || query.get("key");
// Replace path if query parameter is used. This is for backwards compatibility.
if (key && !pathComponents[0]) {
window.history.replaceState({}, "", `/${key}`);
}
if (!key) {
error = {
status: 404,
title: "Question not found",
};
return;
}

view = pathComponents[1] || "form";
if (["form", "share", "summary"].includes(view) === false) {
error = {
status: 404,
title: "Page not found",
};
return;
}

// Fetch the question using the key.
const qr = await getQuestion(key);
if (qr.error) {
error = qr.error;
return;
}
question = qr.data;

// Initialize new answer or get existings answer.
const id = query.get("id");
if (view === "form") {
const ar = id ? await getAnswer(id) : await createAnswer(key);
if (ar.error) {
error = ar.error;
return;
}
answer = ar.data;
window.history.replaceState({}, "", `/${key}?id=${answer.id}`);
}

// Fetch the summary.
if (view === "summary") {
fetchSummary(key);
}
} catch (err) {
error = {
status: 500,
title: "Failed to initialize feedback form.",
};
} finally {
loading = false;
}
};

window.addEventListener("popstate", (event) => {
initAnswer();
});

initAnswer();
});

const handleChange = async (payload: UpdateAnswerPayload) => {
if (!answer) {
return;
}

const initializeQuestion = async () => {
try {
const ar = await updateAnswer(answer?.id, payload);
if (ar.error) {
error = ar.error;
// Wait until the server has started.
await waitUntilLive();

const { key } = parsePath();
if (!key) {
setError({
status: 404,
title: "Question not found",
});
return;
}
answer = ar.data;
} catch (_) {
error = {
status: 500,
title: "Failed to update feedback.",
};
}
};

const fetchSummary = async (key?: string) => {
loading = true;
try {
const sr = await getSummary(key ?? "");
if (sr.error) {
error = sr.error;
// Fetch the question using the key.
const qr = await getQuestion(key);
if (qr.error) {
setError(qr.error);
return;
}
summary = sr.data;
} catch (_) {
error = {
question = qr.data;
} catch (err) {
setError({
status: 500,
title: "Failed to fetch summary.",
};
title: "Failed to initialize feedback application.",
});
} finally {
loading = false;
setLoading(false);
}
};

const handleSubmit = async () => {
loading = true;
await handleChange({ submit: true });
view = "summary";
window.history.pushState({ id: answer?.id }, "", `/${answer?.key}/summary`);
await fetchSummary(answer?.key);
loading = false;
};
onMount(() => {
initializePath();
initializeQuestion();

window.addEventListener("popstate", () => {
updatePath();
});
});

$effect(() => {
if (["form", "share", "summary"].includes(view) === false) {
setError({
status: 404,
title: "Page not found",
});
return;
}
});
</script>

<header>
Expand All @@ -164,34 +79,31 @@
<Loading />
{:else if error}
<Error {error} />
{:else if question && view === "form"}
<fieldset>
<legend>{question.choice_text}</legend>
<RadioGroup
items={question.choices}
name={question.type}
onChange={(value) => handleChange({ value })}
value={answer?.value}
/>
</fieldset>
{#if answer?.value != undefined}
{#if question?.with_comment}
<Comment
label={question?.comment_text}
onChange={(comment) => handleChange({ comment })}
value={answer?.comment}
/>
{/if}
<Submit onSubmit={handleSubmit} />
{/if}
{:else if question && view === "summary" && summary}
<div class="summary">
<p>Thank you for your feedback!</p>
<BarChart choices={question.choices} {summary} />
</div>
{/if}
{#if question && view === "form"}
<Form {question} />
{:else if question && view === "summary"}
<Summary {question} />
{:else if question && view === "share"}
<Share {question} />
{/if}
{#if question}
<div class="links">
{#if view !== "form"}
<Link target={`/${question.key}`}>Submit an answer</Link>
{/if}
{#if view !== "share"}
<Link target={`/${question.key}/share`}>Share the URL</Link>
{/if}
{#if view !== "summary"}
{#if view === "share"}
<Link target={`/${question.key}/summary`}>Hide the QR code</Link>
{:else}
<Link target={`/${question.key}/summary`}>View answer summary</Link>
{/if}
{/if}
</div>
{/if}
</main>
<Footer />

Expand Down Expand Up @@ -228,16 +140,12 @@
flex: 1;
}

fieldset {
appearance: none;
border: none;
margin: 0;
padding: 0;
}

legend {
appearance: none;
margin: 1rem 0;
padding: 0;
.links {
color: var(--color-secondary);
display: flex;
gap: 1rem;
margin: 3rem 0 2rem;
/* Disable margins from collapsing */
padding-top: 0.05px;
}
</style>
6 changes: 2 additions & 4 deletions front-end/src/lib/BarChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@
</div>

<style>
.bar-chart {
margin: 2rem 0;
}

.row {
display: flex;
align-items: center;
Expand All @@ -37,5 +33,7 @@
border-radius: var(--border-radius);
height: 0.5rem;
min-width: 1rem;

transition: width 0.2s ease-in-out;
}
</style>
19 changes: 19 additions & 0 deletions front-end/src/lib/Link.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { navigate } from "./path.svelte";

interface Props {
children: Snippet;
target: string;
}

let { children, target }: Props = $props();
</script>

<a
href={target}
onclick={(event) => {
navigate(target);
event.preventDefault();
}}>{@render children?.()}</a
>
1 change: 0 additions & 1 deletion front-end/src/lib/Submit.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
color: var(--color-fg);
font-weight: 500;
letter-spacing: 0.5px;
margin: 2rem 0;
padding: 0.5rem 1rem;

transition:
Expand Down
43 changes: 43 additions & 0 deletions front-end/src/lib/path.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export interface QuestionPath {
key: string;
view: string;
id?: string;
}

let path = $state<string>("");

export const updatePath = () => {
path = window.location.pathname;
};

export const initializePath = () => {
updatePath();
const { key } = parsePath();

// Replace path if key is given as query parameter instead of path parameter. This is for backwards compatibility.
const query = new URLSearchParams(window.location.search);
const queryKey = query.get("key");
if (queryKey && !key) {
navigate(`/${queryKey}`, true);
}
};

export const parsePath = (): QuestionPath => {
const pathComponents = path.split("/").slice(1);
const query = new URLSearchParams(window.location.search);

const key = pathComponents[0] ?? "";
const view = pathComponents[1] || "form";
const id = query.get("id") ?? undefined;

return { key, view, id };
};

export const navigate = (next: string, replace = false) => {
if (replace) {
window.history.replaceState({}, "", next);
} else {
window.history.pushState({}, "", next);
}
path = next;
};
23 changes: 23 additions & 0 deletions front-end/src/lib/status.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Problem } from "./api";

export interface Status {
loading: boolean;
error: Problem | null;
}

let status = $state<Status>({
loading: true,
error: null,
});

export const setLoading = (loading: boolean) => {
status.loading = loading;
};

export const setError = (error: Problem | null) => {
status.error = error;
};

export const getStatus = () => {
return JSON.parse(JSON.stringify(status));
};
Loading