Action WCS configurables dans la cellule fiche (#75908) #252

Open
csechet wants to merge 8 commits from wip/75908-wcs-trigger into main
18 changed files with 706 additions and 23 deletions

154
.eslintrc.yml Normal file
View File

@ -0,0 +1,154 @@
env:
browser: true
es6: true
ignorePatterns:
- "combo/apps/dashboard/static/js/dashboard.js"
- "combo/apps/dataviz/static/js/chartngcell.js"
- "combo/apps/dataviz/static/js/combo.cubes-barchart.js"
- "combo/apps/dataviz/static/js/combo.gauge.js"
- "combo/apps/dataviz/static/js/gauge.min.js"
- "combo/apps/dataviz/static/js/pygal-tooltips.js"
- "combo/apps/family/static/js/combo.weekly_agenda.js"
- "combo/apps/gallery/static/js/combo.gallery.js"
- "combo/apps/lingo/static/js/tipi.js"
- "combo/apps/maps/static/js/combo.map.js"
- "combo/apps/maps/static/js/leaflet-gps.js"
- "combo/apps/maps/static/js/leaflet-search.js"
- "combo/apps/pwa/templates/combo/service-worker-registration.js"
- "combo/apps/pwa/templates/combo/service-worker.js"
- "combo/apps/wcs/static/js/combo.filter-cards.js"
- "combo/apps/wcs/static/js/combo.submission.js"
- "combo/combo/apps/maps/static/js/combo.map.js"
- "combo/manager/static/js/combo.manager.js"
- "combo/public/static/js/combo.public.js"
extends: eslint:recommended
parserOptions:
ecmaVersion: 13
sourceType: module
overrides:
- files: tests/js/**/*.js
env:
node: true
rules:
# Follow Standard JS guidelines : https://standardjs.com/rules.html, except rules
# annotated with a 'custom' comment
# Linting
array-callback-return: error
constructor-super: error
eqeqeq: [error, always, {null: ignore}]
handle-callback-err: error
no-array-constructor: error
no-caller: error
no-class-assign: error
no-cond-assign: error
no-const-assign: error
no-control-regex: error
no-debugger: error
no-delete-var: error
no-dupe-args: error
no-dupe-class-members: error
no-dupe-keys: error
no-duplicate-case: error
no-duplicate-imports: error
no-empty-character-class: error
no-empty-pattern: error
no-eval: error
no-ex-assign: error
no-extend-native: error
no-extra-boolean-cast: error
no-fallthrough: error
no-func-assign: error
no-global-assign: error
no-implied-eval: error
no-inner-declarations: error
no-invalid-regexp: error
no-iterator: error
no-labels: error
no-new-func: error
no-new-object: error
no-new-require: error
no-new-symbol: error
no-new-wrappers: error
no-new: error
no-obj-calls: error
no-octal-escape: error
no-octal: error
no-proto: error
no-redeclare: error
no-regex-spaces: error
no-return-assign: error
no-self-assign: error
no-self-compare: error
no-sequences: error
no-shadow-restricted-names: error
no-sparse-arrays: error
no-template-curly-in-string: error
no-this-before-super: error
no-throw-literal: error
no-undef: error
no-unexpected-multiline: error
no-unmodified-loop-condition: error
no-unneeded-ternary: error
no-unreachable: error
no-unsafe-finally: error
no-unsafe-negation: error
no-unused-vars: error
no-use-before-define: [error, {functions: false, variables: false, classes: false}]
no-useless-call: error
no-useless-computed-key: error
no-useless-constructor: error
no-useless-escape: error
no-var: error
no-with: error
use-isnan: error
valid-typeof: error
# Style / Formatting
accessor-pairs: error
block-spacing: error
brace-style: [error, 1tbs, {allowSingleLine: true}]
camelcase: error
comma-dangle: [error, always-multiline] # custom : Adding a dangling comma make patches shorter
comma-spacing: error
comma-style: error
curly: [error, multi-line]
dot-location: [error, property]
eol-last: [error, always]
func-call-spacing: error
indent: [error, 2]
key-spacing: error
keyword-spacing: error
max-len: [error, {code: 110}] # custom: configured like this on python projects
new-cap: [error, { newIsCap: true, capIsNew: false}]
new-parens: error
no-extra-parens: [error, functions]
no-floating-decimal: error
no-irregular-whitespace: error
no-lone-blocks: error
no-mixed-spaces-and-tabs: error
no-multi-spaces: error
no-multi-str: error
no-multiple-empty-lines: error
no-tabs: error
no-trailing-spaces: error
no-undef-init: error
no-useless-rename: error
no-whitespace-before-property: error
object-property-newline: [error, { allowMultiplePropertiesPerLine: true }]
one-var: [error, never]
operator-linebreak: [error, before]
padded-blocks: [error, never]
quotes: [error, single]
rest-spread-spacing: error
semi-spacing: error
semi: [error, never]
space-before-function-paren: error
space-in-parens: error
space-infix-ops: error
space-unary-ops: error
spaced-comment: error
template-curly-spacing: error
wrap-iife: [error, any]
yield-star-spacing: [error, {after: true, before: true}]

View File

@ -34,3 +34,10 @@ repos:
rev: v0.3
hooks:
- id: pre-commit-debian
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.23.1
hooks:
- id: eslint
files: \.m?js$
types: [file]
args: [--fix]

View File

@ -1015,7 +1015,7 @@ class WcsCardCell(CardMixin, CellBase):
verbose_name = _('Card(s)')
class Media:
js = ('js/combo.filter-cards.js', 'xstatic/select2.min.js')
js = ('js/combo.filter-cards.js', 'xstatic/select2.min.js', 'js/combo.wcs-trigger-button.js')
css = {'all': ('xstatic/select2.min.css', 'css/combo.filter-cards.css')}
def include_filters(self):
@ -1171,7 +1171,7 @@ class WcsCardCell(CardMixin, CellBase):
self.card_slug,
self.card_custom_view,
)
api_url += '?include-fields=on&include-submission=on&include-workflow=on'
api_url += '?include-fields=on&include-submission=on&include-workflow=on&include-actions=on'
user = self.get_concerned_user(context)
if self.only_for_user and user and not user.is_anonymous and user.get_name_id():
api_url += '&filter-user-uuid=%s' % user.get_name_id()
@ -1262,9 +1262,7 @@ class WcsCardCell(CardMixin, CellBase):
card_custom_view,
card_id,
)
api_url += (
'?include-files-content=off&include-evolution=off&include-roles=off&include-workflow-data=off'
)
api_url += '?include-files-content=off&include-evolution=off&include-roles=off&include-workflow-data=off&include-actions=on'
user = self.get_concerned_user(context)
if only_for_user and user and not user.is_anonymous and user.get_name_id():
api_url += '&filter-user-uuid=%s' % user.get_name_id()
@ -1646,6 +1644,23 @@ class WcsCardCell(CardMixin, CellBase):
card_data['urls'] = {}
if self.custom_schema:
for item in self.get_custom_schema().get('cells') or []:
if item.get('varname') == '@action@':
if item.get('action_ask_confirmation', False):
render_template(
item=item,
template_key='action_confirmation_template',
template_context=custom_context,
target_key='custom_fields',
target_context=card_data,
)
render_template(
item=item,
template_key='action_label',
template_context=custom_context,
target_key='custom_fields',
target_context=card_data,
)
if item.get('varname') not in ['@custom@', '@link@']:
continue
if not item.get('template'):

