This commit is contained in:
Kara Engelhardt
2026-04-08 17:03:26 +02:00
parent 66882eb115
commit 477b1e42d4
13 changed files with 292 additions and 96 deletions

View File

View File

@@ -1,37 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import StyleSettings from './style-settings.vue' import StyleSettings from './style-settings.vue'
import Select from './input/select.vue'
import Input from './input/input.vue'
const STYLES = JSON.parse(document.querySelector('#styles')?.textContent ?? '{}') const gettext = (window as any).gettext
const VARIABLES = JSON.parse(document.querySelector('#variables')?.textContent ?? '{}')
const FORM_ERRORS = JSON.parse(document.querySelector('#form_errors')?.textContent ?? '{}')
const FORM_DATA = JSON.parse(document.querySelector('#form_data')?.textContent ?? '{}')
const style = ref<string | null>(FORM_DATA.style ?? null) // TODO: Move to store?
const name = ref<string>(FORM_DATA.name ?? '') const STYLES: Styles = JSON.parse(document.querySelector('#styles')?.textContent ?? '{}')
const layout = ref(JSON.parse(FORM_DATA.layout ?? '{}') ?? {}) const VARIABLES: VariableConfig = JSON.parse(document.querySelector('#variables')?.textContent ?? '{}')
const FORM_ERRORS: Record<string, Array<string>> = JSON.parse(document.querySelector('#form_errors')?.textContent ?? '{}')
const LAYOUT: Layout = JSON.parse(document.querySelector('#layout')?.textContent ?? '{}')
const name = ref<string>(LAYOUT.name ?? '')
const style = ref<string | null>(LAYOUT.style ?? null)
const layout = ref<LayoutData>(LAYOUT.layout ?? {fields: {}})
</script> </script>
<template lang="pug"> <template lang="pug">
// TODO: add :key for all `v-for`s // TODO: add :key for all `v-for`s
//- pre // TODO: i18n
//- code {{ STYLES }} details
pre
code {{ FORM_ERRORS }}
.row .row
.col-md-8 .col-md-8
// TODO: show error text
.form-group(:class='"name" in FORM_ERRORS ? "has-error" : ""') .form-group(:class='"name" in FORM_ERRORS ? "has-error" : ""')
label.control-label(for="layout-info-name") Name Input(label="Name" v-model="name" name="name" :errors="FORM_ERRORS['name']")
input#layout-info-name.form-control(v-model="name" name="name")
.form-group(:class='"style" in FORM_ERRORS ? "has-error" : ""') .form-group(:class='"style" in FORM_ERRORS ? "has-error" : ""')
label.control-label(for="layout-info-style") Style Select(label="Style" v-model="style" :choices="Object.values(STYLES).map(x => [x.identifier, x.name])" name="style" :errors="FORM_ERRORS['style']")
select#layout-info-style.form-control(v-model="style" name="style")
option(v-for="styleconfig in STYLES" :key="styleconfig.identifier" :value="styleconfig.identifier") {{ styleconfig.name }}
StyleSettings(v-model="layout" :style="style" :styles="STYLES" :variables="VARIABLES") StyleSettings(v-if="style" v-model="layout" :style="style" :styles="STYLES" :variables="VARIABLES")
.col-md-4 .col-md-4
.panel.panel-default .panel.panel-default
.panel-heading Preview .panel-heading Preview
// TODO: i18n
.panel-body .panel-body
// TODO: Preview // TODO: Preview
pre pre

View File

