migrate checkin rules editor to vue3

- move constants to a module
- move reading from and writing to non-vue html to django interop module
- switch to composition api and script setup sfc with pug
- use optional chaining operators a lot to simplify code
This commit is contained in:
rash
2026-02-03 15:02:32 +01:00
parent 3d37f62c51
commit ee44d4c968
18 changed files with 1584 additions and 1538 deletions

5
.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

26
package-lock.json generated
View File

@@ -14,6 +14,8 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@stylistic/eslint-plugin": "^5.7.1", "@stylistic/eslint-plugin": "^5.7.1",
"@types/jquery": "^3.5.33",
"@types/moment": "^2.11.29",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"@vue/eslint-config-typescript": "^14.6.0", "@vue/eslint-config-typescript": "^14.6.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
@@ -1223,6 +1225,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jquery": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz",
"integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1230,6 +1242,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/moment": {
"version": "2.11.29",
"resolved": "https://registry.npmjs.org/@types/moment/-/moment-2.11.29.tgz",
"integrity": "sha512-D5WIgbLYQzvgfsDnBhZFSTnt/BjGPOE+Jsh3k1BYYijJAkrn7ceeLvU4jtjKKXXuXN42O3ARlU4D/P9ezbQYFA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/sizzle": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz",
"integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.54.0", "version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",

View File

@@ -29,6 +29,8 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@stylistic/eslint-plugin": "^5.7.1", "@stylistic/eslint-plugin": "^5.7.1",
"@types/jquery": "^3.5.33",
"@types/moment": "^2.11.29",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"@vue/eslint-config-typescript": "^14.6.0", "@vue/eslint-config-typescript": "^14.6.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",

View File

@@ -3,6 +3,7 @@
{% load bootstrap3 %} {% load bootstrap3 %}
{% load static %} {% load static %}
{% load compress %} {% load compress %}
{% load vite %}
{% block title %} {% block title %}
{% if checkinlist %} {% if checkinlist %}
{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
@@ -74,45 +75,8 @@
{% bootstrap_field form.ignore_in_statistics layout="control" %} {% bootstrap_field form.ignore_in_statistics layout="control" %}
<h3>{% trans "Custom check-in rule" %}</h3> <h3>{% trans "Custom check-in rule" %}</h3>
<div id="rules-editor" class="form-inline"> <div id="rules-editor">
<div> <!-- Vue app mount point -->
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#rules-edit" role="tab" data-toggle="tab">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</li>
<li role="presentation">
<a href="#rules-viz" role="tab" data-toggle="tab">
<span class="fa fa-eye"></span>
{% trans "Visualize" %}
</a>
</li>
</ul>
<!-- Tab panes -->
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="rules-edit">
<checkin-rules-editor></checkin-rules-editor>
</div>
<div role="tabpanel" class="tab-pane" id="rules-viz">
<checkin-rules-visualization></checkin-rules-visualization>
</div>
</div>
</div>
<div class="alert alert-info" v-if="missingItems.length">
<p>
{% trans "Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:" %}
</p>
<ul>
<li v-for="h in missingItems">{{ "{" }}{h}{{ "}" }}</li>
</ul>
<p>
{% trans "Please double-check if this was intentional." %}
</p>
</div>
</div> </div>
<div class="disabled-withoutjs sr-only"> <div class="disabled-withoutjs sr-only">
{{ form.rules }} {{ form.rules }}
@@ -127,11 +91,6 @@
</form> </form>
{{ items|json_script:"items" }} {{ items|json_script:"items" }}
{% if DEBUG %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
{% else %}
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
{% endif %}
{% compress js %} {% compress js %}
<script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script>
@@ -144,15 +103,6 @@
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
{% endcompress %} {% endcompress %}
{% compress js %} {% vite_hmr %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script> {% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/" %}
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/lookup-select2.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rule.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
{% endcompress %}
{% endblock %} {% endblock %}

View File

@@ -5,6 +5,7 @@
{% load getitem %} {% load getitem %}
{% load static %} {% load static %}
{% load compress %} {% load compress %}
{% load vite %}
{% block title %}{% trans "Check-in simulator" %}{% endblock %} {% block title %}{% trans "Check-in simulator" %}{% endblock %}
{% block inside %} {% block inside %}
<h1> <h1>
@@ -124,11 +125,9 @@
{% endif %} {% endif %}
{% if result.rule_graph %} {% if result.rule_graph %}
<div id="rules-editor" class="form-inline"> <div id="rules-editor" class="form-inline">
<div role="tabpanel" class="tab-pane" id="rules-viz"> <!-- Vue app mount point -->
<checkin-rules-visualization></checkin-rules-visualization>
</div>
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
</div> </div>
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -152,10 +151,6 @@
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
{% endcompress %} {% endcompress %}
{% compress js %} {% vite_hmr %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script> {% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/" %}
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
{% endcompress %}
{% endblock %} {% endblock %}

View File

@@ -1,318 +0,0 @@
$(function () {
var TYPEOPS = {
// Every change to our supported JSON logic must be done
// * in pretix.base.services.checkin
// * in pretix.base.models.checkin
// * in pretix.helpers.jsonlogic_boolalg
// * in checkinrules.js
// * in libpretixsync
// * in pretixscan-ios
'product': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'variation': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'gate': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'datetime': {
'isBefore': {
'label': gettext('is before'),
'cardinality': 2,
},
'isAfter': {
'label': gettext('is after'),
'cardinality': 2,
},
},
'enum_entry_status': {
'==': {
'label': gettext('='),
'cardinality': 2,
},
},
'int_by_datetime': {
'<': {
'label': '<',
'cardinality': 2,
},
'<=': {
'label': '≤',
'cardinality': 2,
},
'>': {
'label': '>',
'cardinality': 2,
},
'>=': {
'label': '≥',
'cardinality': 2,
},
'==': {
'label': '=',
'cardinality': 2,
},
'!=': {
'label': '≠',
'cardinality': 2,
},
},
'int': {
'<': {
'label': '<',
'cardinality': 2,
},
'<=': {
'label': '≤',
'cardinality': 2,
},
'>': {
'label': '>',
'cardinality': 2,
},
'>=': {
'label': '≥',
'cardinality': 2,
},
'==': {
'label': '=',
'cardinality': 2,
},
'!=': {
'label': '≠',
'cardinality': 2,
},
},
};
var VARS = {
'product': {
'label': gettext('Product'),
'type': 'product',
},
'variation': {
'label': gettext('Product variation'),
'type': 'variation',
},
'gate': {
'label': gettext('Gate'),
'type': 'gate',
},
'now': {
'label': gettext('Current date and time'),
'type': 'datetime',
},
'now_isoweekday': {
'label': gettext('Current day of the week (1 = Monday, 7 = Sunday)'),
'type': 'int',
},
'entry_status': {
'label': gettext('Current entry status'),
'type': 'enum_entry_status',
},
'entries_number': {
'label': gettext('Number of previous entries'),
'type': 'int',
},
'entries_today': {
'label': gettext('Number of previous entries since midnight'),
'type': 'int',
},
'entries_since': {
'label': gettext('Number of previous entries since'),
'type': 'int_by_datetime',
},
'entries_before': {
'label': gettext('Number of previous entries before'),
'type': 'int_by_datetime',
},
'entries_days': {
'label': gettext('Number of days with a previous entry'),
'type': 'int',
},
'entries_days_since': {
'label': gettext('Number of days with a previous entry since'),
'type': 'int_by_datetime',
},
'entries_days_before': {
'label': gettext('Number of days with a previous entry before'),
'type': 'int_by_datetime',
},
'minutes_since_last_entry': {
'label': gettext('Minutes since last entry (-1 on first entry)'),
'type': 'int',
},
'minutes_since_first_entry': {
'label': gettext('Minutes since first entry (-1 on first entry)'),
'type': 'int',
},
};
var components = {
CheckinRulesVisualization: CheckinRulesVisualization.default,
}
if (typeof CheckinRule !== "undefined") {
Vue.component('checkin-rule', CheckinRule.default);
components = {
CheckinRulesEditor: CheckinRulesEditor.default,
CheckinRulesVisualization: CheckinRulesVisualization.default,
}
}
var app = new Vue({
el: '#rules-editor',
components: components,
data: function () {
return {
rules: {},
items: [],
all_products: false,
limit_products: [],
TYPEOPS: TYPEOPS,
VARS: VARS,
texts: {
and: gettext('All of the conditions below (AND)'),
or: gettext('At least one of the conditions below (OR)'),
date_from: gettext('Event start'),
date_to: gettext('Event end'),
date_admission: gettext('Event admission'),
date_custom: gettext('custom date and time'),
date_customtime: gettext('custom time'),
date_tolerance: gettext('Tolerance (minutes)'),
condition_add: gettext('Add condition'),
minutes: gettext('minutes'),
duplicate: gettext('Duplicate'),
status_present: pgettext('entry_status', 'present'),
status_absent: pgettext('entry_status', 'absent'),
},
hasRules: false,
};
},
computed: {
missingItems: function () {
// This computed property contains list of item or variation names that
// a) Are allowed on the checkin list according to all_products or include_products
// b) Are not matched by ANY logical branch of the rule.
// The list will be empty if there is a "catch-all" rule.
var products_seen = {};
var variations_seen = {};
var rules = convert_to_dnf(this.rules);
var branch_without_product_filter = false;
if (!rules["or"]) {
rules = {"or": [rules]}
}
for (var part of rules["or"]) {
if (!part["and"]) {
part = {"and": [part]}
}
var this_branch_without_product_filter = true;
for (var subpart of part["and"]) {
if (subpart["inList"]) {
if (subpart["inList"][0]["var"] === "product" && subpart["inList"][1]) {
this_branch_without_product_filter = false;
for (var listentry of subpart["inList"][1]["objectList"]) {
products_seen[parseInt(listentry["lookup"][1])] = true
}
} else if (subpart["inList"][0]["var"] === "variation" && subpart["inList"][1]) {
this_branch_without_product_filter = false;
for (var listentry_ of subpart["inList"][1]["objectList"]) {
variations_seen[parseInt(listentry_["lookup"][1])] = true
}
}
}
}
if (this_branch_without_product_filter) {
branch_without_product_filter = true;
break;
}
}
if (branch_without_product_filter || (!Object.keys(products_seen).length && !Object.keys(variations_seen).length)) {
// At least one branch with no product filters at all that's fine.
return [];
}
var missing = [];
for (var item of this.items) {
if (products_seen[item.id]) continue;
if (!this.all_products && !this.limit_products.includes(item.id)) continue;
if (item.variations.length > 0) {
for (var variation of item.variations) {
if (variations_seen[variation.id]) continue;
missing.push(item.name + " " + variation.name)
}
} else {
missing.push(item.name)
}
}
return missing;
}
},
created: function () {
this.rules = JSON.parse($("#id_rules").val());
if ($("#items").length) {
this.items = JSON.parse($("#items").html());
var root = this.$root
function _update() {
root.all_products = $("#id_all_products").prop("checked")
root.limit_products = $("input[name=limit_products]:checked").map(function () {
return parseInt($(this).val())
}).toArray()
}
$("#id_all_products, input[name=limit_products]").on("change", function () {
_update();
})
_update()
function check_for_invalid_ids(valid_products, valid_variations, rule) {
if (rule["and"]) {
for(const child of rule["and"])
check_for_invalid_ids(valid_products, valid_variations, child);
} else if (rule["or"]) {
for(const child of rule["or"])
check_for_invalid_ids(valid_products, valid_variations, child);
} else if (rule["inList"] && rule["inList"][0]["var"] === "product") {
for(const item of rule["inList"][1]["objectList"]) {
if (!valid_products[item["lookup"][1]])
item["lookup"][2] = "[" + gettext('Error: Product not found!') + "]";
else
item["lookup"][2] = valid_products[item["lookup"][1]];
}
} else if (rule["inList"] && rule["inList"][0]["var"] === "variation") {
for(const item of rule["inList"][1]["objectList"]) {
if (!valid_variations[item["lookup"][1]])
item["lookup"][2] = "[" + gettext('Error: Variation not found!') + "]";
else
item["lookup"][2] = valid_variations[item["lookup"][1]];
}
}
}
check_for_invalid_ids(
Object.fromEntries(this.items.map(p => [p.id, p.name])),
Object.fromEntries(this.items.flatMap(p => p.variations?.map(v => [v.id, p.name + ' ' + v.name]))),
this.rules
);
}
},
watch: {
rules: {
deep: true,
handler: function (newval) {
$("#id_rules").val(JSON.stringify(newval));
}
},
}
})
});

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rules as rawRules, items, allProducts, limitProducts } from './django-interop'
import { convertToDNF } from './jsonlogic-boolalg'
import RulesEditor from './checkin-rules-editor.vue'
import RulesVisualization from './checkin-rules-visualization.vue'
const gettext = (window as any).gettext
const missingItems = computed(() => {
// This computed variable contains list of item or variation names that
// a) Are allowed on the checkin list according to all_products or include_products
// b) Are not matched by ANY logical branch of the rule.
// The list will be empty if there is a "catch-all" rule.
let productsSeen = {}
let variationsSeen = {}
let rules = convertToDNF(rawRules.value)
let branchWithoutProductFilter = false
if (!rules.or) {
rules = { or: [rules] }
}
for (let part of rules.or) {
if (!part.and) {
part = { and: [part] }
}
let thisBranchWithoutProductFilter = true
for (let subpart of part.and) {
if (subpart.inList) {
if (subpart.inList[0].var === 'product' && subpart.inList[1]) {
thisBranchWithoutProductFilter = false
for (let listentry of subpart.inList[1].objectList) {
productsSeen[parseInt(listentry.lookup[1])] = true
}
} else if (subpart.inList[0].var === 'variation' && subpart.inList[1]) {
thisBranchWithoutProductFilter = false
for (let listentry_ of subpart.inList[1].objectList) {
variationsSeen[parseInt(listentry_.lookup[1])] = true
}
}
}
}
if (thisBranchWithoutProductFilter) {
branchWithoutProductFilter = true
break
}
}
if (branchWithoutProductFilter || (!Object.keys(productsSeen).length && !Object.keys(variationsSeen).length)) {
// At least one branch with no product filters at all that's fine.
return []
}
let missing = []
for (const item of items.value) {
if (productsSeen[item.id]) continue
if (!allProducts.value && !limitProducts.value.includes(item.id)) continue
if (item.variations.length > 0) {
for (let variation of item.variations) {
if (variationsSeen[variation.id]) continue
missing.push(item.name + ' ' + variation.name)
}
} else {
missing.push(item.name)
}
}
return missing
})
</script>
<template lang="pug">
#rules-editor.form-inline
div
ul.nav.nav-tabs(role="tablist")
li.active(role="presentation")
a(href="#rules-edit", role="tab", data-toggle="tab")
span.fa.fa-edit
| {{ gettext("Edit") }}
li(role="presentation")
a(href="#rules-viz", role="tab", data-toggle="tab")
span.fa.fa-eye
| {{ gettext("Visualize") }}
//- Tab panes
.tab-content
#rules-edit.tab-pane.active(v-if="items", role="tabpanel")
RulesEditor
#rules-viz.tab-pane(role="tabpanel")
RulesVisualization
.alert.alert-info(v-if="missingItems.length")
p {{ gettext("Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:") }}
ul
li(v-for="h in missingItems", :key="h") {{ h }}
p {{ gettext("Please double-check if this was intentional.") }}
</template>
<style lang="stylus">
</style>

View File

@@ -1,355 +1,365 @@
<template> <script setup lang="ts">
<div v-bind:class="classObject"> /* eslint-disable vue/no-mutating-props */
<div class="btn-group pull-right"> import { computed } from 'vue'
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="duplicate" import { TEXTS, VARS, TYPEOPS } from './constants'
v-if="level > 0" data-toggle="tooltip" :title="texts.duplicate"> import { productSelectURL, variationSelectURL, gateSelectURL } from './django-interop'
<span class="fa fa-copy"></span> import LookupSelect2 from './lookup-select2.vue'
</button> import Datetimefield from './datetimefield.vue'
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithOR">OR import Timefield from './timefield.vue'
</button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithAND">AND
</button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="cutOut"
v-if="operands && operands.length === 1 && (operator === 'or' || operator === 'and')"><span
class="fa fa-cut"></span></button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="remove"
v-if="level > 0"><span class="fa fa-trash"></span></button>
</div>
<select v-bind:value="variable" v-on:input="setVariable" required class="form-control">
<option value="and">{{texts.and}}</option>
<option value="or">{{texts.or}}</option>
<option v-for="(v, name) in vars" :value="name">{{ v.label }}</option>
</select>
<select v-bind:value="operator" v-on:input="setOperator" required class="form-control"
v-if="operator !== 'or' && operator !== 'and' && vartype !== 'int_by_datetime'">
<option></option>
<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>
</select>
<select v-bind:value="timeType" v-on:input="setTimeType" required class="form-control"
v-if="vartype === 'datetime' || vartype === 'int_by_datetime'">
<option value="date_from">{{texts.date_from}}</option>
<option value="date_to">{{texts.date_to}}</option>
<option value="date_admission">{{texts.date_admission}}</option>
<option value="custom">{{texts.date_custom}}</option>
<option value="customtime">{{texts.date_customtime}}</option>
</select>
<datetimefield v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'custom'" :value="timeValue"
v-on:input="setTimeValue"></datetimefield>
<timefield v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'customtime'" :value="timeValue"
v-on:input="setTimeValue"></timefield>
<input class="form-control" required type="number"
v-if="vartype === 'datetime' && timeType && timeType !== 'customtime' && timeType !== 'custom'" v-bind:value="timeTolerance"
v-on:input="setTimeTolerance" :placeholder="texts.date_tolerance">
<select v-bind:value="operator" v-on:input="setOperator" required class="form-control"
v-if="vartype === 'int_by_datetime'">
<option></option>
<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>
</select>
<input class="form-control" required type="number" v-if="(vartype === 'int' || vartype === 'int_by_datetime') && cardinality > 1"
v-bind:value="rightoperand" v-on:input="setRightOperandNumber">
<lookup-select2 required v-if="vartype === 'product' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandProductList"
:url="productSelectURL"></lookup-select2>
<lookup-select2 required v-if="vartype === 'variation' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandVariationList"
:url="variationSelectURL"></lookup-select2>
<lookup-select2 required v-if="vartype === 'gate' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandGateList"
:url="gateSelectURL"></lookup-select2>
<select required v-if="vartype === 'enum_entry_status' && operator === '=='"
:value="rightoperand" v-on:input="setRightOperandEnum" class="form-control">
<option value="absent">{{ texts.status_absent }}</option>
<option value="present">{{ texts.status_present }}</option>
</select>
<div class="checkin-rule-childrules" v-if="operator === 'or' || operator === 'and'">
<div v-for="(op, opi) in operands">
<checkin-rule :rule="op" :index="opi" :level="level + 1" v-if="typeof op === 'object'"></checkin-rule>
</div>
<button type="button" class="checkin-rule-addchild btn btn-xs btn-default" @click.prevent="addOperand"><span
class="fa fa-plus-circle"></span> {{ texts.condition_add }}
</button>
</div>
</div>
</template>
<script>
export default {
components: {
LookupSelect2: LookupSelect2.default,
Datetimefield: Datetimefield.default,
Timefield: Timefield.default,
},
props: {
rule: Object,
level: Number,
index: Number,
},
computed: {
texts: function () {
return this.$root.texts;
},
variable: function () {
var op = this.operator;
if (op === "and" || op === "or") {
return op;
} else if (this.rule[op] && this.rule[op][0]) {
if (this.rule[op][0]["entries_since"]) {
return "entries_since";
}
if (this.rule[op][0]["entries_before"]) {
return "entries_before";
}
if (this.rule[op][0]["entries_days_since"]) {
return "entries_days_since";
}
if (this.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return this.rule[op][0]["var"];
} else {
return null;
}
},
rightoperand: function () {
var op = this.operator;
if (op === "and" || op === "or") {
return null;
} else if (this.rule[op] && typeof this.rule[op][1] !== "undefined") {
return this.rule[op][1];
} else {
return null;
}
},
operator: function () {
return Object.keys(this.rule)[0];
},
operands: function () {
return this.rule[this.operator];
},
classObject: function () {
var c = {
'checkin-rule': true
};
c['checkin-rule-' + this.variable] = true;
return c;
},
vartype: function () {
if (this.variable && this.$root.VARS[this.variable]) {
return this.$root.VARS[this.variable]['type'];
}
},
timeType: function () {
if (this.vartype === 'int_by_datetime') {
if (this.rule[this.operator][0][this.variable] && this.rule[this.operator][0][this.variable][0]['buildTime']) {
return this.rule[this.operator][0][this.variable][0]['buildTime'][0];
}
} else if (this.rightoperand && this.rightoperand['buildTime']) {
return this.rightoperand['buildTime'][0];
}
},
timeTolerance: function () {
var op = this.operator;
if ((op === "isBefore" || op === "isAfter") && this.rule[op] && typeof this.rule[op][2] !== "undefined") {
return this.rule[op][2];
} else {
return null;
}
},
timeValue: function () {
if (this.vartype === 'int_by_datetime') {
if (this.rule[this.operator][0][this.variable][0]['buildTime']) {
return this.rule[this.operator][0][this.variable][0]['buildTime'][1];
}
} else if (this.rightoperand && this.rightoperand['buildTime']) {
return this.rightoperand['buildTime'][1];
}
},
cardinality: function () {
if (this.vartype && this.$root.TYPEOPS[this.vartype] && this.$root.TYPEOPS[this.vartype][this.operator]) {
return this.$root.TYPEOPS[this.vartype][this.operator]['cardinality'];
}
},
operators: function () {
return this.$root.TYPEOPS[this.vartype];
},
productSelectURL: function () {
return $("#product-select2").text();
},
variationSelectURL: function () {
return $("#variations-select2").text();
},
gateSelectURL: function () {
return $("#gates-select2").text();
},
vars: function () {
return this.$root.VARS;
},
},
methods: {
setVariable: function (event) {
var current_op = Object.keys(this.rule)[0];
var current_val = this.rule[current_op];
if (event.target.value === "and" || event.target.value === "or") { const props = defineProps<{
if (current_val[0] && current_val[0]["var"]) { rule: any
current_val = []; level: number
} index: number
this.$set(this.rule, event.target.value, current_val); }>()
this.$delete(this.rule, current_op);
} else { const emit = defineEmits<{
if (current_val !== "and" && current_val !== "or" && current_val[0] && this.$root.VARS[event.target.value]['type'] === this.vartype) { remove: []
if (this.vartype === "int_by_datetime") { duplicate: []
var current_data = this.rule[current_op][0][this.variable]; }>()
var new_lhs = {};
new_lhs[event.target.value] = JSON.parse(JSON.stringify(current_data)); const operator = computed(() => Object.keys(props.rule)[0])
this.$set(this.rule[current_op], 0, new_lhs); const operands = computed(() => props.rule[operator.value])
} else {
this.$set(this.rule[current_op][0], "var", event.target.value); const variable = computed(() => {
} const op = operator.value
} else if (this.$root.VARS[event.target.value]['type'] === 'int_by_datetime') { if (op === 'and' || op === 'or') {
this.$delete(this.rule, current_op); return op
var o = {}; } else if (props.rule[op]?.[0]) {
o[event.target.value] = [{"buildTime": [null, null]}] if (props.rule[op][0]['entries_since']) return 'entries_since'
this.$set(this.rule, "!!", [o]); if (props.rule[op][0]['entries_before']) return 'entries_before'
} else { if (props.rule[op][0]['entries_days_since']) return 'entries_days_since'
this.$delete(this.rule, current_op); if (props.rule[op][0]['entries_days_before']) return 'entries_days_before'
this.$set(this.rule, "!!", [{"var": event.target.value}]); return props.rule[op][0]['var']
} }
} return null
}, })
setOperator: function (event) {
var current_op = Object.keys(this.rule)[0]; const rightoperand = computed(() => {
var current_val = this.rule[current_op]; const op = operator.value
this.$delete(this.rule, current_op); if (op === 'and' || op === 'or') return null
this.$set(this.rule, event.target.value, current_val); return props.rule[op]?.[1] ?? null
}, })
setRightOperandNumber: function (event) {
if (this.rule[this.operator].length === 1) { const classObject = computed(() => ({
this.rule[this.operator].push(parseInt(event.target.value)); 'checkin-rule': true,
} else { ['checkin-rule-' + variable.value]: true
this.$set(this.rule[this.operator], 1, parseInt(event.target.value)); }))
}
}, const vartype = computed(() => VARS[variable.value]?.type)
setTimeTolerance: function (event) {
if (this.rule[this.operator].length === 2) { const timeType = computed(() => {
this.rule[this.operator].push(parseInt(event.target.value)); if (vartype.value === 'int_by_datetime') {
} else { return props.rule[operator.value]?.[0]?.[variable.value]?.[0]?.buildTime?.[0]
this.$set(this.rule[this.operator], 2, parseInt(event.target.value)); }
} return rightoperand.value?.buildTime?.[0]
}, })
setTimeType: function (event) {
var time = { const timeTolerance = computed(() => {
"buildTime": [event.target.value] const op = operator.value
}; if ((op === 'isBefore' || op === 'isAfter') && props.rule[op]?.[2] !== undefined) {
if (this.vartype === "int_by_datetime") { return props.rule[op][2]
this.$set(this.rule[this.operator][0][this.variable], 0, time); }
} else { return null
if (this.rule[this.operator].length === 1) { })
this.rule[this.operator].push(time);
} else { const timeValue = computed(() => {
this.$set(this.rule[this.operator], 1, time); if (vartype.value === 'int_by_datetime') {
} return props.rule[operator.value]?.[0]?.[variable.value]?.[0]?.buildTime?.[1]
if (event.target.value === "custom") { }
this.$set(this.rule[this.operator], 2, 0); return rightoperand.value?.buildTime?.[1]
} })
}
}, const cardinality = computed(() => TYPEOPS[vartype.value]?.[operator.value]?.cardinality)
setTimeValue: function (val) { const operators = computed(() => TYPEOPS[vartype.value])
if (this.vartype === "int_by_datetime") {
this.$set(this.rule[this.operator][0][this.variable][0]["buildTime"], 1, val); function setVariable (event: Event) {
} else { const target = event.target as HTMLSelectElement
this.$set(this.rule[this.operator][1]["buildTime"], 1, val); const currentOp = Object.keys(props.rule)[0]
} let currentVal = props.rule[currentOp]
},
setRightOperandProductList: function (val) { if (target.value === 'and' || target.value === 'or') {
var products = { if (currentVal[0]?.var) currentVal = []
"objectList": [] props.rule[target.value] = currentVal
}; delete props.rule[currentOp]
for (var i = 0; i < val.length; i++) { } else {
products["objectList"].push({ if (currentVal !== 'and' && currentVal !== 'or' && currentVal[0] && VARS[target.value]?.type === vartype.value) {
"lookup": [ if (vartype.value === 'int_by_datetime') {
"product", const currentData = props.rule[currentOp][0][variable.value]
val[i].id, props.rule[currentOp][0] = { [target.value]: JSON.parse(JSON.stringify(currentData)) }
val[i].text } else {
] props.rule[currentOp][0].var = target.value
}); }
} } else if (VARS[target.value]?.type === 'int_by_datetime') {
if (this.rule[this.operator].length === 1) { delete props.rule[currentOp]
this.rule[this.operator].push(products); props.rule['!!'] = [{ [target.value]: [{ buildTime: [null, null] }] }]
} else { } else {
this.$set(this.rule[this.operator], 1, products); delete props.rule[currentOp]
} props.rule['!!'] = [{ var: target.value }]
}, }
setRightOperandVariationList: function (val) { }
var products = { }
"objectList": []
}; function setOperator (event: Event) {
for (var i = 0; i < val.length; i++) { const target = event.target as HTMLSelectElement
products["objectList"].push({ const currentOp = Object.keys(props.rule)[0]
"lookup": [ const currentVal = props.rule[currentOp]
"variation", delete props.rule[currentOp]
val[i].id, props.rule[target.value] = currentVal
val[i].text }
]
}); function setRightOperandNumber (event: Event) {
} const val = parseInt((event.target as HTMLInputElement).value)
if (this.rule[this.operator].length === 1) { if (props.rule[operator.value].length === 1) {
this.rule[this.operator].push(products); props.rule[operator.value].push(val)
} else { } else {
this.$set(this.rule[this.operator], 1, products); props.rule[operator.value][1] = val
} }
}, }
setRightOperandGateList: function (val) {
var products = { function setTimeTolerance (event: Event) {
"objectList": [] const val = parseInt((event.target as HTMLInputElement).value)
}; if (props.rule[operator.value].length === 2) {
for (var i = 0; i < val.length; i++) { props.rule[operator.value].push(val)
products["objectList"].push({ } else {
"lookup": [ props.rule[operator.value][2] = val
"gate", }
val[i].id, }
val[i].text
] function setTimeType (event: Event) {
}); const val = (event.target as HTMLSelectElement).value
} const time = { buildTime: [val] }
if (this.rule[this.operator].length === 1) { if (vartype.value === 'int_by_datetime') {
this.rule[this.operator].push(products); props.rule[operator.value][0][variable.value][0] = time
} else { } else {
this.$set(this.rule[this.operator], 1, products); if (props.rule[operator.value].length === 1) {
} props.rule[operator.value].push(time)
}, } else {
setRightOperandEnum: function (event) { props.rule[operator.value][1] = time
if (this.rule[this.operator].length === 1) { }
this.rule[this.operator].push(event.target.value); if (val === 'custom') {
} else { props.rule[operator.value][2] = 0
this.$set(this.rule[this.operator], 1, event.target.value); }
} }
}, }
addOperand: function () {
this.rule[this.operator].push({"": []}); function setTimeValue (val: string) {
}, if (vartype.value === 'int_by_datetime') {
wrapWithOR: function () { props.rule[operator.value][0][variable.value][0]['buildTime'][1] = val
var r = JSON.parse(JSON.stringify(this.rule)); } else {
this.$delete(this.rule, this.operator); props.rule[operator.value][1]['buildTime'][1] = val
this.$set(this.rule, "or", [r]); }
}, }
wrapWithAND: function () {
var r = JSON.parse(JSON.stringify(this.rule)); function setRightOperandProductList (val: { id: any; text: string }[]) {
this.$delete(this.rule, this.operator); const products = { objectList: val.map(item => ({ lookup: ['product', item.id, item.text] })) }
this.$set(this.rule, "and", [r]); if (props.rule[operator.value].length === 1) {
}, props.rule[operator.value].push(products)
cutOut: function () { } else {
var cop = Object.keys(this.operands[0])[0]; props.rule[operator.value][1] = products
var r = this.operands[0][cop]; }
this.$delete(this.rule, this.operator); }
this.$set(this.rule, cop, r);
}, function setRightOperandVariationList (val: { id: any; text: string }[]) {
remove: function () { const products = { objectList: val.map(item => ({ lookup: ['variation', item.id, item.text] })) }
this.$parent.rule[this.$parent.operator].splice(this.index, 1); if (props.rule[operator.value].length === 1) {
}, props.rule[operator.value].push(products)
duplicate: function () { } else {
var r = JSON.parse(JSON.stringify(this.rule)); props.rule[operator.value][1] = products
this.$parent.rule[this.$parent.operator].splice(this.index, 0, r); }
}, }
}
} function setRightOperandGateList (val: { id: any; text: string }[]) {
const products = { objectList: val.map(item => ({ lookup: ['gate', item.id, item.text] })) }
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(products)
} else {
props.rule[operator.value][1] = products
}
}
function setRightOperandEnum (event: Event) {
const val = (event.target as HTMLSelectElement).value
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(val)
} else {
props.rule[operator.value][1] = val
}
}
function addOperand () {
props.rule[operator.value].push({ '': [] })
}
function wrapWithOR () {
const r = JSON.parse(JSON.stringify(props.rule))
delete props.rule[operator.value]
props.rule.or = [r]
}
function wrapWithAND () {
const r = JSON.parse(JSON.stringify(props.rule))
delete props.rule[operator.value]
props.rule.and = [r]
}
function cutOut () {
const cop = Object.keys(operands.value[0])[0]
const r = operands.value[0][cop]
delete props.rule[operator.value]
props.rule[cop] = r
}
function remove () {
emit('remove')
}
function duplicate () {
emit('duplicate')
}
function removeChild (index: number) {
props.rule[operator.value].splice(index, 1)
}
function duplicateChild (index: number) {
const r = JSON.parse(JSON.stringify(props.rule[operator.value][index]))
props.rule[operator.value].splice(index, 0, r)
}
</script> </script>
<template lang="pug">
div(:class="classObject")
.btn-group.pull-right
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="level > 0",
type="button",
data-toggle="tooltip",
:title="TEXTS.duplicate",
@click.prevent="duplicate"
)
span.fa.fa-copy
button.checkin-rule-remove.btn.btn-xs.btn-default(
type="button",
@click.prevent="wrapWithOR"
) OR
button.checkin-rule-remove.btn.btn-xs.btn-default(
type="button",
@click.prevent="wrapWithAND"
) AND
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="operands && operands.length === 1 && (operator === 'or' || operator === 'and')",
type="button",
@click.prevent="cutOut"
)
span.fa.fa-cut
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="level > 0",
type="button",
@click.prevent="remove"
)
span.fa.fa-trash
select.form-control(:value="variable", required, @input="setVariable")
option(value="and") {{ TEXTS.and }}
option(value="or") {{ TEXTS.or }}
option(v-for="(v, name) in VARS", :key="name", :value="name") {{ v.label }}
select.form-control(
v-if="operator !== 'or' && operator !== 'and' && vartype !== 'int_by_datetime'",
:value="operator",
required,
@input="setOperator"
)
option
option(v-for="(v, name) in operators", :key="name", :value="name") {{ v.label }}
select.form-control(
v-if="vartype === 'datetime' || vartype === 'int_by_datetime'",
:value="timeType",
required,
@input="setTimeType"
)
option(value="date_from") {{ TEXTS.date_from }}
option(value="date_to") {{ TEXTS.date_to }}
option(value="date_admission") {{ TEXTS.date_admission }}
option(value="custom") {{ TEXTS.date_custom }}
option(value="customtime") {{ TEXTS.date_customtime }}
Datetimefield(
v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'custom'",
:value="timeValue",
@input="setTimeValue"
)
Timefield(
v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'customtime'",
:value="timeValue",
@input="setTimeValue"
)
input.form-control(
v-if="vartype === 'datetime' && timeType && timeType !== 'customtime' && timeType !== 'custom'",
required,
type="number",
:value="timeTolerance",
:placeholder="TEXTS.date_tolerance",
@input="setTimeTolerance"
)
select.form-control(
v-if="vartype === 'int_by_datetime'",
:value="operator",
required,
@input="setOperator"
)
option
option(v-for="(v, name) in operators", :key="name", :value="name") {{ v.label }}
input.form-control(
v-if="(vartype === 'int' || vartype === 'int_by_datetime') && cardinality > 1",
required,
type="number",
:value="rightoperand",
@input="setRightOperandNumber"
)
LookupSelect2(
v-if="vartype === 'product' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="productSelectURL",
@input="setRightOperandProductList"
)
LookupSelect2(
v-if="vartype === 'variation' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="variationSelectURL",
@input="setRightOperandVariationList"
)
LookupSelect2(
v-if="vartype === 'gate' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="gateSelectURL",
@input="setRightOperandGateList"
)
select.form-control(
v-if="vartype === 'enum_entry_status' && operator === '=='",
required,
:value="rightoperand",
@input="setRightOperandEnum"
)
option(value="absent") {{ TEXTS.status_absent }}
option(value="present") {{ TEXTS.status_present }}
.checkin-rule-childrules(v-if="operator === 'or' || operator === 'and'")
div(v-for="(op, opi) in operands", :key="opi")
CheckinRule(
v-if="typeof op === 'object'",
:rule="op",
:index="opi",
:level="level + 1",
@remove="removeChild(opi)",
@duplicate="duplicateChild(opi)"
)
button.checkin-rule-addchild.btn.btn-xs.btn-default(
type="button",
@click.prevent="addOperand"
)
span.fa.fa-plus-circle
| {{ TEXTS.condition_add }}
</template>

View File

@@ -1,25 +1,23 @@
<template> <script setup lang="ts">
<div class="checkin-rules-editor"> import { computed } from 'vue'
<checkin-rule :rule="this.$root.rules" :level="0" :index="0" v-if="hasRules"></checkin-rule> import { TEXTS } from './constants'
<button type="button" class="checkin-rule-addchild btn btn-xs btn-default" v-if="!hasRules" import { rules } from './django-interop'
@click.prevent="addRule"><span class="fa fa-plus-circle"></span> {{ this.$root.texts.condition_add }} import CheckinRule from './checkin-rule.vue'
</button>
</div> const hasRules = computed(() => !!Object.keys(rules.value).length)
</template>
<script> function addRule () {
export default { rules.value.and = []
components: { }
CheckinRule: CheckinRule.default,
},
computed: {
hasRules: function () {
return !!Object.keys(this.$root.rules).length;
}
},
methods: {
addRule: function () {
this.$set(this.$root.rules, "and", []);
},
},
}
</script> </script>
<template lang="pug">
.checkin-rules-editor
CheckinRule(v-if="hasRules", :rule="rules", :level="0", :index="0")
button.checkin-rule-addchild.btn.btn-xs.btn-default(
v-if="!hasRules",
type="button",
@click.prevent="addRule"
)
span.fa.fa-plus-circle
| {{ TEXTS.condition_add }}
</template>

View File

@@ -1,255 +1,276 @@
<template> <script setup lang="ts">
<div :class="'checkin-rules-visualization ' + (maximized ? 'maximized' : '')"> import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
<div class="tools"> import { rules } from './django-interop'
<button v-if="maximized" class="btn btn-default" type="button" @click.prevent="maximized = false"><span class="fa fa-window-close"></span></button> import VizNode from './viz-node.vue'
<button v-if="!maximized" class="btn btn-default" type="button" @click.prevent="maximized = true"><span class="fa fa-window-maximize"></span></button>
</div>
<svg :width="graph.columns * (boxWidth + marginX) + 2 * paddingX" :height="graph.height * (boxHeight + marginY)"
:viewBox="viewBox" ref="svg">
<g :transform="zoomTransform.toString()">
<viz-node v-for="(node, nodeid) in graph.nodes_by_id" :key="nodeid" :node="node"
:children="node.children.map(n => graph.nodes_by_id[n])" :nodeid="nodeid"
:boxWidth="boxWidth" :boxHeight="boxHeight" :marginX="marginX" :marginY="marginY"
:paddingX="paddingX"></viz-node>
</g>
</svg>
</div>
</template>
<script>
export default {
components: {
VizNode: VizNode.default,
},
computed: {
boxWidth() {
return 300
},
boxHeight() {
return 62
},
paddingX() {
return 50
},
marginX() {
return 50
},
marginY() {
return 20
},
contentWidth() {
return this.graph.columns * (this.boxWidth + this.marginX) + 2 * this.paddingX
},
contentHeight() {
return this.graph.height * (this.boxHeight + this.marginY)
},
viewBox() {
return `0 0 ${this.contentWidth} ${this.contentHeight}`
},
graph() {
/**
* Converts a JSON logic rule into a "flow chart".
*
* A JSON logic rule has a structure like an operator tree:
*
* OR
* |-- AND
* |-- A
* |-- B
* |-- AND
* |-- OR
* |-- C
* |-- D
* |-- E
*
* For our visualization, we want to visualize that tree as a graph one can follow along to reach a
* decision, which has the structure of a directed graph:
*
* --- A --- B --- OK!
* /
* /
* /
* --
* \
* \ --- C ---
* \ / \
* --- --- E --- OK!
* \ /
* --- D ---
*/
const graph = {
nodes_by_id: {},
children: [],
columns: -1,
}
// Step 1: Start building the graph by finding all nodes and edges declare const d3: any
let counter = 0;
const _add_to_graph = (rule) => { // returns [heads, tails]
if (typeof rule !== 'object' || rule === null) {
const node_id = (counter++).toString()
graph.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
const operator = Object.keys(rule)[0] const svg = ref<SVGSVGElement | null>(null)
const operands = rule[operator] const maximized = ref(false)
const zoom = ref<any>(null)
const defaultScale = ref(1)
const zoomTransform = ref(d3.zoomTransform({ k: 1, x: 0, y: 0 }))
if (operator === "and") { const boxWidth = 300
let children = [] const boxHeight = 62
let tails = null const paddingX = 50
operands.reverse() const marginX = 50
for (let operand of operands) { const marginY = 20
let [new_children, new_tails] = _add_to_graph(operand)
for (let new_child of new_tails) {
graph.nodes_by_id[new_child].children.push(...children)
for (let c of children) {
graph.nodes_by_id[c].parent = graph.nodes_by_id[new_child]
}
}
if (tails === null) {
tails = new_tails
}
children = new_children
}
return [children, tails]
} else if (operator === "or") {
const children = []
const tails = []
for (let operand of operands) {
let [new_children, new_tails] = _add_to_graph(operand)
children.push(...new_children)
tails.push(...new_tails)
}
return [children, tails]
} else {
const node_id = (counter++).toString()
graph.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
} interface GraphNode {
graph.children = _add_to_graph(JSON.parse(JSON.stringify(this.$root.rules)))[0] rule: any
column: number
// Step 2: We compute the "column" of every node, which is the maximum number of hops required to reach the children: string[]
// node from the root node y?: number
const _set_column_to_min = (nodes, mincol) => { parent?: GraphNode
for (let node of nodes) {
if (mincol > node.column) {
node.column = mincol
graph.columns = Math.max(mincol + 1, graph.columns)
_set_column_to_min(node.children.map(nid => graph.nodes_by_id[nid]), mincol + 1)
}
}
}
_set_column_to_min(graph.children.map(nid => graph.nodes_by_id[nid]), 0)
// Step 3: Align each node on a grid. The x position is already given by the column computed above, but we still
// need the y position. This part of the algorithm is opinionated and probably not yet the nicest solution we
// can use!
const _set_y = (node, offset) => {
if (typeof node.y === "undefined") {
// We only take the first value we found for each node
node.y = offset
}
let used = 0
for (let cid of node.children) {
used += Math.max(0, _set_y(graph.nodes_by_id[cid], offset + used) - 1)
used++
}
return used
}
_set_y(graph, 0)
// Step 4: Compute the "height" of the graph by looking at the node with the highest y value
graph.height = 1
for (let node of [...Object.values(graph.nodes_by_id)]) {
graph.height = Math.max(graph.height, node.y + 1)
}
return graph
}
},
mounted() {
this.createZoom()
},
created() {
window.addEventListener('resize', this.createZoom)
},
destroyed() {
window.removeEventListener('resize', this.createZoom)
},
watch: {
maximized() {
this.$nextTick(() => {
this.createZoom()
})
}
},
methods: {
createZoom() {
if (!this.$refs.svg) return
const viewportHeight = this.$refs.svg.clientHeight
const viewportWidth = this.$refs.svg.clientWidth
this.defaultScale = 1
this.zoom = d3
.zoom()
.scaleExtent([Math.min(this.defaultScale * 0.5, 1), Math.max(5, this.contentHeight / viewportHeight, this.contentWidth / viewportWidth)])
.extent([[0, 0], [viewportWidth, viewportHeight]])
.filter(event => {
const wheeled = event.type === 'wheel'
const mouseDrag =
event.type === 'mousedown' ||
event.type === 'mouseup' ||
event.type === 'mousemove'
const touch =
event.type === 'touchstart' ||
event.type === 'touchmove' ||
event.type === 'touchstop'
return (wheeled || mouseDrag || touch) && this.maximized
})
.wheelDelta(event => {
// In contrast to default implementation, do not use a factor 10 if ctrl is pressed
return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002)
})
.on('zoom', (event) => {
this.zoomTransform = event.transform
})
const initTransform = d3.zoomIdentity
.scale(this.defaultScale)
.translate(
0,
0
)
this.zoomTransform = initTransform
// This sets correct d3 internal state for the initial centering
d3.select(this.$refs.svg)
.call(this.zoom.transform, initTransform)
const svg = d3.select(this.$refs.svg).call(this.zoom)
svg.on('touchmove.zoom', null)
// TODO touch support
},
},
data() {
return {
maximized: false,
zoom: null,
defaultScale: 1,
zoomTransform: d3.zoomTransform({k: 1, x: 0, y: 0}),
}
}
} }
interface Graph {
nodes_by_id: Record<string, GraphNode>
children: string[]
columns: number
height: number
y?: number
}
const graph = computed<Graph>(() => {
/**
* Converts a JSON logic rule into a "flow chart".
*
* A JSON logic rule has a structure like an operator tree:
*
* OR
* |-- AND
* |-- A
* |-- B
* |-- AND
* |-- OR
* |-- C
* |-- D
* |-- E
*
* For our visualization, we want to visualize that tree as a graph one can follow along to reach a
* decision, which has the structure of a directed graph:
*
* --- A --- B --- OK!
* /
* /
* /
* --
* \
* \ --- C ---
* \ / \
* --- --- E --- OK!
* \ /
* --- D ---
*/
const graphData: Graph = {
nodes_by_id: {},
children: [],
columns: -1,
height: 1,
}
// Step 1: Start building the graph by finding all nodes and edges
let counter = 0
const _add_to_graph = (rule: any): [string[], string[]] => { // returns [heads, tails]
if (typeof rule !== 'object' || rule === null) {
const node_id = (counter++).toString()
graphData.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
const operator = Object.keys(rule)[0]
const operands = rule[operator]
if (operator === 'and') {
let children: string[] = []
let tails: string[] | null = null
operands.reverse()
for (const operand of operands) {
const [new_children, new_tails] = _add_to_graph(operand)
for (const new_child of new_tails) {
graphData.nodes_by_id[new_child].children.push(...children)
for (const c of children) {
graphData.nodes_by_id[c].parent = graphData.nodes_by_id[new_child]
}
}
if (tails === null) {
tails = new_tails
}
children = new_children
}
return [children, tails!]
} else if (operator === 'or') {
const children: string[] = []
const tails: string[] = []
for (const operand of operands) {
const [new_children, new_tails] = _add_to_graph(operand)
children.push(...new_children)
tails.push(...new_tails)
}
return [children, tails]
} else {
const node_id = (counter++).toString()
graphData.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
}
graphData.children = _add_to_graph(JSON.parse(JSON.stringify(rules.value)))[0]
// Step 2: We compute the "column" of every node, which is the maximum number of hops required to reach the
// node from the root node
const _set_column_to_min = (nodes: GraphNode[], mincol: number) => {
for (const node of nodes) {
if (mincol > node.column) {
node.column = mincol
graphData.columns = Math.max(mincol + 1, graphData.columns)
_set_column_to_min(node.children.map(nid => graphData.nodes_by_id[nid]), mincol + 1)
}
}
}
_set_column_to_min(graphData.children.map(nid => graphData.nodes_by_id[nid]), 0)
// Step 3: Align each node on a grid. The x position is already given by the column computed above, but we still
// need the y position. This part of the algorithm is opinionated and probably not yet the nicest solution we
// can use!
const _set_y = (node: Graph | GraphNode, offset: number): number => {
if (typeof node.y === 'undefined') {
// We only take the first value we found for each node
node.y = offset
}
let used = 0
for (const cid of node.children) {
used += Math.max(0, _set_y(graphData.nodes_by_id[cid], offset + used) - 1)
used++
}
return used
}
_set_y(graphData, 0)
// Step 4: Compute the "height" of the graph by looking at the node with the highest y value
graphData.height = 1
for (const node of [...Object.values(graphData.nodes_by_id)]) {
graphData.height = Math.max(graphData.height, (node.y ?? 0) + 1)
}
return graphData
})
const contentWidth = computed(() => {
return graph.value.columns * (boxWidth + marginX) + 2 * paddingX
})
const contentHeight = computed(() => {
return graph.value.height * (boxHeight + marginY)
})
const viewBox = computed(() => {
return `0 0 ${contentWidth.value} ${contentHeight.value}`
})
function createZoom () {
if (!svg.value) return
const viewportHeight = svg.value.clientHeight
const viewportWidth = svg.value.clientWidth
defaultScale.value = 1
zoom.value = d3
.zoom()
.scaleExtent([Math.min(defaultScale.value * 0.5, 1), Math.max(5, contentHeight.value / viewportHeight, contentWidth.value / viewportWidth)])
.extent([[0, 0], [viewportWidth, viewportHeight]])
.filter((event: any) => {
const wheeled = event.type === 'wheel'
const mouseDrag
= event.type === 'mousedown'
|| event.type === 'mouseup'
|| event.type === 'mousemove'
const touch
= event.type === 'touchstart'
|| event.type === 'touchmove'
|| event.type === 'touchstop'
return (wheeled || mouseDrag || touch) && maximized.value
})
.wheelDelta((event: any) => {
// In contrast to default implementation, do not use a factor 10 if ctrl is pressed
return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002)
})
.on('zoom', (event: any) => {
zoomTransform.value = event.transform
})
const initTransform = d3.zoomIdentity
.scale(defaultScale.value)
.translate(0, 0)
zoomTransform.value = initTransform
// This sets correct d3 internal state for the initial centering
d3.select(svg.value)
.call(zoom.value.transform, initTransform)
const svgSelection = d3.select(svg.value).call(zoom.value)
svgSelection.on('touchmove.zoom', null)
// TODO touch support
}
watch(maximized, () => {
nextTick(() => {
createZoom()
})
})
onMounted(() => {
createZoom()
window.addEventListener('resize', createZoom)
})
onUnmounted(() => {
window.removeEventListener('resize', createZoom)
})
</script> </script>
<template lang="pug">
div(:class="'checkin-rules-visualization ' + (maximized ? 'maximized' : '')")
.tools
button.btn.btn-default(
v-if="maximized",
type="button",
@click.prevent="maximized = false"
)
span.fa.fa-window-close
button.btn.btn-default(
v-if="!maximized",
type="button",
@click.prevent="maximized = true"
)
span.fa.fa-window-maximize
svg(
ref="svg",
:width="contentWidth",
:height="contentHeight",
:viewBox="viewBox"
)
g(:transform="zoomTransform.toString()")
VizNode(
v-for="(node, nodeid) in graph.nodes_by_id",
:key="nodeid",
:node="node",
:children="node.children.map((n: string) => graph.nodes_by_id[n])",
:nodeid="nodeid",
:boxWidth="boxWidth",
:boxHeight="boxHeight",
:marginX="marginX",
:marginY="marginY",
:paddingX="paddingX"
)
</template>

View File

@@ -0,0 +1,193 @@
/* global gettext, pgettext */
export const TEXTS = {
and: gettext('All of the conditions below (AND)'),
or: gettext('At least one of the conditions below (OR)'),
date_from: gettext('Event start'),
date_to: gettext('Event end'),
date_admission: gettext('Event admission'),
date_custom: gettext('custom date and time'),
date_customtime: gettext('custom time'),
date_tolerance: gettext('Tolerance (minutes)'),
condition_add: gettext('Add condition'),
minutes: gettext('minutes'),
duplicate: gettext('Duplicate'),
status_present: pgettext('entry_status', 'present'),
status_absent: pgettext('entry_status', 'absent'),
}
export const TYPEOPS = {
// Every change to our supported JSON logic must be done
// * in pretix.base.services.checkin
// * in pretix.base.models.checkin
// * in pretix.helpers.jsonlogic_boolalg
// * in checkinrules.js
// * in libpretixsync
// * in pretixscan-ios
product: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
variation: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
gate: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
datetime: {
isBefore: {
label: gettext('is before'),
cardinality: 2,
},
isAfter: {
label: gettext('is after'),
cardinality: 2,
},
},
enum_entry_status: {
'==': {
label: gettext('='),
cardinality: 2,
},
},
int_by_datetime: {
'<': {
label: '<',
cardinality: 2,
},
'<=': {
label: '≤',
cardinality: 2,
},
'>': {
label: '>',
cardinality: 2,
},
'>=': {
label: '≥',
cardinality: 2,
},
'==': {
label: '=',
cardinality: 2,
},
'!=': {
label: '≠',
cardinality: 2,
},
},
int: {
'<': {
label: '<',
cardinality: 2,
},
'<=': {
label: '≤',
cardinality: 2,
},
'>': {
label: '>',
cardinality: 2,
},
'>=': {
label: '≥',
cardinality: 2,
},
'==': {
label: '=',
cardinality: 2,
},
'!=': {
label: '≠',
cardinality: 2,
},
},
}
export const VARS = {
product: {
label: gettext('Product'),
type: 'product',
},
variation: {
label: gettext('Product variation'),
type: 'variation',
},
gate: {
label: gettext('Gate'),
type: 'gate',
},
now: {
label: gettext('Current date and time'),
type: 'datetime',
},
now_isoweekday: {
label: gettext('Current day of the week (1 = Monday, 7 = Sunday)'),
type: 'int',
},
entry_status: {
label: gettext('Current entry status'),
type: 'enum_entry_status',
},
entries_number: {
label: gettext('Number of previous entries'),
type: 'int',
},
entries_today: {
label: gettext('Number of previous entries since midnight'),
type: 'int',
},
entries_since: {
label: gettext('Number of previous entries since'),
type: 'int_by_datetime',
},
entries_before: {
label: gettext('Number of previous entries before'),
type: 'int_by_datetime',
},
entries_days: {
label: gettext('Number of days with a previous entry'),
type: 'int',
},
entries_days_since: {
label: gettext('Number of days with a previous entry since'),
type: 'int_by_datetime',
},
entries_days_before: {
label: gettext('Number of days with a previous entry before'),
type: 'int_by_datetime',
},
minutes_since_last_entry: {
label: gettext('Minutes since last entry (-1 on first entry)'),
type: 'int',
},
minutes_since_first_entry: {
label: gettext('Minutes since first entry (-1 on first entry)'),
type: 'int',
},
}
export const DATETIME_OPTIONS = {
format: document.body.dataset.datetimeformat,
locale: document.body.dataset.datetimelocale,
useCurrent: false,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
}

View File

@@ -1,55 +1,45 @@
<template> <script setup lang="ts">
<input class="form-control"> import { ref, watch, onMounted, onUnmounted } from 'vue'
</template> import { DATETIME_OPTIONS } from './constants'
<script>
export default { const props = defineProps<{
props: ["required", "value"], required?: boolean
template: (''), value?: string
mounted: function () { }>()
var vm = this;
var multiple = this.multiple; const emit = defineEmits<{
$(this.$el) input: [value: string]
.datetimepicker(this.opts()) }>()
.trigger("change")
.on("dp.change", function (e) { const input = ref<HTMLInputElement | null>(null)
vm.$emit("input", $(this).data('DateTimePicker').date().toISOString());
}); watch(() => props.value, (val) => {
if (!vm.value) { $(input.value).data('DateTimePicker').date(moment(val))
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0)); })
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value)); onMounted(() => {
} $(input.value)
}, .datetimepicker({
methods: { ...DATETIME_OPTIONS,
opts: function () { showClear: props.required,
return { })
format: $("body").attr("data-datetimeformat"), .trigger('change')
locale: $("body").attr("data-datetimelocale"), .on('dp.change', function (this: HTMLElement) {
useCurrent: false, emit('input', $(this).data('DateTimePicker').date().toISOString())
showClear: this.required, })
icons: { if (!props.value) {
time: 'fa fa-clock-o', $(input.value).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
date: 'fa fa-calendar', } else {
up: 'fa fa-chevron-up', $(input.value).data('DateTimePicker').date(moment(props.value))
down: 'fa fa-chevron-down', }
previous: 'fa fa-chevron-left', })
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot', onUnmounted(() => {
clear: 'fa fa-trash', $(input.value)
close: 'fa fa-remove' .off()
} .datetimepicker('destroy')
}; })
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script> </script>
<template lang="pug">
input.form-control(ref="input")
</template>

View File

@@ -0,0 +1,68 @@
import { ref, watch } from 'vue'
export const allProducts = ref(false)
export const limitProducts = ref<number[]>([])
function updateProducts () {
allProducts.value = document.querySelector<HTMLInputElement>('#id_all_products')?.checked ?? false
limitProducts.value = Array.from(document.querySelectorAll<HTMLInputElement>('input[name=limit_products]:checked')).map(el => parseInt(el.value))
}
// listen to change events for products
document.querySelectorAll('#id_all_products, input[name=limit_products]').forEach(el => el.addEventListener('change', updateProducts))
updateProducts()
export const rules = ref<any>({})
// grab rules from hidden input
const rulesInput = document.querySelector<HTMLInputElement>('#id_rules')
if (rulesInput?.value) {
rules.value = JSON.parse(rulesInput.value)
}
// sync back to hidden input
watch(rules, (newVal) => {
if (!rulesInput) return
rulesInput.value = JSON.stringify(newVal)
}, { deep: true })
export const items = ref<any[]>([])
const itemsEl = document.querySelector('#items')
if (itemsEl?.textContent) {
items.value = JSON.parse(itemsEl.textContent || '[]')
function checkForInvalidIds (validProducts: Record<string, string>, validVariations: Record<string, string>, rule: any) {
if (rule['and']) {
for (const child of rule['and'])
checkForInvalidIds(validProducts, validVariations, child)
} else if (rule['or']) {
for (const child of rule['or'])
checkForInvalidIds(validProducts, validVariations, child)
} else if (rule['inList'] && rule['inList'][0]['var'] === 'product') {
for (const item of rule['inList'][1]['objectList']) {
if (!validProducts[item['lookup'][1]])
item['lookup'][2] = '[' + gettext('Error: Product not found!') + ']'
else
item['lookup'][2] = validProducts[item['lookup'][1]]
}
} else if (rule['inList'] && rule['inList'][0]['var'] === 'variation') {
for (const item of rule['inList'][1]['objectList']) {
if (!validVariations[item['lookup'][1]])
item['lookup'][2] = '[' + gettext('Error: Variation not found!') + ']'
else
item['lookup'][2] = validVariations[item['lookup'][1]]
}
}
}
checkForInvalidIds(
Object.fromEntries(items.value.map(p => [p.id, p.name])),
Object.fromEntries(items.value.flatMap(p => p.variations?.map(v => [v.id, p.name + ' ' + v.name]) ?? [])),
rules.value
)
}
export const productSelectURL = ref(document.querySelector('#product-select2')?.textContent)
export const variationSelectURL = ref(document.querySelector('#variations-select2')?.textContent)
export const gateSelectURL = ref(document.querySelector('#gate-select2')?.textContent)

View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#rules-editor')
app.config.errorHandler = (error, _vm, info) => {
// vue fatals on errors by default, which is a weird choice
// https://github.com/vuejs/core/issues/3525
// https://github.com/vuejs/router/discussions/2435
console.error('[VUE]', info, error)
}

View File

@@ -1,93 +1,93 @@
function convert_to_dnf(rules) { export function convertToDNF (rules) {
// Converts a set of rules to disjunctive normal form, i.e. returns something of the form // Converts a set of rules to disjunctive normal form, i.e. returns something of the form
// `(a AND b AND c) OR (a AND d AND f)` // `(a AND b AND c) OR (a AND d AND f)`
// without further nesting. // without further nesting.
if (typeof rules !== "object" || Array.isArray(rules) || rules === null) { if (typeof rules !== 'object' || Array.isArray(rules) || rules === null) {
return rules return rules
} }
function _distribute_or_over_and(r) { function _distribute_or_over_and (r) {
var operator = Object.keys(r)[0] let operator = Object.keys(r)[0]
var values = r[operator] let values = r[operator]
if (operator === "and") { if (operator === 'and') {
var arg_to_distribute = null let arg_to_distribute = null
var other_args = [] let other_args = []
for (var arg of values) { for (let arg of values) {
if (typeof arg === "object" && !Array.isArray(arg) && typeof arg["or"] !== "undefined" && arg_to_distribute === null) { if (typeof arg === 'object' && !Array.isArray(arg) && typeof arg['or'] !== 'undefined' && arg_to_distribute === null) {
arg_to_distribute = arg arg_to_distribute = arg
} else { } else {
other_args.push(arg) other_args.push(arg)
} }
} }
if (arg_to_distribute === null) { if (arg_to_distribute === null) {
return r return r
} }
var or_operands = [] let or_operands = []
for (var dval of arg_to_distribute["or"]) { for (let dval of arg_to_distribute['or']) {
or_operands.push({"and": other_args.concat([dval])}) or_operands.push({ and: other_args.concat([dval]) })
} }
return { return {
"or": or_operands or: or_operands
} }
} else if (!operator) { } else if (!operator) {
return r return r
} else if (operator === "!" || operator === "!!" || operator === "?:" || operator === "if") { } else if (operator === '!' || operator === '!!' || operator === '?:' || operator === 'if') {
console.warn("Operator " + operator + " currently unsupported by convert_to_dnf") console.warn('Operator ' + operator + ' currently unsupported by convert_to_dnf')
return r return r
} else { } else {
return r return r
} }
} }
function _simplify_chained_operators(r) { function _simplify_chained_operators (r) {
// Simplify `(a OR b) OR (c or d)` to `a OR b OR c OR d` and the same with `AND` // Simplify `(a OR b) OR (c or d)` to `a OR b OR c OR d` and the same with `AND`
if (typeof r !== "object" || Array.isArray(r)) { if (typeof r !== 'object' || Array.isArray(r)) {
return r return r
} }
var operator = Object.keys(r)[0] let operator = Object.keys(r)[0]
var values = r[operator] let values = r[operator]
if (operator !== "or" && operator !== "and") { if (operator !== 'or' && operator !== 'and') {
return r return r
} }
var new_values = [] let new_values = []
for (var v of values) { for (let v of values) {
if (typeof v !== "object" || Array.isArray(v) || typeof v[operator] === "undefined") { if (typeof v !== 'object' || Array.isArray(v) || typeof v[operator] === 'undefined') {
new_values.push(v) new_values.push(v)
} else { } else {
new_values.push(...v[operator]) new_values.push(...v[operator])
} }
} }
var result = {} let result = {}
result[operator] = new_values result[operator] = new_values
return result return result
} }
// Run _distribute_or_over_and on until it no longer changes anything. Do so recursively // Run _distribute_or_over_and on until it no longer changes anything. Do so recursively
// for the full expression tree. // for the full expression tree.
var old_rules = rules let old_rules = rules
while (true) { while (true) {
rules = _distribute_or_over_and(rules) rules = _distribute_or_over_and(rules)
var operator = Object.keys(rules)[0] let operator = Object.keys(rules)[0]
var values = rules[operator] let values = rules[operator]
var no_list = false let no_list = false
if (!Array.isArray(values)) { if (!Array.isArray(values)) {
values = [values] values = [values]
no_list = true no_list = true
} }
rules = {} rules = {}
if (!no_list) { if (!no_list) {
rules[operator] = [] rules[operator] = []
for (var v of values) { for (let v of values) {
rules[operator].push(convert_to_dnf(v)) rules[operator].push(convertToDNF(v))
} }
} else { } else {
rules[operator] = convert_to_dnf(values[0]) rules[operator] = convertToDNF(values[0])
} }
if (JSON.stringify(old_rules) === JSON.stringify(rules)) { // Let's hope this is good enough... if (JSON.stringify(old_rules) === JSON.stringify(rules)) { // Let's hope this is good enough...
break break
} }
old_rules = rules old_rules = rules
} }
rules = _simplify_chained_operators(rules) rules = _simplify_chained_operators(rules)
return rules return rules
} }