View File

@ -0,0 +1,56 @@
class WcsTriggerButton extends HTMLElement {
#triggerUrl

Désolé question javascript, ça correspond à quoi ce #triggerUrl ?

Désolé question javascript, ça correspond à quoi ce #triggerUrl ?

C'est des variables privées en JS (vraiment privée, ça lève une erreur de syntaxe si on y accède en dehors de la classe, ou si elle n'est pas déclarée dans la classe).

Pas vraiment d'avis définitif sur si c'est bien ou pas, c'est probablement une habitude que je tiens du C++ : je ne vois pas d'inconvénient à en faire des membres "standard".

C'est des variables privées en JS (vraiment privée, ça lève une erreur de syntaxe si on y accède en dehors de la classe, ou si elle n'est pas déclarée dans la classe). Pas vraiment d'avis définitif sur si c'est bien ou pas, c'est probablement une habitude que je tiens du C++ : je ne vois pas d'inconvénient à en faire des membres "standard".
connectedCallback () {
this.addEventListener('click', this.onClick.bind(this))
const label = this.getAttribute('label')
this.#triggerUrl = this.getAttribute('trigger-url')
this.innerHTML = `<button>${label}</button>`
if (this.#triggerUrl === null) {

Et pareil question js, .# c'est quoi ?

(et détail je trouve plus lisible quand il y a un espace après le if).

Et pareil question js, .# c'est quoi ? (et détail je trouve plus lisible quand il y a un espace après le if).

Et pareil question js, .# c'est quoi ?

C.F commentaire précédent.

(et détail je trouve plus lisible quand il y a un espace après le if).

Je vais peut-être en profiter pour faire tourner eslint sur les nouveaux fichiers ajoutés.

> Et pareil question js, .# c'est quoi ? C.F commentaire précédent. > (et détail je trouve plus lisible quand il y a un espace après le if). Je vais peut-être en profiter pour faire tourner eslint sur les nouveaux fichiers ajoutés.
const unavailableMode = this.getAttribute('unavailable-mode')
if (unavailableMode === 'hide') {
this.hidden = true
} else {
const button = this.querySelector('button')
button.disabled = true
}
}
}
async onClick () {
const confirmationMessage = this.getAttribute('confirmation-message')
if (confirmationMessage && !confirm(confirmationMessage)) {
return
}
const response = await fetch(
this.getAttribute('proxy-url'),
{
method: 'POST',
mode: 'cors',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getAttribute('csrf-token'),
},
body: JSON.stringify({
'trigger-url': this.#triggerUrl,
}),
},
)
const json = await response.json()
const button = this.querySelector('button')
if (!response.ok || json.err === 1) {
alert(this.getAttribute('error-message'))
button.disabled = false
} else {
button.disabled = true

Passer ça dans gettext ?

Passer ça dans gettext ?
}
}
}
window.customElements.define('wcs-trigger-button', WcsTriggerButton)