@@ -1,55 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, reactive } from 'vue'
import OptionalSelect from './optional-select.vue' import Select from './input/select.vue'
import Input from './input/input.vue'
import TextContent from './text-content.vue'
const gettext = (window as any).gettext
const props = defineProps<{ const props = defineProps<{
field: any // TODO field: FieldGroupDefinition
overflows: [string, string][], overflows: FieldGroupDefinition[],
variables: any // TODO variables: Variables
}>() }>()
const fieldConfig = defineModel<any>({ required: true }) const fieldConfig = defineModel<FieldConfig>({ required: true })
const overflowOptions = computed(() => { const overflowOptions = computed((): Array<[string|null, string]> => {
if (props.overflows.length) { if (props.overflows.length) {
return [...props.overflows.map(x => [x.identifier, x.name]), [null, "Do not overflow"]] return [...props.overflows.map((x): [string, string] => [x.identifier, x.name]), [null, "Do not overflow"]]
} else { } else {
return [] return []
} }
}) })
function addVariable() { function addVariable() {
fieldConfig.value.entries.push({"label": null, "value": null}) fieldConfig.value.entries.push({"type": "placeholder", "label": null, "content": null})
} }
</script> </script>
<template lang="pug"> <template lang="pug">
//- pre
//- code {{ props }}
.panel.panel-default .panel.panel-default
.panel-heading .panel-heading
h3.panel-title {{ field.name }} h3.panel-title {{ field.name }}
.panel-body .panel-body
.form-group(v-if="props.variables[field.entry_type]") .form-group
span.text-muted These fields appear somewhere and are visible too. span.text-muted These fields appear somewhere and are visible too.
// TODO: for="..." / labeledby? h4 {{ gettext("Content") }}
h4 Fields
.row.form-group(v-for="n in fieldConfig.entries.length") .row.form-group(v-for="n in fieldConfig.entries.length")
.col-md-5 .col-md-5
// TODO: i18n Input(:label="gettext('Label')" v-model="fieldConfig.entries[n-1].label")
label.control-label Label .col-md-6(v-if='field.entry_type == "text"')
input.form-control(v-model="fieldConfig.entries[n-1].label") TextContent(v-model="fieldConfig.entries[n-1]"
.col-md-6 :variables="props.variables")
label.control-label Value .col-md-6(v-else-if='field.entry_type == "image"')
select.form-control(v-model="fieldConfig.entries[n-1].value") Select(:label="gettext('Content')"
option(v-for="(config, id) in props.variables[field.entry_type]" :key="id" :value="id") {{ config.label }} v-model="fieldConfig.entries[n-1].content"
:choices="Object.entries(props.variables).map(([k,v]) => [k, v.label])"
)
.col-md-1 .col-md-1
label.control-label &nbsp; label.control-label &nbsp;
span.sr-only "Delete" span.sr-only {{ gettext('Delete')}}
button.btn.btn-danger(type="button" @click="fieldConfig.entries.splice(n-1, 1)") button.btn.btn-danger(type="button" @click="fieldConfig.entries.splice(n-1, 1)")
i.fa.fa-trash i.fa.fa-trash
span.sr-only "Delete" span.sr-only {{ gettext('Delete')}}
button.btn.btn-default(type="button" @click="addVariable") button.btn.btn-default(type="button" @click="addVariable")
i.fa.fa-plus i.fa.fa-plus
span.sr-only Add field span.sr-only {{ gettext("Add field") }}
OptionalSelect(label="Overflow to ..." :choices="overflowOptions" v-model="fieldConfig.overflow") Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
</template> </template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string,
errors?: string[],
}>()
const modelValue = defineModel<string|null>();
const id = useId()
</script>
<template lang="pug">
label.control-label(:for="id", v-if="props.label") {{ props.label }}
input.form-control(:id="id" v-model="modelValue" v-bind="$attrs")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useId, watchEffect } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label: string
choices: Array<[string, string]>
errors?: string[]
}>()
const modelValue = defineModel<string|null>();
const id = useId()
watchEffect(() => {
if (props.choices.length === 1) {
modelValue.value = props.choices[0][0]
} else if (props.choices.length < 1) {
modelValue.value = null
}
})
</script>
<template lang="pug">
template(v-if="choices.length >= 1")
label.control-label(:for="id") {{ props.label }}
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs")
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import { useId } from 'vue'
const props = defineProps<{
label: string
choices: [string, string][]
}>()
const modelValue = defineModel<string|null>();
const id = useId()
</script>
<template lang="pug">
template(v-if="choices.length >= 1")
label(:for="id") {{ props.label }}
select.form-control(:id="id" v-model="modelValue")
// TODO: persist/v-model
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
</template>

View File

