Skip to content

Commit f0f77fa

Browse files
authored
Use components for form and summary views (#34)
1 parent 07008ae commit f0f77fa

File tree

10 files changed

+375
-178
lines changed

10 files changed

+375
-178
lines changed

front-end/src/App.svelte

Lines changed: 76 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -2,155 +2,70 @@
22
import { onMount } from "svelte";
33
44
import logo from "/logo.svg";
5-
import RadioGroup from "./lib/RadioGroup.svelte";
6-
import {
7-
createAnswer,
8-
getAnswer,
9-
getQuestion,
10-
getSummary,
11-
updateAnswer,
12-
waitUntilLive,
13-
type Answer,
14-
type Problem,
15-
type Question,
16-
type Summary,
17-
type UpdateAnswerPayload,
18-
} from "./lib/api";
5+
import { getQuestion, waitUntilLive, type Question } from "./lib/api";
196
import Loading from "./lib/Loading.svelte";
207
import Error from "./lib/Error.svelte";
21-
import Submit from "./lib/Submit.svelte";
22-
import Comment from "./lib/Comment.svelte";
23-
import BarChart from "./lib/BarChart.svelte";
248
import Footer from "./lib/Footer.svelte";
259
import Share from "./views/Share.svelte";
26-
27-
let loading = $state(true);
28-
let view = $state<string>("form");
29-
let error = $state<Problem | null>(null);
10+
import { initializePath, parsePath, updatePath } from "./lib/path.svelte";
11+
import Link from "./lib/Link.svelte";
12+
import { getStatus, setError, setLoading } from "./lib/status.svelte";
13+
import Summary from "./views/Summary.svelte";
14+
import Form from "./views/Form.svelte";
15+
16+
let { loading, error } = $derived.by(getStatus);
17+
let view = $derived.by(() => parsePath().view);
3018
let question = $state<Question | null>(null);
31-
let answer = $state<Answer | null>(null);
32-
let summary = $state<Summary | null>(null);
33-
34-
onMount(() => {
35-
const initAnswer = async () => {
36-
try {
37-
// Wait until the server has started.
38-
await waitUntilLive();
39-
40-
// Parse the question key from URL path or URL query parameters.
41-
const pathComponents = window.location.pathname.split("/").slice(1);
42-
const query = new URLSearchParams(window.location.search);
43-
44-
const key = pathComponents[0] || query.get("key");
45-
// Replace path if query parameter is used. This is for backwards compatibility.
46-
if (key && !pathComponents[0]) {
47-
window.history.replaceState({}, "", `/${key}`);
48-
}
49-
if (!key) {
50-
error = {
51-
status: 404,
52-
title: "Question not found",
53-
};
54-
return;
55-
}
56-
57-
view = pathComponents[1] || "form";
58-
if (["form", "share", "summary"].includes(view) === false) {
59-
error = {
60-
status: 404,
61-
title: "Page not found",
62-
};
63-
return;
64-
}
65-
66-
// Fetch the question using the key.
67-
const qr = await getQuestion(key);
68-
if (qr.error) {
69-
error = qr.error;
70-
return;
71-
}
72-
question = qr.data;
73-
74-
// Initialize new answer or get existings answer.
75-
const id = query.get("id");
76-
if (view === "form") {
77-
const ar = id ? await getAnswer(id) : await createAnswer(key);
78-
if (ar.error) {
79-
error = ar.error;
80-
return;
81-
}
82-
answer = ar.data;
83-
window.history.replaceState({}, "", `/${key}?id=${answer.id}`);
84-
}
85-
86-
// Fetch the summary.
87-
if (view === "summary") {
88-
fetchSummary(key);
89-
}
90-
} catch (err) {
91-
error = {
92-
status: 500,
93-
title: "Failed to initialize feedback form.",
94-
};
95-
} finally {
96-
loading = false;
97-
}
98-
};
99-
100-
window.addEventListener("popstate", (event) => {
101-
initAnswer();
102-
});
103-
104-
initAnswer();
105-
});
106-
107-
const handleChange = async (payload: UpdateAnswerPayload) => {
108-
if (!answer) {
109-
return;
110-
}
11119
20+
const initializeQuestion = async () => {
11221
try {
113-
const ar = await updateAnswer(answer?.id, payload);
114-
if (ar.error) {
115-
error = ar.error;
22+
// Wait until the server has started.
23+
await waitUntilLive();
24+
25+
const { key } = parsePath();
26+
if (!key) {
27+
setError({
28+
status: 404,
29+
title: "Question not found",
30+
});
11631
return;
11732
}
118-
answer = ar.data;
119-
} catch (_) {
120-
error = {
121-
status: 500,
122-
title: "Failed to update feedback.",
123-
};
124-
}
125-
};
12633
127-
const fetchSummary = async (key?: string) => {
128-
loading = true;
129-
try {
130-
const sr = await getSummary(key ?? "");
131-
if (sr.error) {
132-
error = sr.error;
34+
// Fetch the question using the key.
35+
const qr = await getQuestion(key);
36+
if (qr.error) {
37+
setError(qr.error);
13338
return;
13439
}
135-
summary = sr.data;
136-
} catch (_) {
137-
error = {
40+
question = qr.data;
41+
} catch (err) {
42+
setError({
13843
status: 500,
139-
title: "Failed to fetch summary.",
140-
};
44+
title: "Failed to initialize feedback application.",
45+
});
14146
} finally {
142-
loading = false;
47+
setLoading(false);
14348
}
14449
};
14550
146-
const handleSubmit = async () => {
147-
loading = true;
148-
await handleChange({ submit: true });
149-
view = "summary";
150-
window.history.pushState({ id: answer?.id }, "", `/${answer?.key}/summary`);
151-
await fetchSummary(answer?.key);
152-
loading = false;
153-
};
51+
onMount(() => {
52+
initializePath();
53+
initializeQuestion();
54+
55+
window.addEventListener("popstate", () => {
56+
updatePath();
57+
});
58+
});
59+
60+
$effect(() => {
61+
if (["form", "share", "summary"].includes(view) === false) {
62+
setError({
63+
status: 404,
64+
title: "Page not found",
65+
});
66+
return;
67+
}
68+
});
15469
</script>
15570

15671
<header>
@@ -164,34 +79,31 @@
16479
<Loading />
16580
{:else if error}
16681
<Error {error} />
167-
{:else if question && view === "form"}
168-
<fieldset>
169-
<legend>{question.choice_text}</legend>
170-
<RadioGroup
171-
items={question.choices}
172-
name={question.type}
173-
onChange={(value) => handleChange({ value })}
174-
value={answer?.value}
175-
/>
176-
</fieldset>
177-
{#if answer?.value != undefined}
178-
{#if question?.with_comment}
179-
<Comment
180-
label={question?.comment_text}
181-
onChange={(comment) => handleChange({ comment })}
182-
value={answer?.comment}
183-
/>
184-
{/if}
185-
<Submit onSubmit={handleSubmit} />
186-
{/if}
187-
{:else if question && view === "summary" && summary}
188-
<div class="summary">
189-
<p>Thank you for your feedback!</p>
190-
<BarChart choices={question.choices} {summary} />
191-
</div>
82+
{/if}
83+
{#if question && view === "form"}
84+
<Form {question} />
85+
{:else if question && view === "summary"}
86+
<Summary {question} />
19287
{:else if question && view === "share"}
19388
<Share {question} />
19489
{/if}
90+
{#if question}
91+
<div class="links">
92+
{#if view !== "form"}
93+
<Link target={`/${question.key}`}>Submit an answer</Link>
94+
{/if}
95+
{#if view !== "share"}
96+
<Link target={`/${question.key}/share`}>Share the URL</Link>
97+
{/if}
98+
{#if view !== "summary"}
99+
{#if view === "share"}
100+
<Link target={`/${question.key}/summary`}>Hide the QR code</Link>
101+
{:else}
102+
<Link target={`/${question.key}/summary`}>View answer summary</Link>
103+
{/if}
104+
{/if}
105+
</div>
106+
{/if}
195107
</main>
196108
<Footer />
197109

@@ -228,16 +140,12 @@
228140
flex: 1;
229141
}
230142
231-
fieldset {
232-
appearance: none;
233-
border: none;
234-
margin: 0;
235-
padding: 0;
236-
}
237-
238-
legend {
239-
appearance: none;
240-
margin: 1rem 0;
241-
padding: 0;
143+
.links {
144+
color: var(--color-secondary);
145+
display: flex;
146+
gap: 1rem;
147+
margin: 3rem 0 2rem;
148+
/* Disable margins from collapsing */
149+
padding-top: 0.05px;
242150
}
243151
</style>

front-end/src/lib/BarChart.svelte

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@
2121
</div>
2222

2323
<style>
24-
.bar-chart {
25-
margin: 2rem 0;
26-
}
27-
2824
.row {
2925
display: flex;
3026
align-items: center;
@@ -37,5 +33,7 @@
3733
border-radius: var(--border-radius);
3834
height: 0.5rem;
3935
min-width: 1rem;
36+
37+
transition: width 0.2s ease-in-out;
4038
}
4139
</style>

front-end/src/lib/Link.svelte

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script lang="ts">
2+
import type { Snippet } from "svelte";
3+
import { navigate } from "./path.svelte";
4+
5+
interface Props {
6+
children: Snippet;
7+
target: string;
8+
}
9+
10+
let { children, target }: Props = $props();
11+
</script>
12+
13+
<a
14+
href={target}
15+
onclick={(event) => {
16+
navigate(target);
17+
event.preventDefault();
18+
}}>{@render children?.()}</a
19+
>

front-end/src/lib/Submit.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
color: var(--color-fg);
1919
font-weight: 500;
2020
letter-spacing: 0.5px;
21-
margin: 2rem 0;
2221
padding: 0.5rem 1rem;
2322
2423
transition:

front-end/src/lib/path.svelte.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export interface QuestionPath {
2+
key: string;
3+
view: string;
4+
id?: string;
5+
}
6+
7+
let path = $state<string>("");
8+
9+
export const updatePath = () => {
10+
path = window.location.pathname;
11+
};
12+
13+
export const initializePath = () => {
14+
updatePath();
15+
const { key } = parsePath();
16+
17+
// Replace path if key is given as query parameter instead of path parameter. This is for backwards compatibility.
18+
const query = new URLSearchParams(window.location.search);
19+
const queryKey = query.get("key");
20+
if (queryKey && !key) {
21+
navigate(`/${queryKey}`, true);
22+
}
23+
};
24+
25+
export const parsePath = (): QuestionPath => {
26+
const pathComponents = path.split("/").slice(1);
27+
const query = new URLSearchParams(window.location.search);
28+
29+
const key = pathComponents[0] ?? "";
30+
const view = pathComponents[1] || "form";
31+
const id = query.get("id") ?? undefined;
32+
33+
return { key, view, id };
34+
};
35+
36+
export const navigate = (next: string, replace = false) => {
37+
if (replace) {
38+
window.history.replaceState({}, "", next);
39+
} else {
40+
window.history.pushState({}, "", next);
41+
}
42+
path = next;
43+
};

front-end/src/lib/status.svelte.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Problem } from "./api";
2+
3+
export interface Status {
4+
loading: boolean;
5+
error: Problem | null;
6+
}
7+
8+
let status = $state<Status>({
9+
loading: true,
10+
error: null,
11+
});
12+
13+
export const setLoading = (loading: boolean) => {
14+
status.loading = loading;
15+
};
16+
17+
export const setError = (error: Problem | null) => {
18+
status.error = error;
19+
};
20+
21+
export const getStatus = () => {
22+
return JSON.parse(JSON.stringify(status));
23+
};

0 commit comments

Comments
 (0)