View File

@ -57,6 +57,29 @@
{% endwith %}
</div>
{% endif %}
{% elif item.varname == "@action@" %}
<div class="{{ item.cell_size|default:"" }}">
<wcs-trigger-button
{% if item.action_ask_confirmation %}
{% with card.custom_fields|get:item.action_confirmation_template|force_escape as confirmation_message %}
confirmation-message="{{ confirmation_message }}"
{% endwith %}
{% endif %}
{% with card.custom_fields|get:item.action_label|force_escape as label %}
label="{{ label }}"
{% endwith %}
proxy-url='{% url 'wcs-trigger-proxy' %}'
unavailable-mode="{{ item.unavailable_action_mode }}"
{% with card.actions|get:item.trigger_id as trigger_url %}
{% if trigger_url %}
trigger-url='{{ trigger_url }}'
{% endif %}
{% endwith %}
error-message="{% trans 'An error occured' %}"
csrf-token='{{ csrf_token }}'
>
</wcs-action-button>
</div>
{% else %}
{% if item.varname %}
{% with fields_by_varnames|get:item.varname as field %}

View File

@ -27,6 +27,8 @@
{% if item.template and link %}
<th>{{ item.header|default:"" }}</th>
{% endif %}
{% elif item.varname == "@action@" %}
<th>{{ item.header|default:"" }}</th>
{% else %}
{% if item.varname %}
{% with fields_by_varnames|get:item.varname as field %}

View File

@ -1,3 +1,5 @@
{% load i18n %}
{% spaceless %}
{% if item.varname == "@custom@" %}
{% if item.template %}
@ -30,6 +32,29 @@
{% endwith %}
{% if not ul_display %}</td>{% endif %}
{% endif %}
{% elif item.varname == "@action@" %}
{% if not ul_display %}<td>{% endif %}
<wcs-trigger-button
{% if item.action_ask_confirmation %}
{% with card.custom_fields|get:item.action_confirmation_template|force_escape as confirmation_message %}
confirmation-message="{{ confirmation_message }}"
{% endwith %}
{% endif %}
{% with card.custom_fields|get:item.action_label|force_escape as label %}
label="{{ label }}"
{% endwith %}
proxy-url='{% url 'wcs-trigger-proxy' %}'
unavailable-mode="{{ item.unavailable_action_mode }}"
{% with card.actions|get:item.trigger_id as trigger_url %}
{% if trigger_url %}
trigger-url='{{ trigger_url }}'
{% endif %}
{% endwith %}
error-message="{% trans 'An error occured' %}"
csrf-token='{{ csrf_token }}'
>
</wcs-action-button>
{% if not ul_display %}</td>{% endif %}
{% else %}
{% if item.varname %}
{% with fields_by_varnames|get:item.varname as field and card.fields|get:item.varname as value %}

View File