View File

@@ -1,97 +1,116 @@
<template> <script setup lang="ts">
<select> import { ref, watch, onMounted, onUnmounted } from 'vue'
<slot></slot>
</select> declare const $: any
</template>
<script> export interface ObjectListItem {
export default { lookup: [string, number | string, string]
props: ["required", "value", "placeholder", "url", "multiple"], }
template: ('<select>\n' +
' <slot></slot>\n' + export interface ObjectList {
' </select>'), objectList: ObjectListItem[]
mounted: function () { }
this.build();
}, const props = defineProps<{
methods: { required?: boolean
build: function () { value?: ObjectList
var vm = this; placeholder?: string
var multiple = this.multiple; url?: string
$(this.$el) multiple?: boolean
.empty() }>()
.select2(this.opts())
.val(this.value || "") const emit = defineEmits<{
.trigger("change") input: [value: any[]]
// emit event on change. }>()
.on("change", function (e) {
vm.$emit("input", $(this).select2('data')); const select = ref<HTMLSelectElement | null>(null)
});
if (vm.value) { function opts () {
for (var i = 0; i < vm.value["objectList"].length; i++) { return {
var option = new Option(vm.value["objectList"][i]["lookup"][2], vm.value["objectList"][i]["lookup"][1], true, true); theme: 'bootstrap',
$(vm.$el).append(option); delay: 100,
} width: '100%',
} multiple: true,
$(vm.$el).trigger("change"); allowClear: props.required,
}, language: $('body').attr('data-select2-locale'),
opts: function () { ajax: {
return { url: props.url,
theme: "bootstrap", data: function (params: { term: string; page?: number }) {
delay: 100, return {
width: '100%', query: params.term,
multiple: true, page: params.page || 1
allowClear: this.required, }
language: $("body").attr("data-select2-locale"), }
ajax: { },
url: this.url, templateResult: function (res: { id?: string; text: string }) {
data: function (params) { if (!res.id) {
return { return res.text
query: params.term, }
page: params.page || 1 const $ret = $('<span>').append(
} $('<span>').addClass('primary').append($('<div>').text(res.text).html())
} )
}, return $ret
templateResult: function (res) { },
if (!res.id) { }
return res.text; }
}
var $ret = $("<span>").append( function build () {
$("<span>").addClass("primary").append($("<div>").text(res.text).html()) $(select.value)
); .empty()
return $ret; .select2(opts())
}, .val(props.value || '')
}; .trigger('change')
} .on('change', function (this: HTMLElement) {
}, emit('input', $(this).select2('data'))
watch: { })
placeholder: function (val) { if (props.value) {
$(this.$el).select2("destroy"); for (let i = 0; i < props.value.objectList.length; i++) {
this.build(); const option = new Option(props.value.objectList[i].lookup[2], String(props.value.objectList[i].lookup[1]), true, true)
}, $(select.value).append(option)
required: function (val) { }
$(this.$el).select2("destroy"); }
this.build(); $(select.value).trigger('change')
}, }
url: function (val) {
$(this.$el).select2("destroy"); watch(() => props.placeholder, () => {
this.build(); $(select.value).select2('destroy')
}, build()
value: function (newval, oldval) { })
if (JSON.stringify(newval) !== JSON.stringify(oldval)) {
$(this.$el).empty(); watch(() => props.required, () => {
if (newval) { $(select.value).select2('destroy')
for (var i = 0; i < newval["objectList"].length; i++) { build()
var option = new Option(newval["objectList"][i]["lookup"][2], newval["objectList"][i]["lookup"][1], true, true); })
$(this.$el).append(option);
} watch(() => props.url, () => {
} $(select.value).select2('destroy')
$(this.$el).trigger("change"); build()
} })
},
}, watch(() => props.value, (newval, oldval) => {
destroyed: function () { if (JSON.stringify(newval) !== JSON.stringify(oldval)) {
$(this.$el) $(select.value).empty()
.off() if (newval) {
.select2("destroy"); for (let i = 0; i < newval.objectList.length; i++) {
} const option = new Option(newval.objectList[i].lookup[2], String(newval.objectList[i].lookup[1]), true, true)
} $(select.value).append(option)
}
}
$(select.value).trigger('change')
}
})
onMounted(() => {
build()
})
onUnmounted(() => {
$(select.value)
.off()
.select2('destroy')
})
</script> </script>
<template lang="pug">
select(ref="select")
slot
</template>

View File

@@ -1,55 +1,45 @@
<template> <script setup lang="ts">
<input class="form-control"> import { ref, watch, onMounted, onUnmounted } from 'vue'
</template> import { DATETIME_OPTIONS } from './constants'
<script>
export default { const props = defineProps<{
props: ["required", "value"], required?: boolean
template: (''), value?: string
mounted: function () { }>()
var vm = this;
var multiple = this.multiple; const emit = defineEmits<{
$(this.$el) input: [value: string]
.datetimepicker(this.opts()) }>()
.trigger("change")
.on("dp.change", function (e) { const input = ref<HTMLInputElement | null>(null)
vm.$emit("input", $(this).data('DateTimePicker').date().format("HH:mm:ss"));
}); watch(() => props.value, (val) => {
if (!vm.value) { $(input.value).data('DateTimePicker').date(val)
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0)); })
} else {
$(this.$el).data("DateTimePicker").date(vm.value); onMounted(() => {
} $(input.value)
}, .datetimepicker({
methods: { ...DATETIME_OPTIONS,
opts: function () { showClear: props.required,
return { })
format: $("body").attr("data-timeformat"), .trigger('change')
locale: $("body").attr("data-datetimelocale"), .on('dp.change', function (this: HTMLElement) {
useCurrent: false, emit('input', $(this).data('DateTimePicker').date().format('HH:mm:ss'))
showClear: this.required, })
icons: { if (!props.value) {
time: 'fa fa-clock-o', $(input.value).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
date: 'fa fa-calendar', } else {
up: 'fa fa-chevron-up', $(input.value).data('DateTimePicker').date(props.value)
down: 'fa fa-chevron-down', }
previous: 'fa fa-chevron-left', })
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot', onUnmounted(() => {
clear: 'fa fa-trash', $(input.value)
close: 'fa fa-remove' .off()
} .datetimepicker('destroy')
}; })
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(val);
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script> </script>
<template lang="pug">
input.form-control(ref="input")
</template>

