parent
f8c8a53ba2
commit
d6ad0d2ccd
10 changed files with 254 additions and 105 deletions
|
@ -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 { db } from "../db";
|
||||
import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from "../db/schema";
|
||||
import { generateRandomToken } from "$lib/randomToken";
|
||||
|
||||
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);
|
||||
|
@ -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 });
|
||||
}
|
65
src/lib/components/SurveyEditForm.svelte
Normal file
65
src/lib/components/SurveyEditForm.svelte
Normal 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>
|
14
src/lib/components/icons/EditIcon.svelte
Normal file
14
src/lib/components/icons/EditIcon.svelte
Normal 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
20
src/lib/survey.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -1,39 +1,19 @@
|
|||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, RouteParams } from './$types';
|
||||
import { loadSurvey } from '$lib/queries';
|
||||
|
||||
import debug from 'debug';
|
||||
import { deleteAnswers } from '../../../../db/answers';
|
||||
import { loadSurveyData } from '../../../../db/survey';
|
||||
|
||||
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 }) => {
|
||||
return await loadSurveyData(params, locals);
|
||||
return await loadSurveyData(params.surveyId, locals.userId);
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
deleteAnswers: async ({ params, locals, request }) => {
|
||||
const survey = await loadSurveyData(params, locals);
|
||||
const survey = await loadSurveyData(params.surveyId, locals.userId);
|
||||
|
||||
let formData = await request.formData();
|
||||
const participantId = parseInt(formData.get('participantId')?.toString() ?? '');
|
||||
|
|
|
@ -11,9 +11,9 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { success } from '$lib/toast';
|
||||
import MarkdownBlock from '$lib/components/MarkdownBlock.svelte';
|
||||
import MenuIcon from '$lib/components/icons/MenuIcon.svelte';
|
||||
import DeleteIcon from '$lib/components/icons/DeleteIcon.svelte';
|
||||
import WarningDialog from '$lib/components/WarningDialog.svelte';
|
||||
import EditIcon from '$lib/components/icons/EditIcon.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
|
@ -43,7 +43,7 @@
|
|||
</script>
|
||||
|
||||
<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">
|
||||
<HomeIcon />
|
||||
</a>
|
||||
|
@ -54,6 +54,13 @@
|
|||
title="Duplicate"
|
||||
><DuplicateIcon />
|
||||
</a>
|
||||
<a
|
||||
href="{data.id.toString()}/edit"
|
||||
class="ml-2 flex items-center"
|
||||
aria-label="Edit survey"
|
||||
title="Edit survey"
|
||||
><EditIcon />
|
||||
</a>
|
||||
<button
|
||||
onclick={() => {
|
||||
deleteSurveyDialogRef?.showModal();
|
||||
|
|
72
src/routes/(app)/survey/[surveyId]/edit/+page.server.ts
Normal file
72
src/routes/(app)/survey/[surveyId]/edit/+page.server.ts
Normal 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, ".");
|
||||
}
|
||||
}
|
20
src/routes/(app)/survey/[surveyId]/edit/+page.svelte
Normal file
20
src/routes/(app)/survey/[surveyId]/edit/+page.svelte
Normal 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" />
|
|
@ -1,9 +1,9 @@
|
|||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { surveyAccessTable, surveySkillsTable, surveysTable } from '../../../../db/schema';
|
||||
import { surveysTable } from '../../../../db/schema';
|
||||
import { db } from '../../../../db';
|
||||
import { generateRandomToken } from '$lib/randomToken';
|
||||
import { loadSurvey } from '$lib/queries';
|
||||
import { fromFormData } from '$lib/survey';
|
||||
import { addParticipant, addSkill, loadSurvey } from '../../../../db/survey';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const baseSurveyId = url.searchParams.get('from');
|
||||
|
@ -17,29 +17,19 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||
|
||||
export const actions = {
|
||||
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;
|
||||
if (!owner) {
|
||||
error(400, 'User is not logged in');
|
||||
}
|
||||
|
||||
const skills = skillTitles.flatMap((title, index) => !!title ? [({
|
||||
title: title.toString(),
|
||||
description: skillDescriptions[index].toString()
|
||||
})] : []);
|
||||
const formData = await event.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');
|
||||
|
@ -49,10 +39,10 @@ export const actions = {
|
|||
|
||||
const surveyId = ids[0].id;
|
||||
for (const participant of participants) {
|
||||
await db.insert(surveyAccessTable).values({ surveyId, recepientEmail: participant, accessToken: generateRandomToken() });
|
||||
await addParticipant(surveyId, participant);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
|
|
@ -1,65 +1,10 @@
|
|||
<script lang="ts">
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
import SurveyEditForm from '$lib/components/SurveyEditForm.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
const { data }: { data: PageData } = $props();
|
||||
|
||||
let participants = $state(data?.participants?.length ?? 1);
|
||||
let skills = $state(data?.skills?.length ?? 1);
|
||||
</script>
|
||||
|
||||
<Navbar title="Create a new survey" />
|
||||
<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={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>
|
||||
<SurveyEditForm {...data} submitButtonTitle="Create" />
|
||||
|
|
Loading…
Add table
Reference in a new issue