@ -41,6 +41,7 @@
<option value="@info-field@">{% trans "Card information field" %}</option>
<option value="@custom@">{% trans "Custom" %}</option>
<option value="@link@">{% trans "Link" %}</option>
<option value="@action@">{% trans "Action" %}</option>
</select>
</label>
</p>
@ -186,6 +187,47 @@
</p>
</div>
{# fields group for "content type == @action@" #}
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@action@">
<p>
<label>
{% trans "Label:" %}

: manquant ?

: manquant ?
<input name="action_label" />
</label>
</p>
<p>
<label>
{% trans "Action:" %}

Espace à retirer avant le :

Espace à retirer avant le :
<select name="trigger_id">
{% for id, action in card_schema.workflow.actions.items %}
<option value="{{ id }}">{{ action.label }}</option>
{% endfor %}
</select>
</label>
</p>
<p>
<label>
{% trans "Ask confirmation:" %}

Espace à remplacer par un :

Espace à remplacer par un :
<input data-dynamic-display-parent="true" type="checkbox" value="true" name="action_ask_confirmation" style="resize: vertical;"></input>
</label>
</p>
<p data-dynamic-display-child-of="action_ask_confirmation" data-dynamic-display-checked="true">
<label>
{% trans "Confirmation text (template):" %}

Je retirerais la majuscule à Template, et j'ajouterais un :

Je retirerais la majuscule à Template, et j'ajouterais un :
<textarea name="action_confirmation_template" style="resize: vertical;"></textarea>
</label>
</p>
<p>
<label>
{% trans "Unavailable action mode:" %}

Espace à retirer devant les :

Espace à retirer devant les :
<select name="unavailable_action_mode">
<option value="hide">{% trans "Hide action button" %}</option>
<option value="disable">{% trans "Disable action button" %}</option>
</select>
</label>
</p>
</div>
<p>
<label>
{% trans "Size" %}

View File

@ -36,6 +36,7 @@
<option value="@info-field@">{% trans "Card information field" %}</option>
<option value="@custom@">{% trans "Custom" %}</option>
<option value="@link@">{% trans "Link" %}</option>
<option value="@action@">{% trans "Action" %}</option>
</select>
</label>
</p>
@ -151,6 +152,53 @@
</label>
</p>
</div>
{# fields group for "content type == @action@" #}
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@action@">
<p>
<label>
{% trans "Header:" %}
<input name="action_header" />
</label>
</p>
<p>
<label>
{% trans "Label:" %}

cf commentaires plus haut.

J'imagine que c'était galère de factoriser pour ne pas dupliquer ça, qu'il y a des petites différences ?

cf commentaires plus haut. J'imagine que c'était galère de factoriser pour ne pas dupliquer ça, qu'il y a des petites différences ?

J'ai jeté un œil pour factoriser, les templates et le code dans combo.manager.js, mais vu l'existant c'est une grosse galère : j'ai préféré coller à ce qui était déjà fait, même si ça me satisfait pas vraiment non plus.

J'ai jeté un œil pour factoriser, les templates et le code dans combo.manager.js, mais vu l'existant c'est une grosse galère : j'ai préféré coller à ce qui était déjà fait, même si ça me satisfait pas vraiment non plus.
<input name="action_label" />
</label>
</p>
<p>
<label>
{% trans "Action:" %}
<select name="trigger_id">
{% for id, action in card_schema.workflow.actions.items %}
<option value="{{ id }}">{{ action.label }}</option>
{% endfor %}
</select>
</label>
</p>
<p>
<label>
{% trans "Ask confirmation:" %}
<input data-dynamic-display-parent="true" type="checkbox" value="true" name="action_ask_confirmation" style="resize: vertical;"></input>
</label>
</p>
<p data-dynamic-display-child-of="action_ask_confirmation" data-dynamic-display-checked="true">
<label>
{% trans "Confirmation text (template):" %}
<textarea name="action_confirmation_template" style="resize: vertical;"></textarea>
</label>
</p>
<p>
<label>
{% trans "Unavailable action mode:" %}
<select name="unavailable_action_mode">
<option value="hide">{% trans "Hide action button" %}</option>
<option value="disable">{% trans "Disable action button" %}</option>
</select>
</label>
</p>
</div>
</form>
</template>
<template class="as-table wcs-cards-cell--grid-cell-tpl">

View File

@ -16,7 +16,7 @@
from django.urls import path, re_path
from .views import TrackingCodeView, redirect_crypto_url, tracking_code_search
from .views import TrackingCodeView, TriggerProxyView, redirect_crypto_url, tracking_code_search
urlpatterns = [
path('tracking-code/', TrackingCodeView.as_view(), name='wcs-tracking-code'),
@ -26,4 +26,5 @@ urlpatterns = [
redirect_crypto_url,
name='wcs-redirect-crypto-url',
),
path('api/wcs/trigger/', TriggerProxyView.as_view(), name='wcs-trigger-proxy'),

Ça peut se faire avec path() tout simplement, plutôt que re_path().

Ça peut se faire avec path() tout simplement, plutôt que re_path().
]

View File

@ -26,6 +26,9 @@ from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpRespo
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from rest_framework import authentication, permissions
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from combo.utils import DecryptionError, aes_hex_decrypt, requests, sign_url
from combo.utils.misc import get_known_service_for_url, is_url_from_known_service
@ -154,3 +157,13 @@ def redirect_crypto_url(request, session_key, crypto_url):
real_url += '&orig=%s' % service['orig']
redirect_url = sign_url(real_url, service['secret'], nonce=False)
return HttpResponseRedirect(redirect_url)
class TriggerProxyView(GenericAPIView):
permission_classes = (permissions.AllowAny,)
authentication_classes = (authentication.SessionAuthentication,)
def post(self, request, *args, **kwargs):
trigger_url = request.data['trigger-url']
response = requests.post(trigger_url, remote_service='auto', user=request.user)
return Response(response.json())

View File

@ -431,9 +431,11 @@ $(function() {
var sel1 = '[data-dynamic-display-child-of="' + $(this).attr('name') + '"]';
var sel2 = '[data-dynamic-display-value="' + $(this).val() + '"]';
var sel3 = '[data-dynamic-display-value-in*=" ' + $(this).val() + ' "]';

Dans wcs le cas des cases à cocher est déjà géré, différemment, (cf wcs/qommon/static/js/qommon.js), ça serait bien de poser le même fonctionnement ici, pour faciliter à un moment la convergence dans gadjo.

Dans wcs le cas des cases à cocher est déjà géré, différemment, (cf wcs/qommon/static/js/qommon.js), ça serait bien de poser le même fonctionnement ici, pour faciliter à un moment la convergence dans gadjo.
var sel4 = '[data-dynamic-display-checked="' + $(this).prop('checked') + '"]';
$(sel1).addClass('field-hidden').hide();
$(sel1 + sel2).removeClass('field-hidden').show();
$(sel1 + sel3).removeClass('field-hidden').show();
$(sel1 + sel4).removeClass('field-hidden').show();
$(sel1).trigger('change');
});
$('[data-dynamic-display-child-of]').addClass('field-hidden').hide();
@ -667,9 +669,12 @@ Card_cell_custom.prototype = {
// set cell text
let schema_field = this.field_with_varname(schema_cell.varname);
let cell_text = "";
if (schema_field || schema_cell.varname == '@custom@' || schema_cell.varname == '@link@') {
if (schema_field || schema_cell.varname == '@custom@' || schema_cell.varname == '@link@' || schema_cell.varname == '@action@') {
let cell_content = "";
if (schema_cell.varname == '@custom@') {
if (schema_cell.varname == '@action@') {
cell_content += $(this.grid_cell_form).find('select[name="trigger_id"] option[value="' + schema_cell.trigger_id + '"]').text();
cell_content += ' (' + gettext('Action Button') + ')';
} else if (schema_cell.varname == '@custom@') {
cell_content = (schema_cell.template || '') + ' (' + gettext('Custom') + ')';
} else if (schema_cell.varname == '@link@') {
cell_content = (schema_cell.template || '') + ': ';
@ -695,7 +700,10 @@ Card_cell_custom.prototype = {
cell_text += $('<span/>').addClass(schema_cell.display_mode).text(cell_content).html();
cell_text += '<span class="cell-meta">';
let cell_display_mode_label = '';
if (schema_cell.varname == '@custom@') {
if(schema_cell.varname == '@action@') {
cell_display_mode_label = undefined
}
else if (schema_cell.varname == '@custom@') {
cell_display_mode_label = $(this.grid_cell_form).find('select[name="custom_display_mode"] option[value="' + (schema_cell.display_mode || 'label') + '"]').text();
} else if (schema_cell.varname == '@link@') {
cell_display_mode_label = $(this.grid_cell_form).find('select[name="link_display_mode"] option[value="' + schema_cell.display_mode + '"]').text();
@ -748,7 +756,16 @@ Card_cell_custom.prototype = {
}
if (this.display_mode === 'card') {
if (grid_cell.dataset.varname == '@custom@') {
if (grid_cell.dataset.varname == '@action@') {
this.grid_cell_form.elements.entry_type.value = '@action@';
this.grid_cell_form.elements.action_label.value = grid_cell.dataset.action_label || '';
this.grid_cell_form.elements.trigger_id.value = grid_cell.dataset.trigger_id || '';
this.grid_cell_form.elements.action_ask_confirmation.value = 'true';
this.grid_cell_form.elements.action_ask_confirmation.checked = grid_cell.dataset.action_ask_confirmation == 'true';
this.grid_cell_form.elements.action_confirmation_template.value = grid_cell.dataset.action_confirmation_template || '';
this.grid_cell_form.elements.unavailable_action_mode.value = grid_cell.dataset.unavailable_action_mode || '';
}
else if (grid_cell.dataset.varname == '@custom@') {
this.grid_cell_form.elements.entry_type.value = '@custom@';
this.grid_cell_form.elements.custom_template.value = grid_cell.dataset.template || '';
this.grid_cell_form.elements.custom_display_mode.value = grid_cell.dataset.display_mode || 'label';
@ -791,7 +808,17 @@ Card_cell_custom.prototype = {
this.grid_cell_form.elements.cell_size.value = grid_cell.dataset.cell_size || '';
} else if (this.display_mode === 'table') {
if (grid_cell.dataset.varname == '@custom@') {
if (grid_cell.dataset.varname == '@action@') {
this.grid_cell_form.elements.entry_type.value = '@action@';
this.grid_cell_form.elements.action_header.value = grid_cell.dataset.header || '';
this.grid_cell_form.elements.action_label.value = grid_cell.dataset.action_label || '';
this.grid_cell_form.elements.trigger_id.value = grid_cell.dataset.trigger_id || '';
this.grid_cell_form.elements.action_ask_confirmation.value = 'true';
this.grid_cell_form.elements.action_ask_confirmation.checked = grid_cell.dataset.action_ask_confirmation == 'true';
this.grid_cell_form.elements.action_confirmation_template.value = grid_cell.dataset.action_confirmation_template || '';
this.grid_cell_form.elements.unavailable_action_mode.value = grid_cell.dataset.unavailable_action_mode || '';
}
else if (grid_cell.dataset.varname == '@custom@') {
this.grid_cell_form.elements.entry_type.value = '@custom@';
if (this.display_mode == 'table') {
this.grid_cell_form.elements.custom_header.value = grid_cell.dataset.header || '';
@ -862,7 +889,15 @@ Card_cell_custom.prototype = {
for (var data in schema_cell) delete schema_cell[data];
if (this.display_mode == 'card') {
if (form_datas.entry_type == '@custom@') {
if (form_datas.entry_type == '@action@') {
schema_cell.varname = '@action@';
schema_cell.trigger_id = form_datas.trigger_id;
schema_cell.action_label = form_datas.action_label;
schema_cell.action_ask_confirmation = form_datas.action_ask_confirmation == 'true';
schema_cell.action_confirmation_template = form_datas.action_confirmation_template;
schema_cell.unavailable_action_mode = form_datas.unavailable_action_mode;
}
else if (form_datas.entry_type == '@custom@') {
schema_cell.varname = '@custom@';
schema_cell.display_mode = form_datas.custom_display_mode;
schema_cell.template = form_datas.custom_template;
@ -909,7 +944,16 @@ Card_cell_custom.prototype = {
schema_cell.cell_size = form_datas.cell_size;
} else if (this.display_mode == 'table') {
if (form_datas.entry_type == '@custom@') {
if (form_datas.entry_type == '@action@') {
schema_cell.varname = '@action@';
schema_cell.header = form_datas.action_header;
schema_cell.trigger_id = form_datas.trigger_id;
schema_cell.action_label = form_datas.action_label;
schema_cell.action_ask_confirmation = form_datas.action_ask_confirmation == 'true';
schema_cell.action_confirmation_template = form_datas.action_confirmation_template;
schema_cell.unavailable_action_mode = form_datas.unavailable_action_mode;
}
else if (form_datas.entry_type == '@custom@') {
schema_cell.varname = '@custom@';
schema_cell.header = form_datas.custom_header;
schema_cell.template = form_datas.custom_template;

View File

@ -1,5 +1,5 @@
import { expect, test, vi} from 'vitest'
import { expect, test } from 'vitest'
test('dummy test', async () => {
expect(true).toBe(true)
expect(true).toBe(true)
})

24
tests/js/test-utils.js Normal file
View File

@ -0,0 +1,24 @@
import { test } from 'vitest'
export const domTest = test.extend({
// Empty {} is required by vitest
// eslint-disable-next-line no-empty-pattern
appendToDom: async ({}, use) => {
const wrappers = []
const appendToDom = (htmlContent) => {
const wrapper = document.createElement('div')
wrapper.innerHTML = htmlContent
document.appendChild(wrapper)
wrappers.push(wrapper)
return wrapper
}
await use(appendToDom)
for (const wrapper of wrappers) {
wrapper.remove()
}
},
})
export async function flushPromises () {
await new Promise((resolve) => setTimeout(resolve))
}

View File

@ -0,0 +1,128 @@
import { expect, vi} from 'vitest'
import { domTest, flushPromises } from '../test-utils.js'
import '../../../combo/apps/wcs/static/js/combo.wcs-trigger-button.js'
domTest('render button label', async ({appendToDom}) => {
let dom = appendToDom(`
<wcs-trigger-button label="Test label"></wcs/trigger-button>
`)
const innerButton = dom.querySelector('button')
expect(innerButton.innerText).toBe('Test label')
})
domTest('show button if trigger-url attribute is set', async ({appendToDom}) => {
const dom = appendToDom(`
<wcs-trigger-button label="Test label" trigger-url="https://dummy.org"></wcs/trigger-button>
`)
const triggerButton = dom.querySelector('wcs-trigger-button')
const innerButton = dom.querySelector('button')
expect(triggerButton.hidden).toBe(false)
expect(innerButton.disabled).toBe(false)
})
domTest('hide button if trigger-url is not set and unavailable-mode is hide', async ({appendToDom}) => {
const dom = appendToDom(`
<wcs-trigger-button unavailable-mode="hide"></wcs/trigger-button>
`)
const triggerButton = dom.querySelector('wcs-trigger-button')
const innerButton = dom.querySelector('button')
expect(triggerButton.hidden).toBe(true)
expect(innerButton.disabled).toBe(false)
})
domTest('disable button if trigger-url is not set and unavailable-mode is disable', async ({appendToDom}) => {
const dom = appendToDom(`
<wcs-trigger-button unavailable-mode="disable"></wcs/trigger-button>
`)
const triggerButton = dom.querySelector('wcs-trigger-button')
const innerButton = dom.querySelector('button')
expect(triggerButton.hidden).toBe(false)
expect(innerButton.disabled).toBe(true)
})
export const triggerTest = domTest.extend({
clickTrigger: async ({appendToDom}, use) => {
const load = async (domContent, mockFetch) => {
const dom = appendToDom(domContent)
fetch.mockImplementationOnce(mockFetch)
const innerButton = dom.querySelector('button')
innerButton.dispatchEvent(new Event('click', {bubbles: true}))
await flushPromises()
return dom
}
const alertBackup = global.alert
const confirmBackup = global.confirm
const fetchBackup = global.fetch
global.fetch = vi.fn()
global.alert = vi.fn()
global.confirm = vi.fn()
await use(load)
global.fetch = fetchBackup
global.confirm = confirmBackup
global.alert = alertBackup
},
})
triggerTest('trigger is called on button click', async ({clickTrigger}) => {
const dom = await clickTrigger(
`<wcs-trigger-button
trigger-url="https://trigger.test"
csrf-token="test-csrf-token"
proxy-url="https://proxy.test">
</wcs/trigger-button>`,
async (url, options) => {
expect(url).toBe('https://proxy.test')
expect(options.headers['X-CSRFToken']).toBe('test-csrf-token')
expect(options.method).toBe('POST')
expect(JSON.parse(options.body)).toStrictEqual({'trigger-url': 'https://trigger.test'})
return {ok: true, json: async () => ({}) }
},
)
const innerButton = dom.querySelector('wcs-trigger-button button')
expect(fetch).toHaveBeenCalledOnce()
// Button should be disabled after successfull call
expect(innerButton.disabled).toBe(true)
})
triggerTest('error message is shown on unsuccessfull trigger call', async ({clickTrigger}) => {
const dom = await clickTrigger(
'<wcs-trigger-button error-message="Error message"></wcs/trigger-button>',
async () => ({ok: false, json: async () => ({}) }),
)
expect(fetch).toHaveBeenCalledOnce()
expect(alert).toHaveBeenCalledWith('Error message')
const innerButton = dom.querySelector('wcs-trigger-button button')
expect(innerButton.disabled).toBe(false)
})
triggerTest('confirmation message is shown if set', async ({clickTrigger}) => {
confirm.mockImplementationOnce(() => true)
await clickTrigger(
`<wcs-trigger-button confirmation-message="Confirmation">
</wcs/trigger-button>`,
async () => ({ok: true, json: async () => ({}) }),
)
expect(confirm).toHaveBeenCalledWith('Confirmation')
expect(fetch).toHaveBeenCalledOnce()
})
triggerTest('trigger is not called if confirmation is dismissed', async ({clickTrigger}) => {
confirm.mockImplementationOnce(() => false)
await clickTrigger(
`<wcs-trigger-button confirmation-message="Confirmation">
</wcs/trigger-button>`,
)
expect(confirm).toHaveBeenCalledWith('Confirmation')
expect(fetch).not.toHaveBeenCalledOnce()
})

View File

@ -932,7 +932,7 @@ def test_card_cell_table_mode_render(mock_send, context, app):
assert len(requests_get.call_args_list) == 1
assert (
requests_get.call_args_list[0][0][0]
== '/api/cards/card_model_1/list/foo?include-fields=on&include-submission=on&include-workflow=on&filter-internal-id=11'
== '/api/cards/card_model_1/list/foo?include-fields=on&include-submission=on&include-workflow=on&include-actions=on&filter-internal-id=11'
)
assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://127.0.0.1:8999/'
@ -944,7 +944,7 @@ def test_card_cell_table_mode_render(mock_send, context, app):
assert len(requests_get.call_args_list) == 1
assert (
requests_get.call_args_list[0][0][0]
== '/api/cards/card_model_1/list?include-fields=on&include-submission=on&include-workflow=on&filter-internal-id=11'
== '/api/cards/card_model_1/list?include-fields=on&include-submission=on&include-workflow=on&include-actions=on&filter-internal-id=11'
)
assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://127.0.0.1:8999/'
@ -1299,6 +1299,55 @@ def test_card_cell_table_mode_render_custom_schema_link_entry(mock_send, context
assert PyQuery(result).find('table tr td a') == []
@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
def test_card_cell_table_mode_render_custom_schema_action_entry(mock_send, context):
page = Page.objects.create(title='xxx', template_name='standard')
cell = WcsCardCell.objects.create(
page=page,
placeholder='content',
order=0,
carddef_reference='default:card_model_1',
custom_schema={
'cells': [
{
'varname': '@action@',
'trigger_id': 'jump:trigger-1',
'action_label': 'Label {{ card.fields.fielda }}',
'action_ask_confirmation': True,
'action_confirmation_template': 'Confirmation {{ card.fields.fielda }}',
'unavailable_action_mode': 'hide',
},
]
},
display_mode='table',
related_card_path='__all__',
)
request = RequestFactory().get('/')
cell.modify_global_context(context, request)
context['synchronous'] = True # to get fresh content
result = cell.render(context)
button = PyQuery(result).find('table tr:first-child td:first-child wcs-trigger-button')
assert button.attr['label'] == 'Label <i>a</i>'
assert button.attr['proxy-url'] == reverse('wcs-trigger-proxy')
assert button.attr['trigger-url'] == 'https://jump.test/trigger-1'
assert button.attr['confirmation-message'] == 'Confirmation <i>a</i>'
assert button.attr['unavailable-mode'] == 'hide'
cell.custom_schema['cells'][0]['trigger_id'] = 'unavailable-trigger'
cell.save()
result = cell.render(context)
button = PyQuery(result).find('table tr:first-child td:first-child wcs-trigger-button')
assert button.attr['trigger-url'] is None
cell.custom_schema['cells'][0]['action_ask_confirmation'] = False
cell.save()
result = cell.render(context)
button = PyQuery(result).find('table tr:first-child td:first-child wcs-trigger-button')
assert button.attr['action_confirmation_template'] is None
@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
@pytest.mark.parametrize('with_headers', [True, False])
def test_card_cell_table_mode_render_with_headers(mock_send, context, with_headers):
@ -1339,6 +1388,7 @@ def test_card_cell_table_mode_render_with_headers(mock_send, context, with_heade
{'varname': 'info:last_update_time'},
{'varname': 'info:status'},
{'varname': 'info:text'},
{'varname': '@action@', 'header': 'Action', 'action_label': '', 'trigger_id': ''},
],
},
display_mode='table',
@ -1351,7 +1401,7 @@ def test_card_cell_table_mode_render_with_headers(mock_send, context, with_heade
result = cell.render(context)
if with_headers:
assert len(PyQuery(result).find('table thead th')) == 14
assert len(PyQuery(result).find('table thead th')) == 15
assert PyQuery(result).find('table thead th:nth-child(1)').text() == ''
assert PyQuery(result).find('table thead th:nth-child(2)').text() == 'My Custom Header'
assert PyQuery(result).find('table thead th:nth-child(3)').text() == 'Field B'
@ -1366,6 +1416,7 @@ def test_card_cell_table_mode_render_with_headers(mock_send, context, with_heade
assert PyQuery(result).find('table thead th:nth-child(12)').text() == 'Last modified'
assert PyQuery(result).find('table thead th:nth-child(13)').text() == 'Status'
assert PyQuery(result).find('table thead th:nth-child(14)').text() == 'Text'
assert PyQuery(result).find('table thead th:nth-child(15)').text() == 'Action'
else:
assert PyQuery(result).find('table thead') == []
@ -1879,7 +1930,7 @@ def test_card_cell_list_mode_render(mock_send, context, app):
assert len(requests_get.call_args_list) == 1
assert (
requests_get.call_args_list[0][0][0]
== '/api/cards/card_model_1/list/foo?include-fields=on&include-submission=on&include-workflow=on&filter-internal-id=11'
== '/api/cards/card_model_1/list/foo?include-fields=on&include-submission=on&include-workflow=on&include-actions=on&filter-internal-id=11'
)
assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://127.0.0.1:8999/'
@ -1891,7 +1942,7 @@ def test_card_cell_list_mode_render(mock_send, context, app):
assert len(requests_get.call_args_list) == 1
assert (
requests_get.call_args_list[0][0][0]
== '/api/cards/card_model_1/list?include-fields=on&include-submission=on&include-workflow=on&filter-internal-id=11'
== '/api/cards/card_model_1/list?include-fields=on&include-submission=on&include-workflow=on&include-actions=on&filter-internal-id=11'
)
assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://127.0.0.1:8999/'
@ -3220,6 +3271,56 @@ def test_card_cell_card_mode_render_custom_schema_link_entry(mock_send, context,
assert PyQuery(result).find('.value a') == []
@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
def test_card_cell_card_mode_render_custom_schema_action_entry(mock_send, context, app):
page = Page.objects.create(title='xxx', template_name='standard')
cell = WcsCardCell.objects.create(
page=page,
placeholder='content',
order=0,
carddef_reference='default:card_model_1',
custom_schema={
'cells': [
{
'varname': '@action@',
'trigger_id': 'jump:trigger-1',
'action_label': 'Label {{ card.fields.fielda }}',
'action_ask_confirmation': True,
'action_confirmation_template': 'Confirmation {{ card.fields.fielda }}',
'unavailable_action_mode': 'hide',
},
]
},
related_card_path='',
)
context['card_model_1_id'] = 11
request = RequestFactory().get('/')
cell.modify_global_context(context, request)
cell.repeat_index = 0
context['synchronous'] = True # to get fresh content
result = cell.render(context)
button = PyQuery(result).find('wcs-trigger-button')
assert button.attr['label'] == 'Label <i>a</i>'
assert button.attr['proxy-url'] == reverse('wcs-trigger-proxy')
assert button.attr['trigger-url'] == 'https://jump.test/trigger-1'
assert button.attr['confirmation-message'] == 'Confirmation <i>a</i>'
assert button.attr['unavailable-mode'] == 'hide'
cell.custom_schema['cells'][0]['trigger_id'] = 'unavailable-trigger'
cell.save()
result = cell.render(context)
button = PyQuery(result).find('wcs-trigger-button')
assert button.attr['trigger-url'] is None
cell.custom_schema['cells'][0]['action_ask_confirmation'] = False
cell.save()
result = cell.render(context)
button = PyQuery(result).find('wcs-trigger-button')
assert button.attr['action_confirmation_template'] is None
@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
def test_card_cell_card_mode_render_all_cards(mock_send, nocache, app):
page = Page.objects.create(title='xxx', slug='foo', template_name='standard')

View File

@ -166,6 +166,7 @@ WCS_CARDS_DATA = {
'item_raw': 'foo',
},
},
'actions': {'jump:trigger-1': 'https://jump.test/trigger-1'},
},
{
'id': 12,

View File

@ -1,4 +1,3 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@ -10,7 +9,7 @@ export default defineConfig({
all: true,
reporter: ['cobertura', 'html'],
},
environment: 'happy-dom'
}
environment: 'happy-dom',
},
})