New load indicator, centralize environment data, extract QuestionnaireDetailForm

This commit is contained in:
Mira Weller
2026-09-22 17:15:15 +02:00
parent b3e769302e
commit e30ce1042e
7 changed files with 169 additions and 88 deletions
@@ -1,13 +1,41 @@
<script setup lang="ts">
import Editor from './Editor.vue';
import {provide, ref} from "vue";
import * as api from "./api";
import {sort, numericComp, groupBy} from './helper';
import {Category, GroupedItems, Item, SalesChannel} from "./model";
const sales_channels_list = ref<SalesChannel[]>();
const grouped_items = ref<GroupedItems>()
provide('pretix:env:organizer:sales_channels', sales_channels_list)
provide('pretix:env:event:grouped_items', grouped_items)
async function loadEnvironmentData() {
sales_channels_list.value = await api.getSalesChannels();
const items_list = await api.getItems();
const categories_list = await api.getCategories();
const categories = Object.fromEntries(categories_list.map(cat => [cat.id, cat]));
categories['null'] = { position: -1, internal_name: gettext('Uncategorized'), name: null, id: null };
sort(items_list, numericComp(item => categories[item.category]?.position), numericComp(item => item.position));
grouped_items.value = [...groupBy(items_list, item => categories[item.category])]
}
loadEnvironmentData()
</script>
<template>
<Suspense>
<Editor/>
<template #fallback>
<div class="big-load-indicator"><i class="fa fa-cog big-rotating-icon"></i></div>
</template>
</Suspense>
</template>
<style>
.big-load-indicator { text-align: center; padding-bottom: 100px }
.progressBar { position: fixed; top: 0; left: 0; right: 0; z-index:1000000; pointer-events: none; }
.progressBar.local { position: absolute; }
.progressBar .progressBar_progress { height: 5px; background: #0091EA; border-bottom: #026099; }
@@ -4,19 +4,11 @@ import * as api from './api';
import {Datafield, Questionnaire} from './model';
import {i18n_any, sort, numericComp, groupBy, SYSTEM_DATAFIELDS} from './helper';
import { gettext } from './gettextstub';
import {onMounted, onUnmounted, ref} from 'vue';
import {inject, onMounted, onUnmounted, ref} from 'vue';
import { SlickList, SlickItem } from 'vue-slicksort';
import { ProgressBar } from "./ProgressBar";
const sales_channels_list = await api.getSalesChannels();
const items_list = await api.getItems();
const categories_list = await api.getCategories();
const categories = Object.fromEntries(categories_list.map(cat => [cat.id, cat]));
categories['null'] = { position: -1, internal_name: gettext('Uncategorized'), name: null, id: null };
sort(items_list, numericComp(item => categories[item.category]?.position), numericComp(item => item.position));
console.log('items_list', items_list)
const grouped_items = [...groupBy(items_list, item => categories[item.category])]
console.log('grouped_items', grouped_items)
const grouped_items = inject('pretix:env:event:grouped_items')
type QuestionnaireMaybeUnsaved = Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number };
const order_questionnaires = ref<QuestionnaireMaybeUnsaved[]>();
@@ -113,6 +105,13 @@ export default {
function windowFocused() {
if (Date.now() - lastDataRefresh > 30000) {
if (document.querySelector("dialog[open]")) {
// TODO this should not be skipped completely but handled in a more robust way
// currently dialogs are very flaky if the whole questionnaires list gets replaced underneath
// them (they will stay open, but saving them will cause weird problems i don't fully understand yet)
console.log('skipping refresh because a dialog is open')
return
}
console.log('refreshing to avoid overwriting with older data on edit')
refreshQuestionnaireList()
}
@@ -194,12 +193,12 @@ const preview_mode = ref(false)
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="order_datafields"
:sales_channels="sales_channels_list"
:grouped_items="null"
:selected_product="null"
:preview_mode="false"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()" />
@invalidate:datafields="refreshDatafieldList()"
@invalidate:questionnaires="refreshQuestionnaireList()"/>
</SlickItem>
</SlickList>
<div class="editor-action-row form-horizontal">
@@ -242,12 +241,12 @@ const preview_mode = ref(false)
<QuestionnaireElement v-for="(questionnaire, index) in position_questionnaires.filter(q => q.items.indexOf(selected_product as any) !== -1)"
:questionnaire="questionnaire"
:datafields="position_datafields"
:sales_channels="sales_channels_list"
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="true"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"/>
@invalidate:datafields="refreshDatafieldList()"
@invalidate:questionnaires="refreshQuestionnaireList()"/>
</details>
</div>
@@ -257,12 +256,12 @@ const preview_mode = ref(false)
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="position_datafields"
:sales_channels="sales_channels_list"
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="false"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"/>
@invalidate:datafields="refreshDatafieldList()"
@invalidate:questionnaires="refreshQuestionnaireList()"/>
</SlickItem>
</SlickList>
<div v-if="!preview_mode" class="editor-action-row form-horizontal">
@@ -0,0 +1,76 @@
<script setup lang="ts">
import {gettext} from "./gettextstub";
import {GroupedItems, Questionnaire, SalesChannel} from "./model";
import {inject, Ref, useId} from "vue";
import {i18n_any, QUESTIONNAIRE_TYPE, QUESTIONNAIRE_TYPE_LABEL, setListState} from "./helper";
const sales_channels: Ref<SalesChannel[]> = inject('pretix:env:organizer:sales_channels')
const id = useId()
const props = defineProps<{
questionnaire: Questionnaire,
grouped_items: GroupedItems,
}>()
</script>
<template>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Internal name') }}
</label>
<div class="col-md-9">
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Where to ask') }}
</label>
<div class="col-md-9">
<select v-model="questionnaire.type" class="form-control">
<option v-for="(label, type) in QUESTIONNAIRE_TYPE_LABEL" :value="QUESTIONNAIRE_TYPE[type]">{{ label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Sales channels') }}
</label>
<div class="col-md-9">
<div class="checkbox">
<label>
<input type="checkbox" v-model="questionnaire.all_sales_channels"> {{ gettext('All sales channels') }}
</label>
</div>
<div class="checkbox" v-for="channel in sales_channels">
<label>
<input type="checkbox" :checked="questionnaire.all_sales_channels || questionnaire.limit_sales_channels.indexOf(channel.identifier) !== -1"
@change="e => setListState(questionnaire.limit_sales_channels, channel.identifier, e.target.checked)"
:disabled="questionnaire.all_sales_channels">
{{ i18n_any(channel.label) }}
</label>
</div>
</div>
</div>
<div class="form-group" v-if="grouped_items">
<label class="col-md-3 control-label">
{{ gettext('Visible on products') }}
</label>
<div class="col-md-9">
<div v-for="[category, items] in grouped_items">
<div class="category-header">{{ category.internal_name || i18n_any(category.name) }}</div>
<div class="checkbox" v-for="item in items">
<label :for="id + '_' + item.id">
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1" @change="e => setListState(questionnaire.items, item.id, e.target.checked)"> {{ item.internal_name || i18n_any(item.name) }}
</label>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="sass">
</style>
@@ -6,7 +6,7 @@ import {
QUESTION_TYPE,
QUESTION_TYPE_LABEL,
QUESTIONNAIRE_TYPE,
QUESTIONNAIRE_TYPE_LABEL,
QUESTIONNAIRE_TYPE_LABEL, setListState,
SYSTEM_DATAFIELDS
} from "./helper";
import { gettext } from './gettextstub';
@@ -15,6 +15,8 @@ import NativeDialog from "./NativeDialog.vue";
import { SlickList, SlickItem, DragHandle } from 'vue-slicksort';
import {getDatafieldCreateUrl} from "./api";
import DjangoDialog from "./DjangoDialog.vue";
import * as api from './api';
import QuestionnaireDetailForm from "./QuestionnaireDetailForm.vue";
const dlgEditor = ref()
const dlgAddExisting = ref()
@@ -25,8 +27,8 @@ const newTextblockTitle = ref()
const newTextblockText = ref()
const id = useId();
const props = defineProps(['questionnaire', 'datafields', 'sales_channels', 'selected_product', 'grouped_items', 'preview_mode', 'err_mes'])
const emit = defineEmits(['update', 'invalidate:datafields'])
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'grouped_items', 'preview_mode', 'err_mes'])
const emit = defineEmits(['update', 'invalidate:datafields', 'invalidate:questionnaires'])
let nextId = 1;
watch(() => props.questionnaire.children, () => {
@@ -35,15 +37,6 @@ watch(() => props.questionnaire.children, () => {
}
})
function setVisibleOnItem (checked, itemId) {
const i = props.questionnaire.items.indexOf(itemId)
if (i === -1) {
props.questionnaire.items.push(itemId)
} else {
props.questionnaire.items.splice(i, 1)
}
}
function addExistingDatafield (field) {
props.questionnaire.children.push({
_cid: useId(),
@@ -94,6 +87,18 @@ async function onNewDatafieldCreated (data) {
emit('invalidate:datafields')
}
async function deleteQuestionnaire () {
if (confirm("Are you sure?")) {
try {
await api.deleteQuestionnaire(props.questionnaire.id)
} catch (e) {
console.error('Failed to delete questionnaire', e)
alert('Error while deleting questionnaire')
}
emit('invalidate:questionnaires')
}
}
const isHidden = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) === -1)
const isEditable = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) !== -1)
@@ -107,7 +112,7 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<summary class="questionnaire-panel-heading">
<div class=" editor-row">
<div class="editor-preview-area">
<input type="checkbox" @change="e => {setVisibleOnItem(e.target.checked, selected_product); emit('update')}" v-if="selected_product && !preview_mode" :checked="!isHidden">
<input type="checkbox" @change="e => {setListState(props.questionnaire.items, selected_product, e.target.checked); emit('update')}" v-if="selected_product && !preview_mode" :checked="!isHidden">
{{ props.questionnaire.internal_name }}
<span class="fa fa-warning" v-if="questionnaire._err_mes"></span>
<span class="fa fa-cog fa-spin" v-if="questionnaire._loading"></span>
@@ -152,61 +157,10 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<Teleport to="body">
<NativeDialog ref="dlgEditor" class="modal-card"
:title="gettext('Edit questionnaire')">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Internal name') }}
</label>
<div class="col-md-9">
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Where to ask') }}
</label>
<div class="col-md-9">
<select v-model="questionnaire.type" class="form-control">
<option v-for="(label, type) in QUESTIONNAIRE_TYPE_LABEL" :value="QUESTIONNAIRE_TYPE[type]">{{ label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Sales channels') }}
</label>
<div class="col-md-9">
<div class="checkbox">
<label>
<input type="checkbox" v-model="questionnaire.all_sales_channels"> {{ gettext('All sales channels') }}
</label>
</div>
<div class="checkbox" v-for="channel in sales_channels">
<label>
<input type="checkbox" :checked="questionnaire.all_sales_channels || questionnaire.limit_sales_channels.indexOf(channel.identifier) !== -1"
@change="e => e.target.checked ? questionnaire.limit_sales_channels.push(channel.identifier) : questionnaire.limit_sales_channels.splice(questionnaire.limit_sales_channels.indexOf(channel.identifier), 1)"
:disabled="questionnaire.all_sales_channels">
{{ i18n_any(channel.label) }}
</label>
</div>
</div>
</div>
<div class="form-group" v-if="grouped_items">
<label class="col-md-3 control-label">
{{ gettext('Visible on products') }}
</label>
<div class="col-md-9">
<div v-for="[category, items] in grouped_items">
<div class="category-header">{{ category.internal_name || i18n_any(category.name) }}</div>
<div class="checkbox" v-for="item in items">
<label :for="id + '_' + item.id">
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1" @change="e => setVisibleOnItem(e.target.checked, item.id)"> {{ item.internal_name || i18n_any(item.name) }}
</label>
</div>
</div>
</div>
</div>
<button @click="dlgEditor.close(); emit('update')" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
<button class="btn btn-default">{{ gettext('Delete') }}</button>
<QuestionnaireDetailForm :questionnaire="questionnaire" :grouped_items="grouped_items"/>
<button @click="dlgEditor.close(); emit('update')" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
<button @click="deleteQuestionnaire" class="btn btn-default">{{ gettext('Delete') }}</button>
</NativeDialog>
<NativeDialog ref="dlgAddExisting" class="modal-card"
@@ -1,4 +1,4 @@
import { ApiListResponse, Datafield, Questionnaire, Item, Category } from './model'
import {ApiListResponse, Datafield, Questionnaire, Item, Category, SalesChannel} from './model'
import { ProgressBar } from "./ProgressBar";
import {fromJsonScript} from "./helper";
@@ -37,7 +37,9 @@ async function api_json_request(resource, method, json_body) {
'X-CSRFToken': $('[name=csrfmiddlewaretoken]').val() as string,
},
});
if (response.status >= 400)
if (response.status === 204)
return {}
else if (response.status >= 400)
throw new APIError(await response.json());
else
return await response.json()
@@ -61,6 +63,10 @@ export async function createQuestionnaire(data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`, 'POST', data);
}
export async function deleteQuestionnaire(id) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/${id}/`, 'DELETE', {});
}
export async function getItems() {
using pb = ProgressBar.show('loading product list')
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/items/`);
@@ -72,7 +78,7 @@ export async function getCategories() {
}
export async function getSalesChannels() {
return await api_get_all<Category>(`organizers/${organizer_slug}/saleschannels/`);
return await api_get_all<SalesChannel>(`organizers/${organizer_slug}/saleschannels/`);
}
export function getEventLocales() {
@@ -37,7 +37,7 @@ export function sort (array, ...orderBy) {
return 0
})
}
export function *groupBy (array, key) {
export function *groupBy<K,V> (array: V[], key: (v: V) => K): Generator<[K, V[]]> {
let lastKey, lastArray
for (const x of array) {
const k = key(x)
@@ -55,6 +55,15 @@ export function *groupBy (array, key) {
}
}
export function setListState(list, state, identifier) {
const i = list.indexOf(identifier)
if (i === -1) {
if (state) list.push(identifier)
} else {
if (!state) list.splice(i, 1)
}
}
export function fromJsonScript (id) {
return JSON.parse(document.getElementById(id).textContent)
}
@@ -70,3 +70,12 @@ export type Category = {
name: I18nString,
internal_name: string | null,
}
export type SalesChannel = {
identifier: string,
type: string,
label: I18nString,
position: number,
}
export type GroupedItems = [Category, Item[]][];