View File

@@ -1,255 +1,242 @@
<template> <script setup lang="ts">
<g> import { computed } from 'vue'
<path v-for="e in edges" :d="e" class="edge"></path> import { TEXTS, VARS, TYPEOPS } from './constants'
<path v-if="rootEdge" :d="rootEdge" class="edge"></path>
<path v-if="!node.children.length" :d="checkEdge" class="edge"></path>
<rect :width="boxWidth" :height="boxHeight" :x="x" :y="y" :class="nodeClass" rx="5">
</rect>
<foreignObject :width="boxWidth - 10" :height="boxHeight - 10" :x="x + 5" :y="y + 5"> declare const $: any
<div xmlns="http://www.w3.org/1999/xhtml" class="text"> declare const moment: any
<span v-if="vardata && vardata.type === 'int'">
<span v-if="variable.startsWith('entries_')" class="fa fa-sign-in"></span>
{{ vardata.label }}
<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
<span v-else-if="vardata && vardata.type === 'int_by_datetime'">
<span v-if="variable.startsWith('entries_')" class="fa fa-sign-in"></span>
{{ vardata.label }}
<span v-if="node.rule[operator][0][variable][0].buildTime[0] === 'custom'">
{{ df(node.rule[operator][0][variable][0].buildTime[1]) }}
</span>
<span v-else-if="node.rule[operator][0][variable][0].buildTime[0] === 'customtime'">
{{ tf(node.rule[operator][0][variable][0].buildTime[1]) }}
</span>
<span v-else>
{{ this.$root.texts[node.rule[operator][0][variable][0].buildTime[0]] }}
</span>
<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
<span v-else-if="vardata && variable === 'now'">
<span class="fa fa-clock-o"></span> {{ vardata.label }}<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }}<br>
<span v-if="rightoperand.buildTime[0] === 'custom'">
{{ df(rightoperand.buildTime[1]) }}
</span>
<span v-else-if="rightoperand.buildTime[0] === 'customtime'">
{{ tf(rightoperand.buildTime[1]) }}
</span>
<span v-else>
{{ this.$root.texts[rightoperand.buildTime[0]] }}
</span>
<span v-if="operands[2]">
<span v-if="operator === 'isBefore'">+</span>
<span v-else>-</span>
{{ operands[2] }}
{{ this.$root.texts.minutes }}
</span>
</strong>
</span>
<span v-else-if="vardata && operator === 'inList'">
<span class="fa fa-sign-in" v-if="variable === 'gate'"></span>
<span class="fa fa-ticket" v-else></span>
{{ vardata.label }}
<span v-if="varresult !== null">
({{varresult}})
</span>
<br>
<strong>
{{ rightoperand.objectList.map((o) => o.lookup[2]).join(", ") }}
</strong>
</span>
<span v-else-if="vardata && vardata.type === 'enum_entry_status'">
<span class="fa fa-check-circle-o"></span>
{{ vardata.label }}
<span v-if="varresult !== null">
({{varresult}})
</span>
<br>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
</div>
</foreignObject>
<g v-if="result === false" :transform="`translate(${x + boxWidth - 15}, ${y - 10})`"> interface GraphNode {
<ellipse fill="#fff" cx="14.685823" cy="14.318233" rx="12.140151" ry="11.55523" /> rule: any
<path d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z" column: number
class="error" /> children: string[]
</g> y: number
<g v-if="result === true" :transform="`translate(${x + boxWidth - 15}, ${y - 10})`"> parent?: GraphNode
<ellipse fill="#fff" cx="14.685823" cy="14.318233" rx="12.140151" ry="11.55523" /> }
<path d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z"
class="check"/>
</g>
<g v-if="!node.children.length && (resultInclParents === null || resultInclParents === true)" :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`">
<path d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z"
class="check"/>
</g>
<g v-if="!node.children.length && (resultInclParents === false)" :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`">
<path d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z"
class="error" />
</g>
</g>
</template>
<script>
export default {
props: { const props = defineProps<{
node: Object, node: GraphNode
nodeid: String, nodeid: string
children: Array, children: GraphNode[]
boxWidth: Number, boxWidth: number
boxHeight: Number, boxHeight: number
marginX: Number, marginX: number
marginY: Number, marginY: number
paddingX: Number, paddingX: number
}, }>()
computed: {
x() {
return this.node.column * (this.boxWidth + this.marginX) + this.marginX / 2 + this.paddingX
},
y() {
return this.node.y * (this.boxHeight + this.marginY) + this.marginY / 2
},
edges() {
const startX = this.x + this.boxWidth + 1
const startY = this.y + this.boxHeight / 2
return this.children.map((c) => {
const endX = (c.column * (this.boxWidth + this.marginX) + this.marginX / 2 + this.paddingX) - 1
const endY = (c.y * (this.boxHeight + this.marginY) + this.marginY / 2) + this.boxHeight / 2
return ` const x = computed(() => {
return props.node.column * (props.boxWidth + props.marginX) + props.marginX / 2 + props.paddingX
})
const y = computed(() => {
return props.node.y * (props.boxHeight + props.marginY) + props.marginY / 2
})
const edges = computed(() => {
const startX = x.value + props.boxWidth + 1
const startY = y.value + props.boxHeight / 2
return props.children.map((c) => {
const endX = (c.column * (props.boxWidth + props.marginX) + props.marginX / 2 + props.paddingX) - 1
const endY = (c.y * (props.boxHeight + props.marginY) + props.marginY / 2) + props.boxHeight / 2
return `
M ${startX} ${startY} M ${startX} ${startY}
L ${endX - 50} ${startY} L ${endX - 50} ${startY}
C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)} C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)}
L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)} L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)}
C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY} C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY}
` `
}) })
}, })
checkEdge() {
const startX = this.x + this.boxWidth + 1
const startY = this.y + this.boxHeight / 2
return `M ${startX} ${startY} L ${startX + 25} ${startY}` const checkEdge = computed(() => {
}, const startX = x.value + props.boxWidth + 1
rootEdge() { const startY = y.value + props.boxHeight / 2
if (this.node.column > 0) {
return
}
const startX = 0
const startY = this.boxHeight / 2 + this.marginY / 2
const endX = this.x - 1
const endY = this.y + this.boxHeight / 2
return ` return `M ${startX} ${startY} L ${startX + 25} ${startY}`
})
const rootEdge = computed(() => {
if (props.node.column > 0) {
return
}
const startX = 0
const startY = props.boxHeight / 2 + props.marginY / 2
const endX = x.value - 1
const endY = y.value + props.boxHeight / 2
return `
M ${startX} ${startY} M ${startX} ${startY}
L ${endX - 50} ${startY} L ${endX - 50} ${startY}
C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)} C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)}
L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)} L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)}
C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY} C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY}
` `
}, })
variable () {
const op = this.operator;
if (this.node.rule[op] && this.node.rule[op][0]) {
if (this.node.rule[op][0]["entries_since"]) {
return "entries_since";
}
if (this.node.rule[op][0]["entries_before"]) {
return "entries_before";
}
if (this.node.rule[op][0]["entries_days_since"]) {
return "entries_days_since";
}
if (this.node.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return this.node.rule[op][0]["var"];
} else {
return "";
}
},
vardata () {
return this.$root.VARS[this.variable];
},
varresult () {
const op = this.operator;
if (this.node.rule[op] && this.node.rule[op][0]) {
if (typeof this.node.rule[op][0]["__result"] === "undefined")
return null;
return this.node.rule[op][0]["__result"];
} else {
return "";
}
},
rightoperand () {
const op = this.operator;
if (this.node.rule[op] && typeof this.node.rule[op][1] !== "undefined") {
return this.node.rule[op][1];
} else {
return null;
}
},
op: function () {
return this.$root.TYPEOPS[this.vardata.type][this.operator]
},
operands: function () {
return this.node.rule[this.operator]
},
operator: function () {
return Object.keys(this.node.rule).filter(function (k) { return !k.startsWith("__") })[0];
},
result: function () {
return typeof this.node.rule.__result == "undefined" ? null : !!this.node.rule.__result
},
resultInclParents: function () {
if (typeof this.node.rule.__result == "undefined")
return null
function _p(node) { const operator = computed(() => {
if (node.parent) { return Object.keys(props.node.rule).filter((k) => !k.startsWith('__'))[0]
return node.rule.__result && _p(node.parent) })
}
return node.rule.__result const variable = computed(() => {
} const op = operator.value
return _p(this.node) if (props.node.rule[op] && props.node.rule[op][0]) {
}, if (props.node.rule[op][0]['entries_since']) {
nodeClass: function () { return 'entries_since'
return { }
"node": true, if (props.node.rule[op][0]['entries_before']) {
"node-true": this.result === true, return 'entries_before'
"node-false": this.result === false, }
} if (props.node.rule[op][0]['entries_days_since']) {
} return 'entries_days_since'
}, }
methods: { if (props.node.rule[op][0]['entries_days_before']) {
df (val) { return 'entries_days_before'
const format = $("body").attr("data-datetimeformat") }
return moment(val).format(format) return props.node.rule[op][0]['var']
}, } else {
tf (val) { return ''
const format = $("body").attr("data-timeformat") }
return moment(val, "HH:mm:ss").format(format) })
}
}, const vardata = computed(() => {
} return VARS[variable.value as keyof typeof VARS]
})
const varresult = computed(() => {
const op = operator.value
if (props.node.rule[op] && props.node.rule[op][0]) {
if (typeof props.node.rule[op][0]['__result'] === 'undefined')
return null
return props.node.rule[op][0]['__result']
} else {
return ''
}
})
const rightoperand = computed(() => {
const op = operator.value
if (props.node.rule[op] && typeof props.node.rule[op][1] !== 'undefined') {
return props.node.rule[op][1]
} else {
return null
}
})
const op = computed(() => {
return TYPEOPS[vardata.value.type as keyof typeof TYPEOPS]?.[operator.value as any]
})
const operands = computed(() => {
return props.node.rule[operator.value]
})
const result = computed(() => {
return typeof props.node.rule.__result === 'undefined' ? null : !!props.node.rule.__result
})
const resultInclParents = computed(() => {
if (typeof props.node.rule.__result === 'undefined') return null
function _p (node: GraphNode): boolean {
if (node.parent) {
return node.rule.__result && _p(node.parent)
}
return node.rule.__result
}
return _p(props.node)
})
const nodeClass = computed(() => {
return {
node: true,
'node-true': result.value === true,
'node-false': result.value === false,
}
})
function df (val: string) {
const format = $('body').attr('data-datetimeformat')
return moment(val).format(format)
}
function tf (val: string) {
const format = $('body').attr('data-timeformat')
return moment(val, 'HH:mm:ss').format(format)
}
</script> </script>
<template lang="pug">
g
path.edge(v-for="e in edges", :key="e", :d="e")
path.edge(v-if="rootEdge", :d="rootEdge")
path.edge(v-if="!node.children.length", :d="checkEdge")
rect(:width="boxWidth", :height="boxHeight", :x="x", :y="y", :class="nodeClass", rx="5")
foreignObject(:width="boxWidth - 10", :height="boxHeight - 10", :x="x + 5", :y="y + 5")
div.text(xmlns="http://www.w3.org/1999/xhtml")
span(v-if="vardata && vardata.type === 'int'")
span.fa.fa-sign-in(v-if="variable.startsWith('entries_')")
| {{ vardata.label }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op.label }} {{ rightoperand }}
span(v-else-if="vardata && vardata.type === 'int_by_datetime'")
span.fa.fa-sign-in(v-if="variable.startsWith('entries_')")
| {{ vardata.label }}
span(v-if="node.rule[operator][0][variable][0].buildTime[0] === 'custom'")
| {{ df(node.rule[operator][0][variable][0].buildTime[1]) }}
span(v-else-if="node.rule[operator][0][variable][0].buildTime[0] === 'customtime'")
| {{ tf(node.rule[operator][0][variable][0].buildTime[1]) }}
span(v-else)
| {{ TEXTS[node.rule[operator][0][variable][0].buildTime[0]] }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op.label }} {{ rightoperand }}
span(v-else-if="vardata && variable === 'now'")
span.fa.fa-clock-o
| {{ vardata.label }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op.label }}
br
span(v-if="rightoperand.buildTime[0] === 'custom'")
| {{ df(rightoperand.buildTime[1]) }}
span(v-else-if="rightoperand.buildTime[0] === 'customtime'")
| {{ tf(rightoperand.buildTime[1]) }}
span(v-else)
| {{ TEXTS[rightoperand.buildTime[0]] }}
span(v-if="operands[2]")
span(v-if="operator === 'isBefore'") +
span(v-else) -
| {{ operands[2] }}
| {{ TEXTS.minutes }}
span(v-else-if="vardata && operator === 'inList'")
span.fa.fa-sign-in(v-if="variable === 'gate'")
span.fa.fa-ticket(v-else)
| {{ vardata.label }}
span(v-if="varresult !== null") ({{ varresult }})
br
strong
| {{ rightoperand.objectList.map((o: any) => o.lookup[2]).join(", ") }}
span(v-else-if="vardata && vardata.type === 'enum_entry_status'")
span.fa.fa-check-circle-o
| {{ vardata.label }}
span(v-if="varresult !== null") ({{ varresult }})
br
strong
| {{ op.label }} {{ rightoperand }}
g(v-if="result === false", :transform="`translate(${x + boxWidth - 15}, ${y - 10})`")
ellipse(fill="#fff", cx="14.685823", cy="14.318233", rx="12.140151", ry="11.55523")
path.error(d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z")
g(v-if="result === true", :transform="`translate(${x + boxWidth - 15}, ${y - 10})`")
ellipse(fill="#fff", cx="14.685823", cy="14.318233", rx="12.140151", ry="11.55523")
path.check(d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z")
g(v-if="!node.children.length && (resultInclParents === null || resultInclParents === true)", :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`")
path.check(d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z")
g(v-if="!node.children.length && (resultInclParents === false)", :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`")
path.error(d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z")
</template>