@@ -1,45 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watchEffect } from 'vue' import { computed, watchEffect } from "vue";
import OptionalSelect from './optional-select.vue' import FieldSettings from "./field-settings.vue";
import FieldSettings from './field-settings.vue'
const gettext = (window as any).gettext;
const props = defineProps<{ const props = defineProps<{
styles: any, // TODO styles: Styles;
variables: any, // TODO variables: VariableConfig
style?: string, style?: string;
}>() }>();
const layout = defineModel() const layout = defineModel<LayoutData>();
const styleData = computed(() => { const styleData = computed(() => {
if (!props.style || !(props.style in props.styles)) { if (!props.style || !(props.style in props.styles)) {
return null return null;
} }
return props.styles[props.style] return props.styles[props.style];
}) });
watchEffect(() => { watchEffect(() => {
// TODO: this seems wrooong // TODO: this seems wrooong
if (!('fields' in layout.value)) { if (!("fields" in layout.value)) {
layout.value.fields = {} layout.value.fields = {};
} }
if (props.style) { if (props.style) {
for (const field of props.styles[props.style].fields) { for (const field of props.styles[props.style].fields) {
if (!(field.identifier in layout.value.fields)) { if (!(field.identifier in layout.value.fields)) {
layout.value.fields[field.identifier] = {entries: JSON.parse(JSON.stringify(field.default_entries)), overflow: null} layout.value.fields[field.identifier] = {
entries: JSON.parse(JSON.stringify(field.default_entries)),
overflow: null,
};
} }
} }
} }
}) });
</script> </script>
<template lang="pug"> <template lang="pug">
h2.h3 Form Fields h2.h3 {{ gettext("Field Groups") }}
FieldSettings(v-if="styleData" FieldSettings(v-if="styleData"
v-for="(field, fieldId) in styleData.fields" v-for="(field, fieldId) in styleData.fields"
v-model="layout.fields[field.identifier]" v-model="layout.fields[field.identifier]"
:field="field" :field="field"
:overflows="styleData.fields.slice(fieldId + 1).filter(x => x.entry_type === field.entry_type)" :overflows="styleData.fields.slice(fieldId + 1).filter(x => x.entry_type === field.entry_type)"
:variables="variables" :variables="variables[field.entry_type]"
) )
</template> </template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import Select from './input/select.vue'
import Input from './input/input.vue'
const gettext = (window as any).gettext
const props = defineProps<{
variables: Variables
}>()
const entry = defineModel<FieldEntry>({ required: true })
const selectChoices = computed(() =>{
const choices = Object.entries(props.variables).map(([k,v]): [string, string] => [k, v.label])
choices.push(["other", gettext("Other…")])
return choices
});
const selection = computed({
get() {
if (entry.value.type === 'placeholder') {
return entry.value.content
} else if (entry.value.type === 'text') {
return "other"
} else {
throw new Error(`Unknown entry type "${entry.value.type}"`);
}
},
set(newValue) {
if (newValue == "other") {
entry.value.type = "text"
entry.value.content = "";
} else {
entry.value.type = "placeholder"
entry.value.content = newValue
}
}
})
const textContent = computed({
get() {
if (entry.value.type === 'placeholder') {
return ""
} else if (entry.value.type === 'text') {
return entry.value.content
} else {
throw new Error(`Unknown entry type "${entry.value.type}"`);
}
},
set(newValue) {
entry.value.content = newValue
}
})
</script>
<template lang="pug">
Select(:label="gettext('Content')"
v-model="selection"
:choices="selectChoices"
)
Input(v-model="textContent" v-if="selection === 'other'")
</template>

View File

@@ -0,0 +1,41 @@
type FieldGroupDefinition = {
identifier: string;
entry_type: string;
name: string;
default_entries: FieldConfig[];
};
type Style = {
identifier: string;
name: string;
fields: FieldGroupDefinition[];
};
type Variable = {
label: string
};
type Styles = Record<string, Style>;
type Variables = Record<string, Variable>;
type VariableConfig = Record<string, Variables>;
type FieldEntry = {
type: 'placeholder' | 'text';
label: string; // TODO i18n
content: string;
}
type FieldConfig = {
entries: Array<FieldEntry>;
overflow: string | null;
};
type LayoutData = {
fields?: Record<string, FieldConfig>;
};
type Layout = {
name?: string;
style?: string;
layout?: LayoutData;
};

View File

