mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(forms): M3a responses view + CSV export + detail modal
In-App-Antwort-Inspektion (docs/plans/forms-module.md M3, ohne
Server-Public-Submit — der landet zusammen mit M4 Visibility):
- ResponsesView mit Status-Tabs (Alle | Neu | Gesichtet | Archiviert |
Spam) + Live-Counts pro Tab. Tabelle: Eingegangen / Status-Chip /
Submitter (Name → Email → Anonym-Fallback) / Antwort-Snippet (max
60 Zeichen vom ersten non-section/consent-Feld). Klick auf Zeile
öffnet Detail-Modal.
- ResponseDetailModal als Overlay mit ESC-Close + Backdrop-Click,
Status-Pills oben, Submitter-Block, Antworten pro Feld in Form-
Reihenfolge, Section-Trenner, Required-Marker, Delete-Button.
- lib/csv.ts: pure buildResponsesCsv(form, responses) — Header
submittedAt/status/submitter/email + Form-Felder (section + consent
ausgenommen), Werte mit RFC-4180-Escape, UTF-8 BOM beim Download.
5/5 Vitest-Cases grün.
- BuilderView "Antworten ({n})"-Link mit responseCount-Counter.
- Route /forms/[id]/responses.
- 22 neue i18n-Keys × 5 Locales (forms.responses.* + .detail.*).
Public-Submit-Pfad (mana-api Endpoint + PublicFormView) bleibt offen
bis nach M4 Visibility.
Re-applied from notes-space-context branch (cherry 6e7972181,
forms-only subset).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f104a2bc35
commit
0ef71de008
11 changed files with 1088 additions and 1 deletions
|
|
@ -70,6 +70,36 @@
|
|||
"requireEmail": "E-Mail-Adresse vom Absender abfragen",
|
||||
"allowMultiple": "Mehrere Antworten pro Person erlauben",
|
||||
"anonymous": "Anonym — Submitter-Daten nicht speichern"
|
||||
},
|
||||
"viewResponses": "Antworten ({n})"
|
||||
},
|
||||
"responses": {
|
||||
"routeTitle": "Antworten",
|
||||
"back": "← Zum Builder",
|
||||
"title": "Antworten — {form}",
|
||||
"export": "CSV-Export",
|
||||
"exportTitle": "CSV herunterladen",
|
||||
"loading": "Lade Antworten ...",
|
||||
"emptyAll": "Noch keine Antworten.",
|
||||
"emptyHint": "Veröffentliche das Formular und teile den Link, um Antworten zu sammeln.",
|
||||
"emptyTab": "Keine Antworten in dieser Ansicht.",
|
||||
"anonymous": "Anonym",
|
||||
"tabs": {
|
||||
"all": "Alle"
|
||||
},
|
||||
"col": {
|
||||
"submittedAt": "Eingegangen",
|
||||
"status": "Status",
|
||||
"submitter": "Absender",
|
||||
"snippet": "Antwort"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Antwort",
|
||||
"closeAria": "Modal schließen",
|
||||
"deleteConfirm": "Diese Antwort wirklich löschen?",
|
||||
"delete": "Löschen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,36 @@
|
|||
"requireEmail": "Ask submitter for email address",
|
||||
"allowMultiple": "Allow multiple submissions per person",
|
||||
"anonymous": "Anonymous — don't store submitter data"
|
||||
},
|
||||
"viewResponses": "Responses ({n})"
|
||||
},
|
||||
"responses": {
|
||||
"routeTitle": "Responses",
|
||||
"back": "← To builder",
|
||||
"title": "Responses — {form}",
|
||||
"export": "Export CSV",
|
||||
"exportTitle": "Download CSV",
|
||||
"loading": "Loading responses ...",
|
||||
"emptyAll": "No responses yet.",
|
||||
"emptyHint": "Publish the form and share the link to start collecting responses.",
|
||||
"emptyTab": "No responses in this view.",
|
||||
"anonymous": "Anonymous",
|
||||
"tabs": {
|
||||
"all": "All"
|
||||
},
|
||||
"col": {
|
||||
"submittedAt": "Received",
|
||||
"status": "Status",
|
||||
"submitter": "Submitter",
|
||||
"snippet": "Answer"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Response",
|
||||
"closeAria": "Close modal",
|
||||
"deleteConfirm": "Really delete this response?",
|
||||
"delete": "Delete",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,36 @@
|
|||
"requireEmail": "Pedir correo electrónico al remitente",
|
||||
"allowMultiple": "Permitir varias respuestas por persona",
|
||||
"anonymous": "Anónimo — no guardar datos del remitente"
|
||||
},
|
||||
"viewResponses": "Respuestas ({n})"
|
||||
},
|
||||
"responses": {
|
||||
"routeTitle": "Respuestas",
|
||||
"back": "← Al constructor",
|
||||
"title": "Respuestas — {form}",
|
||||
"export": "Exportar CSV",
|
||||
"exportTitle": "Descargar CSV",
|
||||
"loading": "Cargando respuestas ...",
|
||||
"emptyAll": "Aún no hay respuestas.",
|
||||
"emptyHint": "Publica el formulario y comparte el enlace para empezar a recibir respuestas.",
|
||||
"emptyTab": "Sin respuestas en esta vista.",
|
||||
"anonymous": "Anónimo",
|
||||
"tabs": {
|
||||
"all": "Todas"
|
||||
},
|
||||
"col": {
|
||||
"submittedAt": "Recibida",
|
||||
"status": "Estado",
|
||||
"submitter": "Remitente",
|
||||
"snippet": "Respuesta"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Respuesta",
|
||||
"closeAria": "Cerrar ventana",
|
||||
"deleteConfirm": "¿Eliminar esta respuesta?",
|
||||
"delete": "Eliminar",
|
||||
"yes": "Sí",
|
||||
"no": "No"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,36 @@
|
|||
"requireEmail": "Demander l'adresse e-mail au destinataire",
|
||||
"allowMultiple": "Autoriser plusieurs réponses par personne",
|
||||
"anonymous": "Anonyme — ne pas conserver les données du destinataire"
|
||||
},
|
||||
"viewResponses": "Réponses ({n})"
|
||||
},
|
||||
"responses": {
|
||||
"routeTitle": "Réponses",
|
||||
"back": "← Vers le constructeur",
|
||||
"title": "Réponses — {form}",
|
||||
"export": "Export CSV",
|
||||
"exportTitle": "Télécharger en CSV",
|
||||
"loading": "Chargement des réponses ...",
|
||||
"emptyAll": "Pas encore de réponses.",
|
||||
"emptyHint": "Publie le formulaire et partage le lien pour collecter des réponses.",
|
||||
"emptyTab": "Aucune réponse dans cette vue.",
|
||||
"anonymous": "Anonyme",
|
||||
"tabs": {
|
||||
"all": "Toutes"
|
||||
},
|
||||
"col": {
|
||||
"submittedAt": "Reçue",
|
||||
"status": "Statut",
|
||||
"submitter": "Expéditeur",
|
||||
"snippet": "Réponse"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Réponse",
|
||||
"closeAria": "Fermer la fenêtre",
|
||||
"deleteConfirm": "Vraiment supprimer cette réponse ?",
|
||||
"delete": "Supprimer",
|
||||
"yes": "Oui",
|
||||
"no": "Non"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,36 @@
|
|||
"requireEmail": "Chiedi l'indirizzo e-mail al mittente",
|
||||
"allowMultiple": "Permetti più risposte per persona",
|
||||
"anonymous": "Anonimo — non memorizzare i dati del mittente"
|
||||
},
|
||||
"viewResponses": "Risposte ({n})"
|
||||
},
|
||||
"responses": {
|
||||
"routeTitle": "Risposte",
|
||||
"back": "← Al builder",
|
||||
"title": "Risposte — {form}",
|
||||
"export": "Esporta CSV",
|
||||
"exportTitle": "Scarica CSV",
|
||||
"loading": "Caricamento risposte ...",
|
||||
"emptyAll": "Ancora nessuna risposta.",
|
||||
"emptyHint": "Pubblica il modulo e condividi il link per iniziare a raccogliere risposte.",
|
||||
"emptyTab": "Nessuna risposta in questa vista.",
|
||||
"anonymous": "Anonimo",
|
||||
"tabs": {
|
||||
"all": "Tutte"
|
||||
},
|
||||
"col": {
|
||||
"submittedAt": "Ricevuta",
|
||||
"status": "Stato",
|
||||
"submitter": "Mittente",
|
||||
"snippet": "Risposta"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Risposta",
|
||||
"closeAria": "Chiudi finestra",
|
||||
"deleteConfirm": "Eliminare davvero questa risposta?",
|
||||
"delete": "Elimina",
|
||||
"yes": "Sì",
|
||||
"no": "No"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
<!--
|
||||
ResponseDetailModal — full answer view for one submission. Click-
|
||||
outside or ESC closes.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { responsesStore } from '../stores/responses.svelte';
|
||||
import { RESPONSE_STATUS_LABELS } from '../types';
|
||||
import type { Form, FormResponse, ResponseStatus, AnswerValue } from '../types';
|
||||
|
||||
let {
|
||||
form,
|
||||
response,
|
||||
onclose,
|
||||
}: {
|
||||
form: Form;
|
||||
response: FormResponse;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
|
||||
function formatAnswer(value: AnswerValue | undefined): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '—';
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
? $_('forms.responses.detail.yes', { default: 'Ja' })
|
||||
: $_('forms.responses.detail.no', { default: 'Nein' });
|
||||
}
|
||||
const str = String(value);
|
||||
return str.length > 0 ? str : '—';
|
||||
}
|
||||
|
||||
async function setStatus(status: ResponseStatus) {
|
||||
await responsesStore.setStatus(response.id, status);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = confirm(
|
||||
$_('forms.responses.detail.deleteConfirm', {
|
||||
default: 'Diese Antwort wirklich löschen?',
|
||||
})
|
||||
);
|
||||
if (!ok) return;
|
||||
await responsesStore.deleteResponse(response.id);
|
||||
onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="backdrop"
|
||||
onclick={onclose}
|
||||
aria-label={$_('forms.responses.detail.closeAria', { default: 'Modal schließen' })}
|
||||
></button>
|
||||
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<header class="modal-header">
|
||||
<div>
|
||||
<h2>{$_('forms.responses.detail.title', { default: 'Antwort' })}</h2>
|
||||
<p class="submitted-at">{response.submittedAt}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
onclick={onclose}
|
||||
aria-label={$_('forms.responses.detail.closeAria', { default: 'Modal schließen' })}>×</button
|
||||
>
|
||||
</header>
|
||||
|
||||
<div class="status-row">
|
||||
{#each Object.keys(RESPONSE_STATUS_LABELS) as st}
|
||||
<button
|
||||
type="button"
|
||||
class="status-pill"
|
||||
class:active={response.status === st}
|
||||
data-status={st}
|
||||
onclick={() => setStatus(st as ResponseStatus)}
|
||||
>
|
||||
{RESPONSE_STATUS_LABELS[st as ResponseStatus].de}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if response.submitterName || response.submitterEmail}
|
||||
<section class="submitter">
|
||||
<p class="submitter-line">
|
||||
{#if response.submitterName}
|
||||
<strong>{response.submitterName}</strong>
|
||||
{/if}
|
||||
{#if response.submitterEmail}
|
||||
<span class="email">{response.submitterEmail}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="answers">
|
||||
{#each form.fields as field (field.id)}
|
||||
{#if field.type !== 'section'}
|
||||
<div class="answer-row">
|
||||
<p class="field-label">
|
||||
{field.label}
|
||||
{#if field.required}<span class="required-mark">*</span>{/if}
|
||||
</p>
|
||||
<p class="answer-value">{formatAnswer(response.answers[field.id])}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<hr class="section-divider" />
|
||||
<p class="section-label">{field.label}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="delete" onclick={handleDelete}>
|
||||
{$_('forms.responses.detail.delete', { default: 'Löschen' })}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(720px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow: auto;
|
||||
background: rgb(15 15 18);
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
z-index: 51;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.submitted-at {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(255 255 255 / 0.6);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-pill:hover {
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.status-pill.active[data-status='new'] {
|
||||
background: rgb(20 184 166 / 0.18);
|
||||
color: rgb(94 234 212);
|
||||
border-color: rgb(20 184 166 / 0.4);
|
||||
}
|
||||
|
||||
.status-pill.active[data-status='reviewed'] {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
border-color: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
.status-pill.active[data-status='archived'] {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
.status-pill.active[data-status='spam'] {
|
||||
background: rgb(248 113 113 / 0.18);
|
||||
color: rgb(252 165 165);
|
||||
border-color: rgb(248 113 113 / 0.4);
|
||||
}
|
||||
|
||||
.submitter {
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
border: 1px solid rgb(255 255 255 / 0.06);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.submitter-line {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.email {
|
||||
color: rgb(255 255 255 / 0.6);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.answers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.answer-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: rgb(248 113 113);
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
.answer-value {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid rgb(255 255 255 / 0.06);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.delete {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete:hover {
|
||||
color: rgb(248 113 113);
|
||||
border-color: rgb(248 113 113 / 0.4);
|
||||
}
|
||||
</style>
|
||||
97
apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts
Normal file
97
apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { buildResponsesCsv } from './csv';
|
||||
import { DEFAULT_FORM_SETTINGS } from '../types';
|
||||
import type { Form, FormResponse } from '../types';
|
||||
|
||||
function makeForm(): Form {
|
||||
return {
|
||||
id: 'f1',
|
||||
title: 'Pulse Check',
|
||||
description: null,
|
||||
fields: [
|
||||
{ id: 'name', type: 'short_text', label: 'Name', required: true },
|
||||
{ id: 'mood', type: 'rating', label: 'Stimmung', required: false },
|
||||
{ id: 'tags', type: 'multi_choice', label: 'Themen', required: false },
|
||||
// Excluded from CSV: section + consent fields
|
||||
{ id: 'section1', type: 'section', label: 'Abschnitt', required: false },
|
||||
{ id: 'agree', type: 'consent', label: 'Einwilligung', required: true },
|
||||
],
|
||||
branching: [],
|
||||
status: 'published',
|
||||
settings: DEFAULT_FORM_SETTINGS,
|
||||
responseCount: 0,
|
||||
visibility: 'private',
|
||||
unlistedToken: '',
|
||||
unlistedExpiresAt: null,
|
||||
createdAt: '2026-04-28T10:00:00Z',
|
||||
updatedAt: '2026-04-28T10:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makeResponse(overrides: Partial<FormResponse> = {}): FormResponse {
|
||||
return {
|
||||
id: 'r1',
|
||||
formId: 'f1',
|
||||
submittedAt: '2026-04-28T12:00:00Z',
|
||||
answers: { name: 'Anna', mood: 4, tags: ['arbeit', 'familie'] },
|
||||
submitterEmail: 'anna@example.com',
|
||||
submitterName: 'Anna Mustermann',
|
||||
submitterMeta: null,
|
||||
status: 'new',
|
||||
syncedTargets: [],
|
||||
createdAt: '2026-04-28T12:00:00Z',
|
||||
updatedAt: '2026-04-28T12:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildResponsesCsv', () => {
|
||||
it('emits header row with submittedAt + status + submitter columns + form fields (sans section/consent)', () => {
|
||||
const csv = buildResponsesCsv(makeForm(), []);
|
||||
expect(csv).toBe('submittedAt,status,submitter,submitterEmail,Name,Stimmung,Themen');
|
||||
});
|
||||
|
||||
it('emits one data row per response with answers in field order', () => {
|
||||
const csv = buildResponsesCsv(makeForm(), [makeResponse()]);
|
||||
const lines = csv.split('\n');
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[1]).toBe(
|
||||
'2026-04-28T12:00:00Z,new,Anna Mustermann,anna@example.com,Anna,4,arbeit; familie'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes cells containing commas, quotes, or newlines per RFC-4180', () => {
|
||||
const r = makeResponse({
|
||||
answers: { name: 'Anna, "die Erste"\nZweiter Versuch', mood: 5, tags: [] },
|
||||
});
|
||||
const csv = buildResponsesCsv(makeForm(), [r]);
|
||||
const lines = csv.split('\n');
|
||||
// Comma-or-newline-or-quote → must be wrapped in double quotes,
|
||||
// internal quotes doubled. The newline keeps the row lenient
|
||||
// across the literal split('\n') above; we just check the cell.
|
||||
expect(csv).toContain('"Anna, ""die Erste""');
|
||||
});
|
||||
|
||||
it('renders empty answer as empty string, boolean as yes/no, array joined with semicolons', () => {
|
||||
const f: Form = {
|
||||
...makeForm(),
|
||||
fields: [
|
||||
{ id: 'a', type: 'short_text', label: 'A', required: false },
|
||||
{ id: 'b', type: 'yes_no', label: 'B', required: false },
|
||||
{ id: 'c', type: 'multi_choice', label: 'C', required: false },
|
||||
],
|
||||
};
|
||||
const r = makeResponse({ answers: { b: true, c: ['x', 'y'] } });
|
||||
const csv = buildResponsesCsv(f, [r]);
|
||||
const lines = csv.split('\n');
|
||||
expect(lines[1]).toBe('2026-04-28T12:00:00Z,new,Anna Mustermann,anna@example.com,,yes,x; y');
|
||||
});
|
||||
|
||||
it('omits submitter columns when response has anonymous submitter (null name + null email)', () => {
|
||||
const r = makeResponse({ submitterName: null, submitterEmail: null });
|
||||
const csv = buildResponsesCsv(makeForm(), [r]);
|
||||
const lines = csv.split('\n');
|
||||
// Empty cells, not "null" string
|
||||
expect(lines[1]).toContain(',,,Anna,4,');
|
||||
});
|
||||
});
|
||||
73
apps/mana/apps/web/src/lib/modules/forms/lib/csv.ts
Normal file
73
apps/mana/apps/web/src/lib/modules/forms/lib/csv.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { AnswerValue, Form, FormResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Build a CSV blob from an array of responses against the parent form's
|
||||
* field definitions. Pure function — no DOM, no Dexie. The parent UI
|
||||
* is responsible for triggering the download.
|
||||
*
|
||||
* Column order: submittedAt, status, submitter (name + email if not
|
||||
* anonymous), then one column per field in field-order. Section /
|
||||
* consent fields are excluded from columns (no answer data).
|
||||
*/
|
||||
export function buildResponsesCsv(form: Form, responses: FormResponse[]): string {
|
||||
const fields = form.fields.filter((f) => f.type !== 'section' && f.type !== 'consent');
|
||||
const headers = [
|
||||
'submittedAt',
|
||||
'status',
|
||||
'submitter',
|
||||
'submitterEmail',
|
||||
...fields.map((f) => f.label || f.id),
|
||||
];
|
||||
|
||||
const rows = responses.map((r) => {
|
||||
return [
|
||||
r.submittedAt,
|
||||
r.status,
|
||||
r.submitterName ?? '',
|
||||
r.submitterEmail ?? '',
|
||||
...fields.map((f) => formatAnswer(r.answers[f.id])),
|
||||
];
|
||||
});
|
||||
|
||||
const lines = [headers, ...rows].map((row) => row.map(escapeCsvCell).join(','));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatAnswer(value: AnswerValue | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (Array.isArray(value)) return value.join('; ');
|
||||
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-4180 compliant escape: wrap in quotes if the cell contains
|
||||
* comma, quote, CR or LF; double up internal quotes.
|
||||
*/
|
||||
function escapeCsvCell(value: string): string {
|
||||
const str = String(value);
|
||||
if (/[",\r\n]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-only convenience wrapper. Triggers a download of the CSV
|
||||
* blob with a filename derived from the form title + today's date.
|
||||
*/
|
||||
export function downloadResponsesCsv(form: Form, responses: FormResponse[]): void {
|
||||
const csv = buildResponsesCsv(form, responses);
|
||||
// Prepend BOM so Excel auto-detects UTF-8.
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const safeTitle = form.title.replace(/[^a-zA-Z0-9-_]+/g, '-').slice(0, 40) || 'form';
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${safeTitle}-responses-${today}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
@ -149,6 +149,13 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<a class="responses-link" href="/forms/{entry.id}/responses">
|
||||
{$_('forms.builder.viewResponses', {
|
||||
default: 'Antworten ({n})',
|
||||
values: { n: entry.responseCount },
|
||||
})}
|
||||
</a>
|
||||
|
||||
<button type="button" class="delete" onclick={deleteForm}>
|
||||
{$_('forms.builder.delete', { default: 'Löschen' })}
|
||||
</button>
|
||||
|
|
@ -287,8 +294,22 @@
|
|||
border-color: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
.delete {
|
||||
.responses-link {
|
||||
margin-left: auto;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.responses-link:hover {
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.delete {
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,375 @@
|
|||
<!--
|
||||
Forms — ResponsesView (M3)
|
||||
|
||||
Tabular display of every response for one form. Header row with
|
||||
status filter + CSV export + count. Click a row → open detail modal.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { useFormResponses } from '../queries';
|
||||
import { downloadResponsesCsv } from '../lib/csv';
|
||||
import { RESPONSE_STATUS_LABELS } from '../types';
|
||||
import type { Form, FormResponse, ResponseStatus } from '../types';
|
||||
import ResponseDetailModal from '../components/ResponseDetailModal.svelte';
|
||||
|
||||
let { form }: { form: Form } = $props();
|
||||
|
||||
const responses$ = useFormResponses(form.id);
|
||||
const responses = $derived(responses$.value);
|
||||
|
||||
type FilterTab = 'all' | ResponseStatus;
|
||||
let activeTab = $state<FilterTab>('all');
|
||||
|
||||
const counts = $derived({
|
||||
all: responses.length,
|
||||
new: responses.filter((r) => r.status === 'new').length,
|
||||
reviewed: responses.filter((r) => r.status === 'reviewed').length,
|
||||
archived: responses.filter((r) => r.status === 'archived').length,
|
||||
spam: responses.filter((r) => r.status === 'spam').length,
|
||||
});
|
||||
|
||||
const filtered = $derived(
|
||||
activeTab === 'all' ? responses : responses.filter((r) => r.status === activeTab)
|
||||
);
|
||||
|
||||
let detailResponseId = $state<string | null>(null);
|
||||
const detailResponse = $derived(
|
||||
detailResponseId ? responses.find((r) => r.id === detailResponseId) : null
|
||||
);
|
||||
|
||||
function openDetail(r: FormResponse) {
|
||||
detailResponseId = r.id;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailResponseId = null;
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
downloadResponsesCsv(form, filtered);
|
||||
}
|
||||
|
||||
function answerSnippet(r: FormResponse): string {
|
||||
const firstField = form.fields.find((f) => f.type !== 'section' && f.type !== 'consent');
|
||||
if (!firstField) return '';
|
||||
const v = r.answers[firstField.id];
|
||||
if (v === null || v === undefined) return '';
|
||||
const str = Array.isArray(v) ? v.join(', ') : String(v);
|
||||
return str.length > 60 ? str.slice(0, 57) + '...' : str;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="responses-view">
|
||||
<header class="top-bar">
|
||||
<button type="button" class="back" onclick={() => goto(`/forms/${form.id}`)}>
|
||||
{$_('forms.responses.back', { default: '← Zum Builder' })}
|
||||
</button>
|
||||
<h1 class="title">
|
||||
{$_('forms.responses.title', {
|
||||
default: 'Antworten — {form}',
|
||||
values: { form: form.title },
|
||||
})}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="export"
|
||||
onclick={handleExport}
|
||||
disabled={filtered.length === 0}
|
||||
title={$_('forms.responses.exportTitle', { default: 'CSV herunterladen' })}
|
||||
>
|
||||
{$_('forms.responses.export', { default: 'CSV-Export' })}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" role="tablist">
|
||||
{#each [['all', counts.all], ['new', counts.new], ['reviewed', counts.reviewed], ['archived', counts.archived], ['spam', counts.spam]] as const as [tab, count]}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
class="tab"
|
||||
class:active={activeTab === tab}
|
||||
onclick={() => (activeTab = tab as FilterTab)}
|
||||
>
|
||||
<span class="tab-label">
|
||||
{tab === 'all'
|
||||
? $_('forms.responses.tabs.all', { default: 'Alle' })
|
||||
: RESPONSE_STATUS_LABELS[tab as ResponseStatus].de}
|
||||
</span>
|
||||
<span class="tab-count">{count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if responses$.loading}
|
||||
<p class="state">{$_('forms.responses.loading', { default: 'Lade Antworten ...' })}</p>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
{#if activeTab === 'all'}
|
||||
<p class="empty-title">
|
||||
{$_('forms.responses.emptyAll', { default: 'Noch keine Antworten.' })}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{$_('forms.responses.emptyHint', {
|
||||
default: 'Veröffentliche das Formular und teile den Link, um Antworten zu sammeln.',
|
||||
})}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="empty-title">
|
||||
{$_('forms.responses.emptyTab', {
|
||||
default: 'Keine Antworten in dieser Ansicht.',
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<table class="response-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$_('forms.responses.col.submittedAt', { default: 'Eingegangen' })}</th>
|
||||
<th>{$_('forms.responses.col.status', { default: 'Status' })}</th>
|
||||
<th>{$_('forms.responses.col.submitter', { default: 'Absender' })}</th>
|
||||
<th>{$_('forms.responses.col.snippet', { default: 'Antwort' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as r (r.id)}
|
||||
<tr onclick={() => openDetail(r)} role="button" tabindex="0">
|
||||
<td class="time">{formatTime(r.submittedAt)}</td>
|
||||
<td>
|
||||
<span class="status-chip" data-status={r.status}>
|
||||
{RESPONSE_STATUS_LABELS[r.status].de}
|
||||
</span>
|
||||
</td>
|
||||
<td class="submitter">
|
||||
{#if r.submitterName}
|
||||
{r.submitterName}
|
||||
{:else if r.submitterEmail}
|
||||
{r.submitterEmail}
|
||||
{:else}
|
||||
<span class="muted">{$_('forms.responses.anonymous', { default: 'Anonym' })}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="snippet">{answerSnippet(r)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if detailResponse}
|
||||
<ResponseDetailModal {form} response={detailResponse} onclose={closeDetail} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.responses-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.back {
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.export {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: rgb(20 184 166 / 0.12);
|
||||
border: 1px solid rgb(20 184 166 / 0.3);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(94 234 212);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export:hover {
|
||||
background: rgb(20 184 166 / 0.2);
|
||||
}
|
||||
|
||||
.export:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgb(20 184 166 / 0.16);
|
||||
color: rgb(94 234 212);
|
||||
border-color: rgb(20 184 166 / 0.4);
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
min-width: 1rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 0.9375rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.response-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.response-table th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
.response-table td {
|
||||
padding: 0.625rem;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
.response-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.response-table tbody tr:hover {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgb(255 255 255 / 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-chip[data-status='new'] {
|
||||
background: rgb(20 184 166 / 0.18);
|
||||
color: rgb(94 234 212);
|
||||
}
|
||||
|
||||
.status-chip[data-status='reviewed'] {
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
}
|
||||
|
||||
.status-chip[data-status='archived'] {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
}
|
||||
|
||||
.status-chip[data-status='spam'] {
|
||||
background: rgb(248 113 113 / 0.18);
|
||||
color: rgb(252 165 165);
|
||||
}
|
||||
|
||||
.submitter {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgb(255 255 255 / 0.35);
|
||||
}
|
||||
|
||||
.snippet {
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import ResponsesView from '$lib/modules/forms/views/ResponsesView.svelte';
|
||||
import { useAllForms } from '$lib/modules/forms/queries';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
const forms$ = useAllForms();
|
||||
const form = $derived(forms$.value.find((f) => f.id === page.params.id));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('forms.responses.routeTitle', { default: 'Antworten' })} - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage
|
||||
appId="forms"
|
||||
backHref={`/forms/${page.params.id}`}
|
||||
title={$_('forms.responses.routeTitle', { default: 'Antworten' })}
|
||||
>
|
||||
{#if forms$.loading}
|
||||
<p class="state">{$_('forms.builder.loading', { default: 'Lade ...' })}</p>
|
||||
{:else if !form}
|
||||
<div class="state">
|
||||
<p>{$_('forms.builder.notFound', { default: 'Formular nicht gefunden.' })}</p>
|
||||
<a href="/forms">{$_('forms.builder.backLink', { default: '← Zurück zur Liste' })}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<ResponsesView {form} />
|
||||
{/if}
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.state a {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: rgb(94 234 212);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue