Survey editing

Closes #16
This commit is contained in:
Markus Brueckner 2025-01-07 21:10:55 +01:00
parent f8c8a53ba2
commit d6ad0d2ccd
10 changed files with 254 additions and 105 deletions

View file

@ -1,6 +1,12 @@
import { error } from "@sveltejs/kit";
import debug from "debug";
const log = debug('survey:admin:edit');
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { db } from "../db"; import { db } from "../db";
import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from "../db/schema"; import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from "../db/schema";
import { generateRandomToken } from "$lib/randomToken";
export async function loadSurvey(id: number, ownerId: number) { export async function loadSurvey(id: number, ownerId: number) {
const survey = await db.select().from(surveysTable).where(and(eq(surveysTable.id, id), eq(surveysTable.owner, ownerId))).limit(1); const survey = await db.select().from(surveysTable).where(and(eq(surveysTable.id, id), eq(surveysTable.owner, ownerId))).limit(1);
@ -32,3 +38,33 @@ export async function loadSurvey(id: number, ownerId: number) {
})) }))
} }
} }
export async function loadSurveyData(surveyId: string, userId: number | null) {
const sId = parseInt(surveyId);
if (isNaN(sId)) {
log('Invalid survey ID %s', surveyId);
error(404, 'Invalid survey ID');
}
if (!userId) {
log('User is not logged in');
error(403, 'User is not logged in');
}
const surveyData = await loadSurvey(sId, userId);
if (!surveyData) {
log('Survey not found or user (%s) does not have access: %s', userId, surveyId);
error(404, 'Survey not found');
}
return surveyData;
}
// add a new participant to a survey
export async function addParticipant(surveyId: number, recepientEmail: string) {
await db.insert(surveyAccessTable).values({ surveyId, recepientEmail, accessToken: generateRandomToken() });
}
// add a new skill to a survey
export async function addSkill(surveyId: number, title: string, description: string) {
await db.insert(surveySkillsTable).values({ surveyId, title, description });
}

View file

