mirror of
https://github.com/pretix/pretix.git
synced 2026-10-01 19:14:43 +00:00
New load indicator, centralize environment data, extract QuestionnaireDetailForm
This commit is contained in:
@@ -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() {
|
||||
|
||||
+10
-1
@@ -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[]][];
|
||||
|
||||
Reference in New Issue
Block a user