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">
import { ref } from '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 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 gettext = (window as any).gettext
const style = ref<string | null>(FORM_DATA.style ?? null)
const name = ref<string>(FORM_DATA.name ?? '')
const layout = ref(JSON.parse(FORM_DATA.layout ?? '{}') ?? {})
// TODO: Move to store?
const STYLES: Styles = JSON.parse(document.querySelector('#styles')?.textContent ?? '{}')
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>
<template lang="pug">
// TODO: add :key for all `v-for`s
//- pre
//- code {{ STYLES }}
// TODO: i18n
details
pre
code {{ FORM_ERRORS }}
.row
.col-md-8
// TODO: show error text
.form-group(:class='"name" in FORM_ERRORS ? "has-error" : ""')
label.control-label(for="layout-info-name") Name
input#layout-info-name.form-control(v-model="name" name="name")
Input(label="Name" v-model="name" name="name" :errors="FORM_ERRORS['name']")
.form-group(:class='"style" in FORM_ERRORS ? "has-error" : ""')
label.control-label(for="layout-info-style") 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 }}
Select(label="Style" v-model="style" :choices="Object.values(STYLES).map(x => [x.identifier, x.name])" name="style" :errors="FORM_ERRORS['style']")
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
.panel.panel-default
.panel-heading Preview
// TODO: i18n
.panel-body
// TODO: Preview
pre

View File