@@ -1,8 +1,9 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './components/app.vue' import App from './components/app.vue'
const app = createApp(App) const mountEl = document.querySelector<HTMLElement>('#editor')!
app.mount('#editor') const app = createApp(App, mountEl.dataset)
app.mount(mountEl)
app.config.errorHandler = (error, _vm, info) => { app.config.errorHandler = (error, _vm, info) => {
// vue fatals on errors by default, which is a weird choice // vue fatals on errors by default, which is a weird choice

View File

@@ -4,6 +4,8 @@ import enum
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.pdf import get_images, get_variables
from .models import WalletLayout from .models import WalletLayout
@@ -34,10 +36,10 @@ class PlaceholderFieldType(enum.Enum):
class PlaceholderField: class PlaceholderField:
type: PlaceholderFieldType type: PlaceholderFieldType
label: LazyI18nString label: LazyI18nString
value: str content: str
def asdict(self): def asdict(self):
return {"type": self.type.value, "label": self.label.data, "value": self.value} return {"type": self.type.value, "label": self.label.data, "value": self.content}
@dataclass @dataclass
@@ -178,10 +180,12 @@ class PassLayout:
self.style = style self.style = style
self.layout = layout self.layout = layout
def validate(self): def validate(self, event):
self.validate_fields() self.validate_fields(event)
def validate_fields(self, event):
placeholders = {"text": get_variables(event), "image": get_images(event)}
def validate_fields(self):
style_fields = self.style.fields style_fields = self.style.fields
if "fields" not in self.layout: if "fields" not in self.layout:
raise ValidationError(_("Layout did not contain any fields")) raise ValidationError(_("Layout did not contain any fields"))
@@ -192,10 +196,16 @@ class PassLayout:
for fieldgroup in style_fields: for fieldgroup in style_fields:
layout_field_data = layout_fields.get(fieldgroup.identifier, {}) layout_field_data = layout_fields.get(fieldgroup.identifier, {})
if fieldgroup.min_entries and fieldgroup.min_entries < len( if fieldgroup.min_entries and fieldgroup.min_entries < len(
layout_field_data.get('entries') layout_field_data.get('entries', [])
): ):
raise ValidationError( raise ValidationError(
_("At least {min_entries} must be specified for {name}").format( _("At least {min_entries} must be specified for {name}").format(
min_entries=fieldgroup.min_entries, name=fieldgroup.name min_entries=fieldgroup.min_entries, name=fieldgroup.name
) )
) )
# TODO: move field validation to json schema
for entry in layout_field_data.get('entries', []):
if entry['type'] not in ('placeholder', fieldgroup.entry_type.value):
raise ValidationError(_("Placeholder of wrong type \"{type}\" in {name}").format(type=entry['type'], name="fieldgroup.name"))
if entry['type'] == 'placeholder' and entry['content'] not in placeholders[fieldgroup.entry_type.value]:
raise ValidationError(_("Unknown placeholder {name}").format(name=entry['content']))

View File

@@ -10,13 +10,14 @@
{% block title %}{% trans "Wallet layouts" %}{% endblock %} {% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "Edit layout" %}</h1> <h1>{% trans "Edit layout" %} {{ object.name }} </h1>
{{ styles|json_script:"styles" }} {{ styles|json_script:"styles" }}
{{ variables|json_script:"variables" }} {{ variables|json_script:"variables" }}
{{ form.errors|json_script:"form_errors" }} {{ form.errors|json_script:"form_errors" }}
{{ layout|json_script:"layout" }}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div id="editor"></div> <div id="editor">This is a test</div>
</form> </form>
{% vite_hmr %} {% vite_hmr %}
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %} {% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}

View File

@@ -4,7 +4,7 @@ from django.http import Http404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.views.generic import FormView, ListView, CreateView, UpdateView from django.views.generic import FormView, ListView, CreateView, UpdateView
from pretix.base.pdf import get_variables from pretix.base.pdf import get_images, get_variables
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
from .styles import PassLayout, get_platform_styles, get_platforms from .styles import PassLayout, get_platform_styles, get_platforms
from .models import WalletLayout from .models import WalletLayout
@@ -14,7 +14,9 @@ from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from i18nfield.fields import I18nCharField from i18nfield.fields import I18nCharField
from i18nfield.forms import I18nModelForm from i18nfield.forms import I18nModelForm
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from rest_framework import serializers
from rest_framework.renderers import JSONRenderer
# TODO: should this even be a list view? # TODO: should this even be a list view?
class LayoutListView(EventPermissionRequiredMixin, ListView): class LayoutListView(EventPermissionRequiredMixin, ListView):
model = WalletLayout model = WalletLayout
@@ -33,7 +35,7 @@ class LayoutListView(EventPermissionRequiredMixin, ListView):
class LayoutEditForm(forms.ModelForm): class LayoutEditForm(forms.ModelForm):
style = forms.TypedChoiceField() style = forms.ChoiceField()
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.platform = kwargs.pop('platform') self.platform = kwargs.pop('platform')
@@ -43,14 +45,14 @@ class LayoutEditForm(forms.ModelForm):
model = WalletLayout model = WalletLayout
fields = ("name","style","layout") fields = ("name","style","layout")
def __init__(self, platform, **kwargs): def __init__(self, event, platform, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.event = event
self.platform = platform self.platform = platform
self.platform_styles = get_platform_styles(platform) self.platform_styles = get_platform_styles(platform)
self.fields["style"].choices = [ self.fields["style"].choices = [
(id, style.name) for id, style in self.platform_styles.items() (id, style.name) for id, style in self.platform_styles.items()
] ]
self.fields["style"].coerce = self.coerce_style
def coerce_style(self, value): def coerce_style(self, value):
return self.platform_styles[value] return self.platform_styles[value]
@@ -65,11 +67,28 @@ class LayoutEditForm(forms.ModelForm):
def clean(self): def clean(self):
if "style" in self.cleaned_data and "layout" in self.cleaned_data: if "style" in self.cleaned_data and "layout" in self.cleaned_data:
layout = PassLayout( layout = PassLayout(
style=self.cleaned_data["style"], layout=self.cleaned_data["layout"] style=self.coerce_style(self.cleaned_data["style"]), layout=self.cleaned_data["layout"]
) )
layout.validate() layout.validate(self.event)
return self.cleaned_data return self.cleaned_data
class LayoutSerializer(I18nAwareModelSerializer):
# # TODO: only necessary if we save through this serializer
# style = serializers.ChoiceField(choices={})
# def __init__(self, *args, platform, **kwargs):
# super().__init__(*args, **kwargs)
# self.platform = platform
# self.platform_styles = get_platform_styles(platform)
# self.fields["style"].choices = [
# (id, style.name) for id, style in self.platform_styles.items()
# ]
class Meta:
model = WalletLayout
fields = ("name","style","layout")
class LayoutEditorView(EventPermissionRequiredMixin, UpdateView): class LayoutEditorView(EventPermissionRequiredMixin, UpdateView):
template_name = "pretixplugins/wallet/edit.html" template_name = "pretixplugins/wallet/edit.html"
form_class = LayoutEditForm form_class = LayoutEditForm
@@ -83,6 +102,7 @@ class LayoutEditorView(EventPermissionRequiredMixin, UpdateView):
def get_form_kwargs(self) -> dict[str, Any]: def get_form_kwargs(self) -> dict[str, Any]:
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs["platform"] = self.platform kwargs["platform"] = self.platform
return kwargs return kwargs
@@ -98,11 +118,25 @@ class LayoutEditorView(EventPermissionRequiredMixin, UpdateView):
context["styles"] = { context["styles"] = {
id: style.asdict() for id, style in self.get_platform_styles().items() id: style.asdict() for id, style in self.get_platform_styles().items()
} }
if self.request.method == "POST":
form = self.get_form()
if not form.is_valid():
layout_data = LayoutSerializer(self.object).data
layout_data.update(form.cleaned_data)
context['layout'] = layout_data
else:
context["layout"] = LayoutSerializer(self.object).data
else:
context["layout"] = LayoutSerializer(self.object).data
context["variables"] = { context["variables"] = {
"text": { "text": {
varname: {"label": var["label"], "editor_sample": var["editor_sample"]} varname: {"label": var["label"], "editor_sample": var["editor_sample"]}
for varname, var in get_variables(self.request.event).items() for varname, var in get_variables(self.request.event).items()
} },
"image": {
varname: {"label": var['label']} for varname, var in get_images(self.request.event).items()
} | {"poweredby": {"label": _("pretix-Logo")}} # TODO: image upload
} }
return context return context