cells: add wcs action button cell in frontend card cell (#75908)
gitea/combo/pipeline/head This commit looks good Details

This commit is contained in:
Corentin Sechet 2024-04-12 10:19:56 +02:00
parent 51ad64b477
commit 03a6bfc328
9 changed files with 383 additions and 5 deletions

View File

@ -1644,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,60 @@
class WcsTriggerButton extends HTMLElement {
#triggerUrl
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) {
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
}),
}
)
let json = {}
try {
json = await response.json()
} catch {}
const button = this.querySelector('button')
if(!response.ok || json.err === 1) {
alert('An error occured, please retry later.')
button.disabled = false
}
else {
button.disabled = true
}
}
}
window.customElements.define('wcs-trigger-button', WcsTriggerButton)

View File

@ -57,6 +57,28 @@
{% 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 %}
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

@ -30,6 +30,28 @@
{% 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 %}
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 %}

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,129 @@
import { expect, test, 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}
}
)
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></wcs/trigger-button>`,
async () => ({ok: false})
)
expect(fetch).toHaveBeenCalledOnce()
expect(alert).toHaveBeenCalledWith('An error occured, please retry later.')
const triggerButton = dom.querySelector('wcs-trigger-button')
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)
let dom = await clickTrigger(
`<wcs-trigger-button confirmation-message="Confirmation">
</wcs/trigger-button>`,
async () => ({ok: true})
)
expect(confirm).toHaveBeenCalledWith('Confirmation')
expect(fetch).toHaveBeenCalledOnce()
})
triggerTest('trigger is not called if confirmation is dismissed', async ({clickTrigger}) => {
confirm.mockImplementationOnce(() => false)
let dom = 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,