@@ -1,55 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import OptionalSelect from './optional-select.vue'
import { computed, reactive } from '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<{
field: any // TODO
overflows: [string, string][],
variables: any // TODO
field: FieldGroupDefinition
overflows: FieldGroupDefinition[],
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) {
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 {
return []
}
})
function addVariable() {
fieldConfig.value.entries.push({"label": null, "value": null})
fieldConfig.value.entries.push({"type": "placeholder", "label": null, "content": null})
}
</script>
<template lang="pug">
//- pre
//- code {{ props }}
.panel.panel-default
.panel-heading
h3.panel-title {{ field.name }}
.panel-body
.form-group(v-if="props.variables[field.entry_type]")
.form-group
span.text-muted These fields appear somewhere and are visible too.
// TODO: for="..." / labeledby?
h4 Fields
h4 {{ gettext("Content") }}
.row.form-group(v-for="n in fieldConfig.entries.length")
.col-md-5
// TODO: i18n
label.control-label Label
input.form-control(v-model="fieldConfig.entries[n-1].label")
.col-md-6
label.control-label Value
select.form-control(v-model="fieldConfig.entries[n-1].value")
option(v-for="(config, id) in props.variables[field.entry_type]" :key="id" :value="id") {{ config.label }}
Input(:label="gettext('Label')" v-model="fieldConfig.entries[n-1].label")
.col-md-6(v-if='field.entry_type == "text"')
TextContent(v-model="fieldConfig.entries[n-1]"
:variables="props.variables")
.col-md-6(v-else-if='field.entry_type == "image"')
Select(:label="gettext('Content')"
v-model="fieldConfig.entries[n-1].content"
:choices="Object.entries(props.variables).map(([k,v]) => [k, v.label])"
)
.col-md-1
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)")
i.fa.fa-trash
span.sr-only "Delete"
span.sr-only {{ gettext('Delete')}}
button.btn.btn-default(type="button" @click="addVariable")
i.fa.fa-plus
span.sr-only Add field
OptionalSelect(label="Overflow to ..." :choices="overflowOptions" v-model="fieldConfig.overflow")
span.sr-only {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
</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">
import { computed, watchEffect } from 'vue'
import OptionalSelect from './optional-select.vue'
import FieldSettings from './field-settings.vue'
import { computed, watchEffect } from "vue";
import FieldSettings from "./field-settings.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
styles: any, // TODO
variables: any, // TODO
style?: string,
}>()
styles: Styles;
variables: VariableConfig
style?: string;
}>();
const layout = defineModel()
const layout = defineModel<LayoutData>();
const styleData = computed(() => {
if (!props.style || !(props.style in props.styles)) {
return null
return null;
}
return props.styles[props.style]
})
return props.styles[props.style];
});
watchEffect(() => {
// TODO: this seems wrooong
if (!('fields' in layout.value)) {
layout.value.fields = {}
}
if (!("fields" in layout.value)) {
layout.value.fields = {};
}
if (props.style) {
for (const field of props.styles[props.style].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>
<template lang="pug">
h2.h3 Form Fields
h2.h3 {{ gettext("Field Groups") }}
FieldSettings(v-if="styleData"
v-for="(field, fieldId) in styleData.fields"
v-model="layout.fields[field.identifier]"
:field="field"
:overflows="styleData.fields.slice(fieldId+1).filter(x => x.entry_type === field.entry_type)"
:variables="variables"
:overflows="styleData.fields.slice(fieldId + 1).filter(x => x.entry_type === field.entry_type)"
:variables="variables[field.entry_type]"
)
</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 App from './components/app.vue'
const app = createApp(App)
app.mount('#editor')
const mountEl = document.querySelector<HTMLElement>('#editor')!
const app = createApp(App, mountEl.dataset)
app.mount(mountEl)
app.config.errorHandler = (error, _vm, info) => {
// 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.core.exceptions import ValidationError
from i18nfield.strings import LazyI18nString
from pretix.base.pdf import get_images, get_variables
from .models import WalletLayout
@@ -34,10 +36,10 @@ class PlaceholderFieldType(enum.Enum):
class PlaceholderField:
type: PlaceholderFieldType
label: LazyI18nString
value: str
content: str
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
@@ -178,10 +180,12 @@ class PassLayout:
self.style = style
self.layout = layout
def validate(self):
self.validate_fields()
def validate(self, event):
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
if "fields" not in self.layout:
raise ValidationError(_("Layout did not contain any fields"))
@@ -192,10 +196,16 @@ class PassLayout:
for fieldgroup in style_fields:
layout_field_data = layout_fields.get(fieldgroup.identifier, {})
if fieldgroup.min_entries and fieldgroup.min_entries < len(
layout_field_data.get('entries')
layout_field_data.get('entries', [])
):
raise ValidationError(
_("At least {min_entries} must be specified for {name}").format(
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 content %}
<h1>{% trans "Edit layout" %}</h1>
<h1>{% trans "Edit layout" %} {{ object.name }} </h1>
{{ styles|json_script:"styles" }}
{{ variables|json_script:"variables" }}
{{ form.errors|json_script:"form_errors" }}
{{ layout|json_script:"layout" }}
<form method="post">
{% csrf_token %}
<div id="editor"></div>
<div id="editor">This is a test</div>
</form>
{% vite_hmr %}
{% 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.urls import reverse
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 .styles import PassLayout, get_platform_styles, get_platforms
from .models import WalletLayout
@@ -14,7 +14,9 @@ from django import forms
from django.core.exceptions import ValidationError
from i18nfield.fields import I18nCharField
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?
class LayoutListView(EventPermissionRequiredMixin, ListView):
model = WalletLayout
@@ -33,7 +35,7 @@ class LayoutListView(EventPermissionRequiredMixin, ListView):
class LayoutEditForm(forms.ModelForm):
style = forms.TypedChoiceField()
style = forms.ChoiceField()
def __init__(self, **kwargs):
self.platform = kwargs.pop('platform')
@@ -43,14 +45,14 @@ class LayoutEditForm(forms.ModelForm):
model = WalletLayout
fields = ("name","style","layout")
def __init__(self, platform, **kwargs):
def __init__(self, event, platform, **kwargs):
super().__init__(**kwargs)
self.event = event
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()
]
self.fields["style"].coerce = self.coerce_style
def coerce_style(self, value):
return self.platform_styles[value]
@@ -65,11 +67,28 @@ class LayoutEditForm(forms.ModelForm):
def clean(self):
if "style" in self.cleaned_data and "layout" in self.cleaned_data:
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
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):
template_name = "pretixplugins/wallet/edit.html"
form_class = LayoutEditForm
@@ -83,6 +102,7 @@ class LayoutEditorView(EventPermissionRequiredMixin, UpdateView):
def get_form_kwargs(self) -> dict[str, Any]:
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs["platform"] = self.platform
return kwargs
@@ -98,11 +118,25 @@ class LayoutEditorView(EventPermissionRequiredMixin, UpdateView):
context["styles"] = {
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"] = {
"text": {
varname: {"label": var["label"], "editor_sample": var["editor_sample"]}
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