Skip to content

Commit 14c3c95

Browse files
labmecanicatecJohnVillalovos
authored andcommitted
refactor(inlineAttributeEdit): simplify inline DATETIME editing with native Flatpickr
Replaces the inline DATETIME editing flow based on x-editable/combodate with a simpler implementation using DatePickerSetupControl. Refactors the inline script to use native browser APIs (no jQuery in this block) and fetch for persistence. Unifies markup for attribute types and improves picker open/close handling (external click and Escape key) to avoid duplicate listeners. Keeps compatibility with deferred datepicker initialization using a fallback wait mechanism.
1 parent 3375f28 commit 14c3c95

4 files changed

Lines changed: 237 additions & 49 deletions

File tree

tpl/Admin/InlineAttributeEdit.tpl

Lines changed: 228 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,235 @@
11
{if $attribute->AppliesToEntity($id)}
2+
{assign var=type value=$attribute->Type()}
23
{assign var=attributeId value="inline{$attribute->Id()}{$id}"}
3-
<div class="updateCustomAttribute mb-0 col-12 col-sm-4">
4-
{assign var=datatype value='text'}
5-
{if $attribute->Type() == CustomAttributeTypes::CHECKBOX}
6-
{assign var=datatype value='checklist'}
7-
{elseif $attribute->Type() == CustomAttributeTypes::MULTI_LINE_TEXTBOX}
8-
{assign var=datatype value='textarea'}
9-
{elseif $attribute->Type() == CustomAttributeTypes::SELECT_LIST}
10-
{assign var=datatype value='select'}
11-
{elseif $attribute->Type() == CustomAttributeTypes::DATETIME}
12-
{assign var=datatype value='combodate'}
13-
{assign var=value value={formatdate date=$value key=fullcalendar}}
4+
{assign var=datatype value='text'}
5+
{assign var=inlineClass value='inlineAttribute'}
6+
{assign var=pickerValue value=$value}
7+
8+
{if $type == CustomAttributeTypes::CHECKBOX}
9+
{assign var=datatype value='checklist'}
10+
{elseif $type == CustomAttributeTypes::MULTI_LINE_TEXTBOX}
11+
{assign var=datatype value='textarea'}
12+
{elseif $type == CustomAttributeTypes::SELECT_LIST}
13+
{assign var=datatype value='select'}
14+
{elseif $type == CustomAttributeTypes::DATETIME}
15+
{assign var=AltFormat value='short_datetime'}
16+
{assign var=pickerControlId value="inlinePickerInput{$attributeId}"}
17+
{assign var=inlineClass value='inlineAttributeDateTime'}
18+
{if $value != ''}
19+
{assign var=pickerValue value={formatdate date=$value format='Y-m-d H:i'}}
1420
{/if}
21+
{/if}
22+
23+
<div class="updateCustomAttribute mb-0 d-inline-block">
1524
<label class="inline fw-bold">{$attribute->Label()}</label>
16-
<a class="update changeAttribute link-primary" title="{translate key='Edit'}" href="#"><span class="bi bi-pencil-square"></span>
17-
<span class="visually-hidden">{translate key=Edit}</span></a>
18-
<span class="inlineAttribute" id="inline{$attributeId}" data-type="{$datatype}" data-pk="{$id}" data-value="{$value}" data-name="{FormKeys::ATTRIBUTE_PREFIX}{$attribute->Id()}" {if $attribute->Type() == CustomAttributeTypes::SELECT_LIST} data-source='[{if !$attribute->Required()}{ldelim}value:"",text:""{rdelim},{/if}
19-
{foreach from=$attribute->PossibleValueList() item=v name=vals}
20-
{ldelim}value:"{$v}",text:"{$v}"{rdelim}{if not $smarty.foreach.vals.last},{/if}
21-
{/foreach}]' {/if} {if $attribute->Type() == CustomAttributeTypes::CHECKBOX} data-source='[{ldelim}value:"1",text:"{translate key=Yes}"{rdelim}]'
22-
{/if}></span>
23-
{if $attribute->Type() == CustomAttributeTypes::DATETIME}
24-
<script type="text/javascript">
25-
$(function() {
26-
$('#inline{$attributeId}').editable({
27-
url: "{$url}",
28-
viewformat: "{Resources::GetInstance()->GetDateFormat('momentjs_datetime')}",
29-
format: "YYYY-M-D H:m",
30-
template: "{Resources::GetInstance()->GetDateFormat('momentjs_datetime')}",
31-
combodate: {
32-
minYear: "{Date::Now()->AddYears(-20)->Format('Y')}",
33-
maxYear: "{Date::Now()->AddYears(20)->Format('Y')}",
34-
firstItem: "none"
35-
},
36-
emptytext: '-',
37-
emptyclass: '',
38-
toggle: 'manual',
39-
params: function(params) {
40-
params.CSRF_TOKEN = $('#csrf_token').val();
41-
return params;
25+
26+
<a class="update {if $type == CustomAttributeTypes::DATETIME}changeAttributeDateTime{else}changeAttribute{/if} link-primary"
27+
title="{translate key='Edit'}" href="#">
28+
<span class="bi bi-pencil-square"></span>
29+
<span class="visually-hidden">{translate key=Edit}</span>
30+
</a>
31+
32+
<span class="{$inlineClass} update" id="{$attributeId}" data-type="{$datatype}" data-pk="{$id}"
33+
data-value="{$pickerValue|escape:'html'}" data-name="{FormKeys::ATTRIBUTE_PREFIX}{$attribute->Id()}"
34+
{if $type == CustomAttributeTypes::SELECT_LIST} data-source='[
35+
{if !$attribute->Required()}
36+
{ldelim}"value":"","text":""{rdelim},
37+
{/if}
38+
{foreach from=$attribute->PossibleValueList() item=v name=vals}
39+
{ldelim}"value":{$v|@json_encode|escape:'html'},"text":{$v|@json_encode|escape:'html'}{rdelim}{if not $smarty.foreach.vals.last},{/if}
40+
{/foreach}
41+
]' {/if} {if $type == CustomAttributeTypes::CHECKBOX}
42+
data-source='[{ldelim}value:"1",text:"{translate key=Yes}"{rdelim}]' {/if}>
43+
{if $type == CustomAttributeTypes::DATETIME}
44+
{if $value != ''}{formatdate date=$value key=$AltFormat}{else}-{/if}
45+
{/if}
46+
</span>
47+
48+
{if $type == CustomAttributeTypes::DATETIME}
49+
<div class="d-none position-absolute border rounded bg-body shadow p-2 z-3 update" id="inlinePicker{$attributeId}">
50+
51+
<input type="text" id="{$pickerControlId}" class="form-control form-control-sm" autocomplete="off">
52+
</div>
53+
54+
{control type="DatePickerSetupControl" ControlId=$pickerControlId HasTimepicker=true Inline=true DefaultDate=$pickerValue AltFormat=$AltFormat}
55+
56+
<script>
57+
(function() {
58+
const display = document.getElementById('{$attributeId}');
59+
const container = document.getElementById('inlinePicker{$attributeId}');
60+
const input = document.getElementById('{$pickerControlId}');
61+
const button = display.closest('.updateCustomAttribute').querySelector('.changeAttributeDateTime');
62+
const namespace = 'picker{$attributeId}';
63+
let pendingValue = display.dataset.value || '';
64+
let isSaving = false;
65+
66+
// DatePickerSetupControl initializes flatpickr synchronously (whenFlatpickrReady
67+
// runs inline callbacks immediately when window.flatpickr is already loaded).
68+
// whenPickerReady is only a fallback for deferred script loading.
69+
function whenPickerReady(cb, retries = 60) {
70+
if (input._flatpickr) {
71+
cb(input._flatpickr);
72+
return;
73+
}
74+
75+
let attempts = 0;
76+
const id = setInterval(() => {
77+
if (input._flatpickr) {
78+
clearInterval(id);
79+
cb(input._flatpickr);
80+
return;
81+
}
82+
83+
attempts += 1;
84+
if (attempts >= retries) {
85+
clearInterval(id);
86+
console.warn('InlineAttributeEdit: flatpickr not initialized for {$pickerControlId}');
87+
}
88+
}, 50);
89+
}
90+
91+
function show() { container.classList.remove('d-none'); }
92+
93+
function hide() { container.classList.add('d-none'); }
94+
95+
function extractErrorMessage(errors) {
96+
if (!errors) {
97+
return 'Error saving value';
98+
}
99+
100+
if (typeof errors === 'string') {
101+
return errors;
102+
}
103+
104+
if (Array.isArray(errors)) {
105+
return errors.join('\n');
106+
}
107+
108+
if (typeof errors === 'object') {
109+
const messages = [];
110+
for (const key in errors) {
111+
if (!Object.prototype.hasOwnProperty.call(errors, key)) {
112+
continue;
113+
}
114+
115+
const value = errors[key];
116+
if (Array.isArray(value)) {
117+
messages.push(value.join(', '));
118+
} else {
119+
messages.push(String(value));
120+
}
42121
}
122+
123+
return messages.length ? messages.join('\n') : 'Error saving value';
124+
}
125+
126+
return 'Error saving value';
127+
}
128+
129+
function save(value) {
130+
const body = new URLSearchParams({
131+
pk: '{$id}',
132+
name: '{FormKeys::ATTRIBUTE_PREFIX}{$attribute->Id()}',
133+
value: value,
134+
CSRF_TOKEN: (document.getElementById('csrf_token') || {}).value || ''
135+
});
136+
137+
fetch("{$url}", { method: 'POST', body })
138+
.then(function(response) {
139+
if (!response.ok) {
140+
throw new Error('HTTP ' + response.status);
141+
}
142+
143+
return response.text();
144+
})
145+
.then(function(rawBody) {
146+
const trimmedBody = rawBody ? rawBody.trim() : '';
147+
if (trimmedBody !== '') {
148+
let payload;
149+
try {
150+
payload = JSON.parse(trimmedBody);
151+
} catch (e) {
152+
payload = null;
153+
}
154+
155+
if (payload && typeof payload === 'object' && payload.errors) {
156+
throw new Error(extractErrorMessage(payload.errors));
157+
}
158+
}
159+
160+
const text = value === '' ? '-' : (input._flatpickr?.altInput?.value || value);
161+
display.textContent = text;
162+
display.dataset.value = value;
163+
pendingValue = value;
164+
isSaving = false;
165+
hide();
166+
})
167+
.catch(function(error) {
168+
isSaving = false;
169+
alert((error && error.message) ? error.message : 'Error saving value');
170+
});
171+
}
172+
173+
function saveIfChanged() {
174+
if (isSaving) {
175+
return;
176+
}
177+
178+
const currentValue = display.dataset.value || '';
179+
if (pendingValue === currentValue) {
180+
hide();
181+
return;
182+
}
183+
184+
isSaving = true;
185+
save(pendingValue);
186+
}
187+
188+
whenPickerReady(function(picker) {
189+
picker.config.onChange.push(function(_, dateStr) {
190+
pendingValue = dateStr;
43191
});
44192
});
45-
</script>
46-
{/if}
47-
</div>
48-
{/if}
193+
194+
button.addEventListener('click', function(e) {
195+
e.preventDefault();
196+
e.stopPropagation();
197+
pendingValue = display.dataset.value || '';
198+
show();
199+
if (input._flatpickr) {
200+
input._flatpickr.setDate(display.dataset.value || null, false);
201+
}
202+
});
203+
204+
// Close on outside click / ESC — keyed registry avoids duplicate listeners
205+
// when multiple pickers are rendered on the same page
206+
if (!document._pickerHandlers) document._pickerHandlers = {};
207+
208+
if (document._pickerHandlers[namespace + '_mousedown']) {
209+
document.removeEventListener('mousedown', document._pickerHandlers[namespace + '_mousedown']);
210+
}
211+
document._pickerHandlers[namespace + '_mousedown'] = function(e) {
212+
if (container.classList.contains('d-none')) return;
213+
if (container.contains(e.target) || button.contains(e.target)) return;
214+
saveIfChanged();
215+
};
216+
document.addEventListener('mousedown', document._pickerHandlers[namespace + '_mousedown']);
217+
218+
if (document._pickerHandlers[namespace + '_keydown']) {
219+
document.removeEventListener('keydown', document._pickerHandlers[namespace + '_keydown']);
220+
}
221+
document._pickerHandlers[namespace + '_keydown'] = function(e) {
222+
if (e.key !== 'Escape') return;
223+
if (container.classList.contains('d-none')) return;
224+
pendingValue = display.dataset.value || '';
225+
if (input._flatpickr) {
226+
input._flatpickr.setDate(display.dataset.value || null, false);
227+
}
228+
hide();
229+
};
230+
document.addEventListener('keydown', document._pickerHandlers[namespace + '_keydown']);
231+
})();
232+
</script>
233+
{/if}
234+
</div>
235+
{/if}

tpl/Admin/Resources/manage_resource_types.tpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
{if $AttributeList|default:array()|count > 0}
7676
<td>
7777
{foreach from=$AttributeList item=attribute}
78-
{include file='Admin/InlineAttributeEdit.tpl' id=$id attribute=$attribute value=$type->GetAttributeValue($attribute->Id())}
78+
{include file='Admin/InlineAttributeEdit.tpl' url="{$smarty.server.SCRIPT_NAME}?action={ManageResourceTypesActions::ChangeAttribute}" id=$id attribute=$attribute value=$type->GetAttributeValue($attribute->Id())}
7979
{/foreach}
8080
</td>
8181
{/if}

tpl/Admin/Resources/manage_resources.tpl

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,9 @@
398398
class="inline fw-bold">{translate key='Contact'}</label>
399399
{if $ResourceContactIsUser}
400400
<span class="propertyValue contactValue" data-type="select"
401-
{else}
402-
<span class="propertyValue contactValue" data-type="text"
403-
{/if}
404-
data-pk="{$id}" data-value="{$resource->GetContact()}"
401+
{else} <span class="propertyValue contactValue"
402+
data-type="text" {/if} data-pk="{$id}"
403+
data-value="{$resource->GetContact()}"
405404
data-name="{FormKeys::RESOURCE_CONTACT}">
406405
{if $resource->HasContact()}
407406
{$resource->GetContact()}
@@ -585,9 +584,11 @@
585584
class="bi bi-chevron-down"></i>
586585
</a>
587586
<div id="customAttributes{$id}" class="collapse show">
588-
<div class="row">
587+
<div>
589588
{/if}
590-
{include file='Admin/InlineAttributeEdit.tpl' id=$id attribute=$attribute value=$resource->GetAttributeValue($attribute->Id())}
589+
{include file='Admin/InlineAttributeEdit.tpl' url="{$smarty.server.SCRIPT_NAME}?action={ManageResourcesActions::ActionChangeAttribute}"
590+
id=$id attribute=$attribute
591+
value=$resource->GetAttributeValue($attribute->Id())}
591592
{/if}
592593
{/foreach}
593594
{if $hasResults}

tpl/Admin/Resources/view_resources.tpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@
377377
<div class="customAttributes">
378378
{if $AttributeList|default:array()|count > 0}
379379
{foreach from=$AttributeList item=attribute}
380-
{include file='Admin/InlineAttributeEdit.tpl' id=$id attribute=$attribute value=$resource->GetAttributeValue($attribute->Id())}
380+
{include file='Admin/InlineAttributeEdit.tpl' url="{$smarty.server.SCRIPT_NAME}?action={ManageResourcesActions::ActionChangeAttribute}" id=$id attribute=$attribute value=$resource->GetAttributeValue($attribute->Id())}
381381
{/foreach}
382382
{/if}
383383
</div>

0 commit comments

Comments
 (0)