setup questionnaires vue app, add code from question editor proof of concept

This commit is contained in:
Mira Weller
2026-03-19 13:33:24 +01:00
parent 4bbcc398cc
commit 81775f5d2d
10 changed files with 307 additions and 1 deletions

View File

@@ -0,0 +1,27 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load static %}
{% load compress %}
{% load vite %}
{% block title %}
{% trans "Questionnaires" %}
{% endblock %}
{% block inside %}
<h1>{% trans "Questionnaires" %}</h1>
<p>
{% blocktrans trimmed %}
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
example might be to ask your users about dietary requirements.
{% endblocktrans %}
</p>
<div id="questionnaires-editor">
<!-- Vue app mount point -->
</div>
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts" %}
{% endblock %}

View File

@@ -348,6 +348,7 @@ urlpatterns = [
re_path(r'^questions/(?P<question>\d+)/change$', item.QuestionUpdate.as_view(),
name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^questionnaires/$', item.QuestionnairesEditor.as_view(), name='event.items.questionnaires'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'),
re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'),

View File

@@ -55,7 +55,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import ListView
from django.views.generic import ListView, TemplateView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
@@ -831,6 +831,11 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/items/questionnaires.html'
class QuotaList(PaginationMixin, ListView):
model = Quota
context_object_name = 'quotas'

View File

@@ -0,0 +1,74 @@
<script>
import Question from './Question.vue';
import {get_questions, get_items} from './api';
import { i18n_any, QUESTION_TYPE } from './helper';
import { ref } from 'vue';
const questions_response = await get_questions();
const items_response = await get_items();
const questions = ref(questions_response.results);
export default {
components: {
Question
},
methods: {
i18n_any,
addQuestion: function() {
questions.value.push({
items: [], question: {en:"Untitled question"},
type: QUESTION_TYPE.TEXT, help_text: {en:"Help text"},
})
console.log(questions.value)
}
},
data() {
return {
questions,
items: items_response.results,
selected_product: ref(""),
}
}
}
</script>
<style>
.hidden-question { opacity: 0.3; }
.hidden-question label { text-decoration: line-through; }
.question-editor { margin-right: 140px; }
.question-edit-buttons { float:right }
.question-edit-buttons div { position: absolute; margin-left: 10px; }
.question-edit-buttons button { }
.form-group { margin-bottom: 30px }
</style>
<template>
<p>
<button class="btn btn-default" @click="addQuestion()"><i class="fa fa-plus"></i> Neue Frage erstellen</button>
</p>
<div class="panel panel-default question-editor">
<div class="panel-heading">
Edit questions for product:
<select v-model="selected_product">
<option value="">(all)</option>
<option v-for="item in items" :value="item.id">
{{ i18n_any(item.name) }}
</option>
</select>
</div>
<div class="panel-body">
<div class="form-horizontal">
<Question
v-for="question in questions"
:question="question"
:selected_product="selected_product" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { ref, useId, defineProps } from 'vue';
defineProps(['value', 'id'])
</script>
<template>
<div class="i18n-form-group" :id="id">
<textarea cols="40" rows="2" lang="en" dir="ltr" class="form-control" title="Englisch" :id="`${id}_0`" placeholder="Englisch" v-model="value.en"></textarea>
<textarea cols="40" rows="2" lang="de" dir="ltr" class="form-control" title="Deutsch" :id="`${id}_1`" placeholder="Deutsch" v-model="value.de"></textarea>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref, useId } from 'vue';
const dialog = ref<HTMLDialogElement>();
const props = defineProps({
classes: {
type: String,
default: "",
},
title: '',
});
const visible = ref(false);
const showModal = () => {
dialog.value?.showModal();
visible.value = true;
};
defineExpose({
show: showModal,
close: (returnVal?: string): void => dialog.value?.close(returnVal),
visible,
});
const id = useId();
</script>
<template>
<dialog
ref="dialog" class="modal-card"
@close="visible = false"
closedby="any"
:aria-labelledby="`${id}-title`"
>
<form
v-if="visible"
method="dialog" class="modal-card-inner form-horizontal"
:class="{
[props.classes]: props.classes,
}"
>
<div class="modal-card-content">
<h2 :id="`${id}-title`" class="modal-card-title h3">{{ title }}</h2>
<slot />
</div>
</form>
</dialog>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL } from './helper';
import NativeDialog from './NativeDialog.vue';
import I18nTextField from './I18nTextField.vue';
import { useId, ref } from 'vue'
const id = useId();
const props = defineProps(['question', 'selected_product'])
function toggleItem() {
const i = props.question.items.indexOf(props.selected_product);
if (i === -1) {
props.question.items.push(props.selected_product);
} else {
props.question.items.splice(i, 1);
}
}
const editor = ref();
</script>
<template>
<div class="form-group"
:class="{ 'hidden-question': selected_product && question.items.indexOf(selected_product) === -1 }">
<div class="question-edit-buttons"><div>
<button class="btn btn-default"><i class="fa fa-arrows"></i></button>
<button class="btn btn-default" @click="editor.show()"><i class="fa fa-edit"></i></button>
<button class="btn btn-default" @click="toggleItem()" v-if="selected_product"><i :class="`fa fa-eye${(question.items.indexOf(selected_product) === -1) ? '-slash':''}`"></i></button>
</div></div>
<label class="col-md-3 control-label" :for="id" v-if="question.type != QUESTION_TYPE.BOOLEAN">
{{ i18n_any(question.question) }}
</label>
<div v-else class="col-md-3 control-label label-empty"></div>
<div class="col-md-9">
<input :id="id" type="text" v-if="question.type == QUESTION_TYPE.TEXT" class="form-control">
<div class="checkbox" v-if="question.type == QUESTION_TYPE.BOOLEAN">
<label :for="id">
<input :id="id" type="checkbox"> {{ i18n_any(question.question) }}
</label>
</div>
<input :id="id" type="number" v-if="question.type == QUESTION_TYPE.NUMBER" class="form-control">
<input :id="id" type="file" v-if="question.type == QUESTION_TYPE.FILE" class="form-control">
<select :id="id"
v-if="question.type == QUESTION_TYPE.CHOICE || question.type == QUESTION_TYPE.CHOICE_MULTIPLE"
:multiple="question.type == QUESTION_TYPE.CHOICE_MULTIPLE" class="form-control">
<option></option>
<option v-for="opt in question.options">{{ i18n_any(opt.answer) }}</option>
</select>
<div class="help-block">{{ i18n_any(question.help_text) }}</div>
</div>
</div>
<Teleport to="body">
<NativeDialog ref="editor" class="modal-card"
title="Edit question">
<div class="form-group">
<label class="col-md-3 control-label">
Question
</label>
<div class="col-md-9">
<I18nTextField :value="question.question"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
Question type
</label>
<div class="col-md-9">
<select v-model="question.type" class="form-control">
<option v-for="(label, type) in QUESTION_TYPE_LABEL" :value="QUESTION_TYPE[type]">{{ label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
Help text
</label>
<div class="col-md-9">
<I18nTextField :value="question.help_text"/>
<div class="help-block">Wenn diese Frage noch weitere Erklärung braucht, können Sie sie hier eintragen.</div>
</div>
</div>
<button @click="editor.close()">Close</button>
</NativeDialog>
</Teleport>
</template>

View File

@@ -0,0 +1,10 @@
const organizer_slug = document.body.getAttribute('data-organizer'),
event_slug = document.body.getAttribute('data-event');
export async function get_questions() {
return await (await fetch(`/api/v1/organizers/${organizer_slug}/events/${event_slug}/questions`)).json();
}
export async function get_items() {
return await (await fetch(`/api/v1/organizers/${organizer_slug}/events/${event_slug}/items`)).json();
}

View File

@@ -0,0 +1,37 @@
export function i18n_any(data) {
return Object.values(data)[0];
}
export const QUESTION_TYPE = {
NUMBER: "N",
STRING: "S",
TEXT: "T",
BOOLEAN: "B",
CHOICE: "C",
CHOICE_MULTIPLE: "M",
FILE: "F",
DATE: "D",
TIME: "H",
DATETIME: "W",
COUNTRYCODE: "CC",
PHONENUMBER: "TEL",
};
const _ = x => x;
export const QUESTION_TYPE_LABEL = {
NUMBER: _("Number"),
STRING: _("Text (one line)"),
TEXT: _("Multiline text"),
BOOLEAN: _("Yes/No"),
CHOICE: _("Choose one from a list"),
CHOICE_MULTIPLE: _("Choose multiple from a list"),
FILE: _("File upload"),
DATE: _("Date"),
TIME: _("Time"),
DATETIME: _("Date and time"),
COUNTRYCODE: _("Country code (ISO 3166-1 alpha-2)"),
PHONENUMBER: _("Phone number"),
};

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#questionnaires-editor')