@ -0,0 +1,65 @@
<script lang="ts">
type Props = {
title: string;
description: string | null;
participants: { email: string }[];
skills: { title: string; description: string | null }[];
submitButtonTitle: string;
};
const { title, description, participants, skills, submitButtonTitle }: Props = $props();
let numParticipants = $state(participants?.length ?? 1);
let numSkills = $state(skills?.length ?? 1);
</script>
<form class="grid grid-cols-2 gap-2 p-4" method="post">
<label for="title" class="justify-self-end">Survey Title</label>
<input type="text" name="title" id="title" class="min-w-80 justify-self-start" value={title} />
<label for="description" class="justify-self-end">Survey Description</label>
<textarea name="description" id="description" class="min-w-80 justify-self-start"
>{description}</textarea
>
<h2 class="col-span-2 text-2xl">Participants</h2>
{#each Array(numParticipants) as _, idx}
<label for="participants" class="justify-self-end">Email</label>
<input
type="email"
name="participants"
id="participants"
class="min-w-80 justify-self-start"
value={participants?.[idx]?.email}
/>
{/each}
<button
type="button"
class="col-span-2 w-40 justify-self-center bg-blue-200"
onclick={() => (numParticipants += 1)}>Add participant</button
>
<h2 class="col-span-2 text-2xl">Skills</h2>
{#each Array(numSkills) as _, idx}
<div class="col-span-2 ml-4 grid grid-cols-2 gap-4">
<div class="flex w-full flex-col justify-self-end">
<label for="skill" class="text-xs">Skill title</label>
<input name="skill" id="skill" class="w-full" value={skills?.[idx]?.title} />
</div>
<div class="flex w-full flex-col justify-self-start">
<label for="skill-description" class="text-xs">Skill description</label>
<textarea name="skill-description" id="skill-description" class="min-h-36 w-full"
>{skills?.[idx]?.description}</textarea
>
</div>
</div>
{/each}
<button
type="button"
class="col-span-2 w-40 justify-self-center bg-blue-200"
onclick={() => (numSkills += 1)}>Add skill</button
>
<button type="submit" class="col-span-2 w-40 justify-self-center bg-slate-200"
>{submitButtonTitle}</button
>
</form>

View file

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

20
src/lib/survey.ts Normal file
View file

@ -0,0 +1,20 @@
export function fromFormData(formData: FormData) {
const participants = formData.getAll('participants').filter(email => !!email).map(email => email.toString());
const title = formData.get('title')?.toString();
const description = formData.get('description')?.toString();
const skillTitles = formData.getAll('skill');
const skillDescriptions = formData.getAll('skill-description');
const skills = skillTitles.flatMap((title, index) => !!title ? [({
title: title.toString(),
description: skillDescriptions[index].toString()
})] : []);
return {
participants,
title,
description,
skills
}
}

View file

@ -1,39 +1,19 @@
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad, RouteParams } from './$types'; import type { PageServerLoad, RouteParams } from './$types';
import { loadSurvey } from '$lib/queries';
import debug from 'debug'; import debug from 'debug';
import { deleteAnswers } from '../../../../db/answers'; import { deleteAnswers } from '../../../../db/answers';
import { loadSurveyData } from '../../../../db/survey';
const log = debug('survey:admin'); const log = debug('survey:admin');
async function loadSurveyData(params: RouteParams, locals: App.Locals) {
const surveyId = parseInt(params.surveyId);
if (isNaN(surveyId)) {
log('Invalid survey ID %s', params.surveyId);
error(400, 'Invalid survey ID');
}
if (!locals.userId) {
log('User is not logged in');
error(403, 'User is not logged in');
}
const surveyData = await loadSurvey(surveyId, locals.userId);
if (!surveyData) {
log('Survey not found or user (%s) does not have access: %s', locals.userId, params.surveyId);
error(404, 'Survey not found');
}
return surveyData;
}
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
return await loadSurveyData(params, locals); return await loadSurveyData(params.surveyId, locals.userId);
} }
export const actions = { export const actions = {
deleteAnswers: async ({ params, locals, request }) => { deleteAnswers: async ({ params, locals, request }) => {
const survey = await loadSurveyData(params, locals); const survey = await loadSurveyData(params.surveyId, locals.userId);
let formData = await request.formData(); let formData = await request.formData();
const participantId = parseInt(formData.get('participantId')?.toString() ?? ''); const participantId = parseInt(formData.get('participantId')?.toString() ?? '');

View file

@ -11,9 +11,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { success } from '$lib/toast'; import { success } from '$lib/toast';
import MarkdownBlock from '$lib/components/MarkdownBlock.svelte'; import MarkdownBlock from '$lib/components/MarkdownBlock.svelte';
import MenuIcon from '$lib/components/icons/MenuIcon.svelte';
import DeleteIcon from '$lib/components/icons/DeleteIcon.svelte'; import DeleteIcon from '$lib/components/icons/DeleteIcon.svelte';
import WarningDialog from '$lib/components/WarningDialog.svelte'; import WarningDialog from '$lib/components/WarningDialog.svelte';
import EditIcon from '$lib/components/icons/EditIcon.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -43,7 +43,7 @@
</script> </script>
<Navbar title={data.title}> <Navbar title={data.title}>
<div class="flex w-20 flex-row justify-self-end"> <div class="w-30 flex flex-row justify-self-end">
<a href="/" class="flex items-center" aria-label="Home" title="Home"> <a href="/" class="flex items-center" aria-label="Home" title="Home">
<HomeIcon /> <HomeIcon />
</a> </a>
@ -54,6 +54,13 @@
title="Duplicate" title="Duplicate"
><DuplicateIcon /> ><DuplicateIcon />
</a> </a>
<a
href="{data.id.toString()}/edit"
class="ml-2 flex items-center"
aria-label="Edit survey"
title="Edit survey"
><EditIcon />
</a>
<button <button
onclick={() => { onclick={() => {
deleteSurveyDialogRef?.showModal(); deleteSurveyDialogRef?.showModal();

View file

@ -0,0 +1,72 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import debug from 'debug';
import { fromFormData } from '$lib/survey';
import { db } from '../../../../../db';
import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from '../../../../../db/schema';
import { eq, inArray } from 'drizzle-orm';
import { addParticipant, addSkill, loadSurveyData } from '../../../../../db/survey';
const log = debug('survey:admin:edit');
export const load: PageServerLoad = async ({ params, locals }) => {
return await loadSurveyData(params.surveyId, locals.userId);
}
export const actions = {
default: async ({ request, params, locals }) => {
const owner = locals.userId;
if (!owner) {
error(400, 'User is not logged in');
}
const formData = await request.formData();
const { participants, title, description, skills } = fromFormData(formData);
if (!title) {
error(400, 'Title is required');
}
if (skills.length === 0) {
error(400, 'At least one skill is required');
}
const survey = await loadSurveyData(params.surveyId, locals.userId);
// update the actual survey object
await db.update(surveysTable).set({ title, description }).where(eq(surveysTable.id, survey.id));
// update the skills where applicable
for (let idx = 0; idx < Math.max(survey.skills.length, skills.length); idx++) {
const newSkill = skills[idx];
const oldSkill = survey.skills[idx];
if (newSkill && oldSkill) {
// this is an update -> change the text only
await db.update(surveySkillsTable).set({ title: newSkill.title, description: newSkill.description }).where(eq(surveySkillsTable.id, oldSkill.id));
} else if (newSkill && !oldSkill) {
// this is a new skill -> insert it into the table
await addSkill(survey.id, newSkill.title, newSkill.description);
} else if (!newSkill && oldSkill) {
// this is a deleted skill -> delete it from the table
await db.delete(surveySkillsTable).where(eq(surveySkillsTable.id, oldSkill.id));
// if a skill is deleted, also delete any potential answers for that skill
await db.delete(surveyAnswersTable).where(eq(surveyAnswersTable.skillId, oldSkill.id));
}
}
// update the participants where applicable (unchanged participants are left untouched)
const deletedParticipants = survey.participants.filter(participant => !participants.includes(participant.email));
const newParticipants = participants.filter(email => !survey.participants.some(candidate => candidate.email === email));
// delete all participants no longer part of the survey
await db.delete(surveyAccessTable).where(inArray(surveyAccessTable.recepientEmail, deletedParticipants.map(participant => participant.email)));
// delete answers from deleted participants
await db.delete(surveyAnswersTable).where(inArray(surveyAnswersTable.participantId, deletedParticipants.map(participant => participant.id)));
// add any new participants
for (const newParticipant of newParticipants) {
await addParticipant(survey.id, newParticipant);
}
redirect(303, ".");
}
}

View file

@ -0,0 +1,20 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import SurveyEditForm from '$lib/components/SurveyEditForm.svelte';
import type { PageData } from '../$types';
let { data }: { data: PageData } = $props();
</script>
<Navbar title="Edit survey" />
{#if data.participants.some((participant) => participant.answers.length > 0)}
<div class="bold mx-auto max-w-xl border-2 border-red-700 bg-red-100 text-center text-red-500">
<p>There are participants who have already submitted ratings.</p>
<p>
Changing the structure of this survey (i.e. changing any of the skills) will potentially
invalidate their answers. Consider either deleting their answers before changing the skills or
leave the skills as is (e.g. only add/remove participants).
</p>
</div>
{/if}
<SurveyEditForm {...data} submitButtonTitle="Save" />

View file

@ -1,9 +1,9 @@
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import { surveyAccessTable, surveySkillsTable, surveysTable } from '../../../../db/schema'; import { surveysTable } from '../../../../db/schema';
import { db } from '../../../../db'; import { db } from '../../../../db';
import { generateRandomToken } from '$lib/randomToken'; import { fromFormData } from '$lib/survey';
import { loadSurvey } from '$lib/queries'; import { addParticipant, addSkill, loadSurvey } from '../../../../db/survey';
export const load: PageServerLoad = async ({ url, locals }) => { export const load: PageServerLoad = async ({ url, locals }) => {
const baseSurveyId = url.searchParams.get('from'); const baseSurveyId = url.searchParams.get('from');
@ -17,29 +17,19 @@ export const load: PageServerLoad = async ({ url, locals }) => {
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
const formData = await event.request.formData();
const participants = formData.getAll('participants').filter(email => !!email).map(email => email.toString());
const title = formData.get('title')?.toString();
const description = formData.get('description')?.toString();
const skillTitles = formData.getAll('skill');
const skillDescriptions = formData.getAll('skill-description');
if (!title) {
error(400, 'Title is required');
}
if (participants.length === 0) {
error(400, 'At least one email is required');
}
const owner = event.locals.userId; const owner = event.locals.userId;
if (!owner) { if (!owner) {
error(400, 'User is not logged in'); error(400, 'User is not logged in');
} }
const skills = skillTitles.flatMap((title, index) => !!title ? [({ const formData = await event.request.formData();
title: title.toString(),
description: skillDescriptions[index].toString() const { participants, title, description, skills } = fromFormData(formData);
})] : []);
if (!title) {
error(400, 'Title is required');
}
if (skills.length === 0) { if (skills.length === 0) {
error(400, 'At least one skill is required'); error(400, 'At least one skill is required');
@ -49,10 +39,10 @@ export const actions = {
const surveyId = ids[0].id; const surveyId = ids[0].id;
for (const participant of participants) { for (const participant of participants) {
await db.insert(surveyAccessTable).values({ surveyId, recepientEmail: participant, accessToken: generateRandomToken() }); await addParticipant(surveyId, participant);
} }
for (const skill of skills) { for (const skill of skills) {
await db.insert(surveySkillsTable).values({ surveyId, title: skill.title, description: skill.description }); await addSkill(surveyId, skill.title, skill.description);
} }
redirect(303, `/survey/${surveyId}`); redirect(303, `/survey/${surveyId}`);
} }

View file

@ -1,65 +1,10 @@
<script lang="ts"> <script lang="ts">
import Navbar from '$lib/components/Navbar.svelte'; import Navbar from '$lib/components/Navbar.svelte';
import SurveyEditForm from '$lib/components/SurveyEditForm.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
const { data }: { data: PageData } = $props(); const { data }: { data: PageData } = $props();
let participants = $state(data?.participants?.length ?? 1);
let skills = $state(data?.skills?.length ?? 1);
</script> </script>
<Navbar title="Create a new survey" /> <Navbar title="Create a new survey" />
<form class="grid grid-cols-2 gap-2 p-4" method="post"> <SurveyEditForm {...data} submitButtonTitle="Create" />
<label for="title" class="justify-self-end">Survey Title</label>
<input
type="text"
name="title"
id="title"
class="min-w-80 justify-self-start"
value={data?.title}
/>
<label for="description" class="justify-self-end">Survey Description</label>
<textarea name="description" id="description" class="min-w-80 justify-self-start"
>{data?.description}</textarea
>
<h2 class="col-span-2 text-2xl">Participants</h2>
{#each Array(participants) as _, idx}
<label for="participants" class="justify-self-end">Email</label>
<input
type="email"
name="participants"
id="participants"
class="min-w-80 justify-self-start"
value={data?.participants?.[idx]?.email}
/>
{/each}
<button
type="button"
class="col-span-2 w-40 justify-self-center bg-blue-200"
onclick={() => (participants += 1)}>Add participant</button
>
<h2 class="col-span-2 text-2xl">Skills</h2>
{#each Array(skills) as _, idx}
<div class="col-span-2 ml-4 grid grid-cols-2 gap-4">
<div class="flex w-full flex-col justify-self-end">
<label for="skill" class="text-xs">Skill title</label>
<input name="skill" id="skill" class="w-full" value={data?.skills?.[idx]?.title} />
</div>
<div class="flex w-full flex-col justify-self-start">
<label for="skill-description" class="text-xs">Skill description</label>
<textarea name="skill-description" id="skill-description" class="min-h-36 w-full"
>{data?.skills?.[idx]?.description}</textarea
>
</div>
</div>
{/each}
<button
type="button"
class="col-span-2 w-40 justify-self-center bg-blue-200"
onclick={() => (skills += 1)}>Add skill</button
>
<button type="submit" class="col-span-2 w-40 justify-self-center bg-slate-200">Create</button>
</form>