@shamar/adonis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/admin.css +2 -0
- package/assets/alpine.min.js +5 -0
- package/assets/input.css +2286 -0
- package/assets/shamar-ui.js +3658 -0
- package/dist/assets-controller.d.ts +12 -0
- package/dist/assets-controller.d.ts.map +1 -0
- package/dist/assets-controller.js +32 -0
- package/dist/assets-controller.js.map +1 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/configure.d.ts +21 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +20 -0
- package/dist/configure.js.map +1 -0
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/controller.d.ts +31 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +45 -0
- package/dist/controller.js.map +1 -0
- package/dist/controllers/admin_controller.d.ts +72 -0
- package/dist/controllers/admin_controller.d.ts.map +1 -0
- package/dist/controllers/admin_controller.js +1075 -0
- package/dist/controllers/admin_controller.js.map +1 -0
- package/dist/discover.d.ts +7 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +50 -0
- package/dist/discover.js.map +1 -0
- package/dist/form-state.d.ts +41 -0
- package/dist/form-state.d.ts.map +1 -0
- package/dist/form-state.js +74 -0
- package/dist/form-state.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/require_api_key_middleware.d.ts +30 -0
- package/dist/middleware/require_api_key_middleware.d.ts.map +1 -0
- package/dist/middleware/require_api_key_middleware.js +59 -0
- package/dist/middleware/require_api_key_middleware.js.map +1 -0
- package/dist/provider.d.ts +9 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +66 -0
- package/dist/provider.js.map +1 -0
- package/dist/resources/api_key_resource.d.ts +35 -0
- package/dist/resources/api_key_resource.d.ts.map +1 -0
- package/dist/resources/api_key_resource.js +225 -0
- package/dist/resources/api_key_resource.js.map +1 -0
- package/dist/routes.d.ts +5 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +97 -0
- package/dist/routes.js.map +1 -0
- package/dist/runtime.d.ts +27 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +74 -0
- package/dist/runtime.js.map +1 -0
- package/dist/shamar/auth.d.ts +30 -0
- package/dist/shamar/auth.d.ts.map +1 -0
- package/dist/shamar/auth.js +218 -0
- package/dist/shamar/auth.js.map +1 -0
- package/dist/shamar/branding.d.ts +24 -0
- package/dist/shamar/branding.d.ts.map +1 -0
- package/dist/shamar/branding.js +91 -0
- package/dist/shamar/branding.js.map +1 -0
- package/dist/shamar/display-relations.d.ts +7 -0
- package/dist/shamar/display-relations.d.ts.map +1 -0
- package/dist/shamar/display-relations.js +89 -0
- package/dist/shamar/display-relations.js.map +1 -0
- package/dist/shamar/list-headers.d.ts +38 -0
- package/dist/shamar/list-headers.d.ts.map +1 -0
- package/dist/shamar/list-headers.js +218 -0
- package/dist/shamar/list-headers.js.map +1 -0
- package/dist/shamar/list-query.d.ts +229 -0
- package/dist/shamar/list-query.d.ts.map +1 -0
- package/dist/shamar/list-query.js +564 -0
- package/dist/shamar/list-query.js.map +1 -0
- package/dist/shamar/menu.d.ts +48 -0
- package/dist/shamar/menu.d.ts.map +1 -0
- package/dist/shamar/menu.js +93 -0
- package/dist/shamar/menu.js.map +1 -0
- package/dist/shamar/paths.d.ts +5 -0
- package/dist/shamar/paths.d.ts.map +1 -0
- package/dist/shamar/paths.js +16 -0
- package/dist/shamar/paths.js.map +1 -0
- package/dist/shamar/relation-fields.d.ts +54 -0
- package/dist/shamar/relation-fields.d.ts.map +1 -0
- package/dist/shamar/relation-fields.js +94 -0
- package/dist/shamar/relation-fields.js.map +1 -0
- package/dist/shamar/resource-actions.d.ts +9 -0
- package/dist/shamar/resource-actions.d.ts.map +1 -0
- package/dist/shamar/resource-actions.js +42 -0
- package/dist/shamar/resource-actions.js.map +1 -0
- package/dist/shamar/view-context.d.ts +79 -0
- package/dist/shamar/view-context.d.ts.map +1 -0
- package/dist/shamar/view-context.js +74 -0
- package/dist/shamar/view-context.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -0
- package/resources/views/shamar/dashboard.edge +24 -0
- package/resources/views/shamar/form.edge +87 -0
- package/resources/views/shamar/index.edge +149 -0
- package/resources/views/shamar/partials/boolean-value.edge +25 -0
- package/resources/views/shamar/partials/csrf.edge +1 -0
- package/resources/views/shamar/partials/detail-entry.edge +72 -0
- package/resources/views/shamar/partials/dialog-host.edge +125 -0
- package/resources/views/shamar/partials/field-affix-icon.edge +23 -0
- package/resources/views/shamar/partials/field-input.edge +585 -0
- package/resources/views/shamar/partials/field-relation-checkbox.edge +104 -0
- package/resources/views/shamar/partials/field-relation-m2m.edge +110 -0
- package/resources/views/shamar/partials/field-relation-m2o.edge +116 -0
- package/resources/views/shamar/partials/field-relation-table.edge +139 -0
- package/resources/views/shamar/partials/form-container.edge +82 -0
- package/resources/views/shamar/partials/icon.edge +14 -0
- package/resources/views/shamar/partials/list-row.edge +73 -0
- package/resources/views/shamar/partials/list-toolbar.edge +269 -0
- package/resources/views/shamar/partials/nav-mobile.edge +21 -0
- package/resources/views/shamar/partials/nav.edge +58 -0
- package/resources/views/shamar/partials/page-heading.edge +130 -0
- package/resources/views/shamar/partials/pagination.edge +73 -0
- package/resources/views/shamar/partials/record-pager.edge +37 -0
- package/resources/views/shamar/partials/schema-node.edge +289 -0
- package/resources/views/shamar/partials/shell-close.edge +20 -0
- package/resources/views/shamar/partials/shell-open.edge +51 -0
- package/resources/views/shamar/partials/toast.edge +5 -0
- package/resources/views/shamar/partials/topbar.edge +115 -0
- package/resources/views/shamar/show.edge +17 -0
- package/stubs/config/shamar.stub +29 -0
|
@@ -0,0 +1,3658 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const FLASH_MESSAGES = {
|
|
3
|
+
created: { type: 'success', title: 'Created', message: 'The record was saved successfully.' },
|
|
4
|
+
updated: { type: 'success', title: 'Updated', message: 'Your changes have been saved.' },
|
|
5
|
+
deleted: { type: 'success', title: 'Deleted', message: 'The record was removed.' },
|
|
6
|
+
restored: { type: 'success', title: 'Restored', message: 'The record was restored.' },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const TOAST_ICONS = {
|
|
10
|
+
success:
|
|
11
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/></svg>',
|
|
12
|
+
error:
|
|
13
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>',
|
|
14
|
+
warning:
|
|
15
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>',
|
|
16
|
+
info:
|
|
17
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16v-4m0-4h.01M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Z"/></svg>',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function escapeHtml(value) {
|
|
21
|
+
return String(value)
|
|
22
|
+
.replace(/&/g, '&')
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/"/g, '"');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function csrfToken() {
|
|
29
|
+
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function csrfHeaders(extra) {
|
|
33
|
+
const headers = { ...(extra || {}) };
|
|
34
|
+
const token = csrfToken();
|
|
35
|
+
if (token) headers['X-CSRF-Token'] = token;
|
|
36
|
+
return headers;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeToast(typeOrOptions, messageOrDuration, durationMs) {
|
|
40
|
+
if (typeof typeOrOptions === 'object' && typeOrOptions !== null) {
|
|
41
|
+
const options = typeOrOptions;
|
|
42
|
+
return {
|
|
43
|
+
type: options.type || 'success',
|
|
44
|
+
title: options.title || defaultTitleForType(options.type || 'success'),
|
|
45
|
+
message: options.message ?? options.body ?? '',
|
|
46
|
+
durationMs: options.durationMs ?? 4200,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const type = typeOrOptions || 'success';
|
|
51
|
+
let title = defaultTitleForType(type);
|
|
52
|
+
let message = '';
|
|
53
|
+
let duration = durationMs ?? 4200;
|
|
54
|
+
|
|
55
|
+
if (typeof messageOrDuration === 'object' && messageOrDuration !== null) {
|
|
56
|
+
title = messageOrDuration.title || title;
|
|
57
|
+
message = messageOrDuration.message ?? messageOrDuration.body ?? '';
|
|
58
|
+
duration = messageOrDuration.durationMs ?? duration;
|
|
59
|
+
} else if (typeof messageOrDuration === 'number') {
|
|
60
|
+
duration = messageOrDuration;
|
|
61
|
+
} else if (typeof messageOrDuration === 'string') {
|
|
62
|
+
message = messageOrDuration;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { type, title, message, durationMs: duration };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultTitleForType(type) {
|
|
69
|
+
switch (type) {
|
|
70
|
+
case 'error':
|
|
71
|
+
return 'Something went wrong';
|
|
72
|
+
case 'warning':
|
|
73
|
+
return 'Warning';
|
|
74
|
+
case 'info':
|
|
75
|
+
return 'Notice';
|
|
76
|
+
default:
|
|
77
|
+
return 'Success';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveFlashMessage(value, type) {
|
|
82
|
+
const preset = FLASH_MESSAGES[value];
|
|
83
|
+
if (preset) return preset;
|
|
84
|
+
|
|
85
|
+
const decoded = decodeURIComponent(value);
|
|
86
|
+
if (type === 'error') {
|
|
87
|
+
return {
|
|
88
|
+
type: 'error',
|
|
89
|
+
title: 'Something went wrong',
|
|
90
|
+
message: decoded,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
type: 'success',
|
|
95
|
+
title: 'Success',
|
|
96
|
+
message: decoded,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function showToast(typeOrOptions, messageOrDuration, durationMs) {
|
|
101
|
+
const stack = document.getElementById('shamar-toast-stack');
|
|
102
|
+
if (!stack) return;
|
|
103
|
+
|
|
104
|
+
const toastData = normalizeToast(typeOrOptions, messageOrDuration, durationMs);
|
|
105
|
+
const type = toastData.type;
|
|
106
|
+
const title = toastData.title || defaultTitleForType(type);
|
|
107
|
+
const message = toastData.message || '';
|
|
108
|
+
const icon = TOAST_ICONS[type] || TOAST_ICONS.info;
|
|
109
|
+
|
|
110
|
+
const toast = document.createElement('div');
|
|
111
|
+
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
|
112
|
+
toast.className = `shamar-toast shamar-toast--${type}`;
|
|
113
|
+
|
|
114
|
+
const bodyMarkup = message
|
|
115
|
+
? `<p class="shamar-toast__body">${escapeHtml(message)}</p>`
|
|
116
|
+
: '';
|
|
117
|
+
|
|
118
|
+
toast.innerHTML = `
|
|
119
|
+
<div class="shamar-toast__icon">${icon}</div>
|
|
120
|
+
<div class="shamar-toast__content">
|
|
121
|
+
<p class="shamar-toast__title">${escapeHtml(title)}</p>
|
|
122
|
+
${bodyMarkup}
|
|
123
|
+
</div>
|
|
124
|
+
<button type="button" class="shamar-toast__dismiss" aria-label="Dismiss">×</button>
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
const dismiss = () => {
|
|
128
|
+
toast.classList.remove('is-visible');
|
|
129
|
+
setTimeout(() => toast.remove(), 200);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
toast.querySelector('.shamar-toast__dismiss')?.addEventListener('click', dismiss);
|
|
133
|
+
stack.appendChild(toast);
|
|
134
|
+
requestAnimationFrame(() => toast.classList.add('is-visible'));
|
|
135
|
+
setTimeout(dismiss, toastData.durationMs ?? 4200);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One-time secret reveal (API keys / PATs). Blocks redirect until the user confirms.
|
|
140
|
+
*/
|
|
141
|
+
function revealOneTimeSecret({ secret, title, message, confirmLabel, onConfirm }) {
|
|
142
|
+
const value = String(secret ?? '').trim();
|
|
143
|
+
if (!value) {
|
|
144
|
+
if (typeof onConfirm === 'function') onConfirm();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
window.dispatchEvent(
|
|
148
|
+
new CustomEvent('shamar-open-confirm', {
|
|
149
|
+
detail: {
|
|
150
|
+
mode: 'secret',
|
|
151
|
+
variant: 'warning',
|
|
152
|
+
title: title || 'Copy your secret',
|
|
153
|
+
message:
|
|
154
|
+
message ||
|
|
155
|
+
'This value will only be shown once. Copy it now and store it somewhere safe.',
|
|
156
|
+
secret: value,
|
|
157
|
+
confirmLabel: confirmLabel || "I've copied it",
|
|
158
|
+
onConfirm: typeof onConfirm === 'function' ? onConfirm : null,
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function clearFieldErrors(form) {
|
|
165
|
+
if (!form) return;
|
|
166
|
+
form.querySelectorAll('.shamar-field__error').forEach((el) => el.remove());
|
|
167
|
+
form.querySelectorAll('.is-invalid').forEach((el) => el.classList.remove('is-invalid'));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function findFieldHost(form, name) {
|
|
171
|
+
if (!form || !name) return null;
|
|
172
|
+
const byData = form.querySelector(`[data-field-name="${CSS.escape(name)}"]`);
|
|
173
|
+
if (byData) return byData;
|
|
174
|
+
|
|
175
|
+
const input =
|
|
176
|
+
form.querySelector(`[name="${CSS.escape(name)}"]`) ||
|
|
177
|
+
form.querySelector(`[name="${CSS.escape(name)}[]"]`) ||
|
|
178
|
+
form.querySelector(`#field-${CSS.escape(name)}`);
|
|
179
|
+
if (input) return input.closest('.shamar-field') || input.parentElement;
|
|
180
|
+
|
|
181
|
+
for (const el of form.querySelectorAll(
|
|
182
|
+
'[data-shamar-m2o-config], [data-shamar-m2m-config]',
|
|
183
|
+
)) {
|
|
184
|
+
try {
|
|
185
|
+
const raw =
|
|
186
|
+
el.getAttribute('data-shamar-m2o-config') ||
|
|
187
|
+
el.getAttribute('data-shamar-m2m-config') ||
|
|
188
|
+
'{}';
|
|
189
|
+
const cfg = JSON.parse(raw);
|
|
190
|
+
if (cfg?.name === name) return el.closest('.shamar-field') || el;
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Apply server validation errors onto form fields. Returns message list. */
|
|
199
|
+
function applyFieldErrors(form, errors) {
|
|
200
|
+
clearFieldErrors(form);
|
|
201
|
+
if (!form || !errors || typeof errors !== 'object') return [];
|
|
202
|
+
const messages = [];
|
|
203
|
+
let firstHost = null;
|
|
204
|
+
for (const [name, message] of Object.entries(errors)) {
|
|
205
|
+
const text = String(message ?? '').trim();
|
|
206
|
+
if (!text) continue;
|
|
207
|
+
messages.push(text);
|
|
208
|
+
const host = findFieldHost(form, name);
|
|
209
|
+
if (!host) continue;
|
|
210
|
+
if (!firstHost) firstHost = host;
|
|
211
|
+
host.classList.add('is-invalid');
|
|
212
|
+
const control =
|
|
213
|
+
host.querySelector(
|
|
214
|
+
'.shamar-input, .shamar-select, .shamar-combobox, .shamar-m2m-checkboxes, [data-shamar-m2o-config], [data-shamar-m2m-config]',
|
|
215
|
+
) || host;
|
|
216
|
+
control.classList.add('is-invalid');
|
|
217
|
+
const p = document.createElement('p');
|
|
218
|
+
p.className = 'shamar-field__error';
|
|
219
|
+
p.setAttribute('role', 'alert');
|
|
220
|
+
p.setAttribute('data-shamar-field-error', name);
|
|
221
|
+
p.textContent = text;
|
|
222
|
+
host.appendChild(p);
|
|
223
|
+
}
|
|
224
|
+
if (firstHost?.scrollIntoView) {
|
|
225
|
+
firstHost.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
226
|
+
}
|
|
227
|
+
return messages;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validationToastMessage(messages, fallbackMessage) {
|
|
231
|
+
if (messages?.length === 1) return messages[0];
|
|
232
|
+
if (messages?.length > 1) return `${messages[0]} (+${messages.length - 1} more)`;
|
|
233
|
+
if (fallbackMessage) return String(fallbackMessage);
|
|
234
|
+
return 'Could not save your changes — fix any errors and try again.';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function listViewKey(slug) {
|
|
238
|
+
return `shamar-list-view:${slug}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getStoredListView(slug) {
|
|
242
|
+
const stored = localStorage.getItem(listViewKey(slug));
|
|
243
|
+
return stored === 'kanban' ? 'kanban' : 'table';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function setStoredListView(slug, view) {
|
|
247
|
+
localStorage.setItem(listViewKey(slug), view);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function listPath(basePath, slug, view) {
|
|
251
|
+
return view === 'kanban' ? `${basePath}/${slug}/kanban` : `${basePath}/${slug}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resourceSlugFromUrl(basePath, pathname) {
|
|
255
|
+
const baseSegments = basePath.split('/').filter(Boolean);
|
|
256
|
+
const pathSegments = pathname.split('/').filter(Boolean);
|
|
257
|
+
if (pathSegments.length <= baseSegments.length) return '';
|
|
258
|
+
return pathSegments[baseSegments.length] ?? '';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function clearFlashQueryParams() {
|
|
262
|
+
const params = new URLSearchParams(window.location.search);
|
|
263
|
+
if (!params.has('success') && !params.has('error')) return;
|
|
264
|
+
|
|
265
|
+
params.delete('success');
|
|
266
|
+
params.delete('error');
|
|
267
|
+
const query = params.toString();
|
|
268
|
+
const next = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
|
269
|
+
window.history.replaceState({}, '', next);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function consumeQueryFlash() {
|
|
273
|
+
const params = new URLSearchParams(window.location.search);
|
|
274
|
+
const success = params.get('success');
|
|
275
|
+
const error = params.get('error');
|
|
276
|
+
if (!success && !error) return false;
|
|
277
|
+
|
|
278
|
+
const flash = resolveFlashMessage(success || error || '', success ? 'success' : 'error');
|
|
279
|
+
showToast(flash);
|
|
280
|
+
clearFlashQueryParams();
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function reloadParentView({ success, error } = {}) {
|
|
285
|
+
const url = new URL(window.location.href);
|
|
286
|
+
url.searchParams.delete('success');
|
|
287
|
+
url.searchParams.delete('error');
|
|
288
|
+
if (success) url.searchParams.set('success', success);
|
|
289
|
+
else if (error) url.searchParams.set('error', error);
|
|
290
|
+
window.location.assign(`${url.pathname}${url.search}${url.hash}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function consumeInitialFlash() {
|
|
294
|
+
const flash = document.getElementById('shamar-initial-flash');
|
|
295
|
+
if (!flash) return false;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const data = JSON.parse(flash.textContent || 'null');
|
|
299
|
+
if (data?.title || data?.message) {
|
|
300
|
+
showToast(data);
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
/* ignore */
|
|
304
|
+
}
|
|
305
|
+
flash.remove();
|
|
306
|
+
clearFlashQueryParams();
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function maybeRedirectToStoredListView() {
|
|
311
|
+
const root = document.querySelector('[data-shamar-list-root]');
|
|
312
|
+
if (!root) return;
|
|
313
|
+
|
|
314
|
+
const slug = root.dataset.shamarListRoot;
|
|
315
|
+
const basePath = root.dataset.shamarBasePath;
|
|
316
|
+
const currentView = root.dataset.shamarCurrentView;
|
|
317
|
+
const hasKanban = root.dataset.shamarHasKanban === 'true';
|
|
318
|
+
if (!slug || !basePath || !hasKanban) return;
|
|
319
|
+
|
|
320
|
+
const preferred = getStoredListView(slug);
|
|
321
|
+
if (preferred === currentView) return;
|
|
322
|
+
|
|
323
|
+
const params = window.location.search;
|
|
324
|
+
window.location.replace(listPath(basePath, slug, preferred) + params);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function applyListHrefs() {
|
|
328
|
+
const basePath = document.body.dataset.shamarBasePath;
|
|
329
|
+
if (!basePath) return;
|
|
330
|
+
|
|
331
|
+
document.querySelectorAll('[data-shamar-list-href]').forEach((el) => {
|
|
332
|
+
const slug = el.getAttribute('data-shamar-list-href');
|
|
333
|
+
if (!slug || !(el instanceof HTMLAnchorElement)) return;
|
|
334
|
+
el.href = listPath(basePath, slug, getStoredListView(slug));
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function bindListViewSwitcher() {
|
|
339
|
+
document.querySelectorAll('[data-shamar-list-view]').forEach((el) => {
|
|
340
|
+
el.addEventListener('click', () => {
|
|
341
|
+
const slug = el.getAttribute('data-shamar-list-view-slug');
|
|
342
|
+
const view = el.getAttribute('data-shamar-list-view');
|
|
343
|
+
if (slug && view) setStoredListView(slug, view);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Auto-refresh interval while the list refresh control is armed. */
|
|
349
|
+
const AUTO_REFRESH_MS = 10 * 1000; // 10 seconds
|
|
350
|
+
const AUTO_REFRESH_CLICK_MS = 300;
|
|
351
|
+
let _listAutoRefreshTimer = null;
|
|
352
|
+
let _listRefreshClickTimer = null;
|
|
353
|
+
let _listRefreshLastClick = 0;
|
|
354
|
+
let _listRefreshBusy = false;
|
|
355
|
+
|
|
356
|
+
function listAutoRefreshKey(slug) {
|
|
357
|
+
return `shamar-list-autorefresh:${slug}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isListAutoRefreshEnabled(slug) {
|
|
361
|
+
return sessionStorage.getItem(listAutoRefreshKey(slug)) === '1';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function setListAutoRefreshEnabled(slug, enabled) {
|
|
365
|
+
if (enabled) {
|
|
366
|
+
sessionStorage.setItem(listAutoRefreshKey(slug), '1');
|
|
367
|
+
} else {
|
|
368
|
+
sessionStorage.removeItem(listAutoRefreshKey(slug));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function setListRefreshBusy(button, busy) {
|
|
373
|
+
_listRefreshBusy = busy;
|
|
374
|
+
const target = button || document.querySelector('[data-shamar-list-refresh]');
|
|
375
|
+
if (!target) return;
|
|
376
|
+
target.disabled = busy;
|
|
377
|
+
target.setAttribute('aria-busy', busy ? 'true' : 'false');
|
|
378
|
+
target.classList.toggle('is-busy', busy);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function refreshListPage(button) {
|
|
382
|
+
if (_listRefreshBusy) return false;
|
|
383
|
+
setListRefreshBusy(button, true);
|
|
384
|
+
window.location.reload();
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function stopListAutoRefresh() {
|
|
389
|
+
if (_listAutoRefreshTimer) {
|
|
390
|
+
clearInterval(_listAutoRefreshTimer);
|
|
391
|
+
_listAutoRefreshTimer = null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function startListAutoRefresh(button) {
|
|
396
|
+
stopListAutoRefresh();
|
|
397
|
+
_listAutoRefreshTimer = window.setInterval(() => {
|
|
398
|
+
refreshListPage(button);
|
|
399
|
+
}, AUTO_REFRESH_MS);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function syncListRefreshButton(button, enabled) {
|
|
403
|
+
if (!button) return;
|
|
404
|
+
button.classList.toggle('is-auto', enabled);
|
|
405
|
+
button.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
|
406
|
+
button.title = enabled
|
|
407
|
+
? 'Auto-refresh on (every 10s) — click to turn off'
|
|
408
|
+
: 'Refresh (double-click for auto-refresh every 10s)';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function bindMediaUploads() {
|
|
412
|
+
document.querySelectorAll('[data-shamar-media-file]').forEach((input) => {
|
|
413
|
+
if (!(input instanceof HTMLInputElement)) return;
|
|
414
|
+
input.addEventListener('change', async () => {
|
|
415
|
+
const file = input.files?.[0];
|
|
416
|
+
if (!file) return;
|
|
417
|
+
const root = input.closest('[data-shamar-media-field]');
|
|
418
|
+
const hidden = root?.querySelector('[data-shamar-media-value]');
|
|
419
|
+
const status = root?.querySelector('[data-shamar-media-status]');
|
|
420
|
+
const uploadUrl = input.dataset.uploadUrl;
|
|
421
|
+
const fieldName = input.dataset.fieldName;
|
|
422
|
+
const maxBytes = Number(input.dataset.maxBytes || 0);
|
|
423
|
+
if (!uploadUrl || !fieldName || !(hidden instanceof HTMLInputElement)) return;
|
|
424
|
+
if (maxBytes > 0 && file.size > maxBytes) {
|
|
425
|
+
if (status instanceof HTMLElement) {
|
|
426
|
+
status.hidden = false;
|
|
427
|
+
status.textContent = `File exceeds ${maxBytes} bytes.`;
|
|
428
|
+
}
|
|
429
|
+
input.value = '';
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const reader = new FileReader();
|
|
433
|
+
reader.onload = async () => {
|
|
434
|
+
try {
|
|
435
|
+
if (status instanceof HTMLElement) {
|
|
436
|
+
status.hidden = false;
|
|
437
|
+
status.textContent = 'Uploading…';
|
|
438
|
+
}
|
|
439
|
+
const data = String(reader.result ?? '');
|
|
440
|
+
const res = await fetch(uploadUrl, {
|
|
441
|
+
method: 'POST',
|
|
442
|
+
headers: {
|
|
443
|
+
'Content-Type': 'application/json',
|
|
444
|
+
Accept: 'application/json',
|
|
445
|
+
'X-CSRF-Token': csrfToken() || '',
|
|
446
|
+
},
|
|
447
|
+
credentials: 'same-origin',
|
|
448
|
+
body: JSON.stringify({
|
|
449
|
+
field: fieldName,
|
|
450
|
+
filename: file.name,
|
|
451
|
+
mimeType: file.type || 'application/octet-stream',
|
|
452
|
+
data,
|
|
453
|
+
}),
|
|
454
|
+
});
|
|
455
|
+
const payload = await res.json();
|
|
456
|
+
if (!res.ok) throw new Error(payload?.message || 'Upload failed');
|
|
457
|
+
hidden.value = payload.media?.url || '';
|
|
458
|
+
if (status instanceof HTMLElement) {
|
|
459
|
+
status.textContent = 'Uploaded.';
|
|
460
|
+
}
|
|
461
|
+
const preview = root?.querySelector('[data-shamar-media-preview]');
|
|
462
|
+
if (preview instanceof HTMLElement && hidden.value) {
|
|
463
|
+
if (input.dataset.fieldType === 'image') {
|
|
464
|
+
preview.innerHTML = `<img src="${hidden.value}" alt="" class="max-h-32 rounded-md border border-default object-contain" />`;
|
|
465
|
+
} else {
|
|
466
|
+
preview.innerHTML = `<a href="${hidden.value}" class="text-sm text-fg-brand hover:underline" target="_blank" rel="noopener">View file</a>`;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (status instanceof HTMLElement) {
|
|
471
|
+
status.textContent =
|
|
472
|
+
error instanceof Error ? error.message : 'Upload failed';
|
|
473
|
+
}
|
|
474
|
+
input.value = '';
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
reader.readAsDataURL(file);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function bindListRefresh() {
|
|
483
|
+
const root = document.querySelector('[data-shamar-list-root]');
|
|
484
|
+
const button = document.querySelector('[data-shamar-list-refresh]');
|
|
485
|
+
if (!root || !button) return;
|
|
486
|
+
|
|
487
|
+
const slug = root.dataset.shamarListRoot;
|
|
488
|
+
if (!slug) return;
|
|
489
|
+
|
|
490
|
+
setListRefreshBusy(button, false);
|
|
491
|
+
|
|
492
|
+
const enabled = isListAutoRefreshEnabled(slug);
|
|
493
|
+
syncListRefreshButton(button, enabled);
|
|
494
|
+
if (enabled) {
|
|
495
|
+
startListAutoRefresh(button);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
button.addEventListener('click', (event) => {
|
|
499
|
+
event.preventDefault();
|
|
500
|
+
if (_listRefreshBusy || button.disabled) return;
|
|
501
|
+
|
|
502
|
+
const now = Date.now();
|
|
503
|
+
const isDoubleClick = now - _listRefreshLastClick < AUTO_REFRESH_CLICK_MS;
|
|
504
|
+
_listRefreshLastClick = now;
|
|
505
|
+
|
|
506
|
+
if (_listRefreshClickTimer) {
|
|
507
|
+
clearTimeout(_listRefreshClickTimer);
|
|
508
|
+
_listRefreshClickTimer = null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (isDoubleClick) {
|
|
512
|
+
const next = !isListAutoRefreshEnabled(slug);
|
|
513
|
+
setListAutoRefreshEnabled(slug, next);
|
|
514
|
+
syncListRefreshButton(button, next);
|
|
515
|
+
if (next) {
|
|
516
|
+
startListAutoRefresh(button);
|
|
517
|
+
showToast('info', {
|
|
518
|
+
title: 'Auto-refresh on',
|
|
519
|
+
message: 'This list will refresh every 10 seconds.',
|
|
520
|
+
});
|
|
521
|
+
} else {
|
|
522
|
+
stopListAutoRefresh();
|
|
523
|
+
showToast('info', {
|
|
524
|
+
title: 'Auto-refresh off',
|
|
525
|
+
message: 'Automatic refresh has been stopped.',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
_listRefreshClickTimer = window.setTimeout(() => {
|
|
532
|
+
_listRefreshClickTimer = null;
|
|
533
|
+
if (_listRefreshBusy || button.disabled) return;
|
|
534
|
+
if (isListAutoRefreshEnabled(slug)) {
|
|
535
|
+
setListAutoRefreshEnabled(slug, false);
|
|
536
|
+
stopListAutoRefresh();
|
|
537
|
+
syncListRefreshButton(button, false);
|
|
538
|
+
showToast('info', {
|
|
539
|
+
title: 'Auto-refresh off',
|
|
540
|
+
message: 'Automatic refresh has been stopped.',
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
refreshListPage(button);
|
|
545
|
+
}, AUTO_REFRESH_CLICK_MS);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
window.ShamarUI = {
|
|
550
|
+
showToast,
|
|
551
|
+
applyFieldErrors,
|
|
552
|
+
clearFieldErrors,
|
|
553
|
+
getStoredListView,
|
|
554
|
+
setStoredListView,
|
|
555
|
+
listPath,
|
|
556
|
+
resolveFlashMessage,
|
|
557
|
+
openDialog(detail) {
|
|
558
|
+
_dialogOnResult = typeof detail.onResult === 'function' ? detail.onResult : null;
|
|
559
|
+
window.dispatchEvent(new CustomEvent('shamar-open-dialog', { detail }));
|
|
560
|
+
},
|
|
561
|
+
openConfirm(detail) {
|
|
562
|
+
window.dispatchEvent(new CustomEvent('shamar-open-confirm', { detail }));
|
|
563
|
+
},
|
|
564
|
+
readImportFile(event) {
|
|
565
|
+
const input = event?.target;
|
|
566
|
+
const file = input?.files?.[0];
|
|
567
|
+
if (!file) return;
|
|
568
|
+
const form = input.closest('form');
|
|
569
|
+
const textarea = form?.querySelector('textarea[name="csv"]');
|
|
570
|
+
if (!textarea) return;
|
|
571
|
+
const reader = new FileReader();
|
|
572
|
+
reader.onload = () => {
|
|
573
|
+
textarea.value = String(reader.result || '');
|
|
574
|
+
};
|
|
575
|
+
reader.readAsText(file);
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
let _dialogOnResult = null;
|
|
580
|
+
let _pendingRelationPick = null;
|
|
581
|
+
|
|
582
|
+
function applyPendingRelationPick() {
|
|
583
|
+
if (!_pendingRelationPick) return;
|
|
584
|
+
const detail = _pendingRelationPick;
|
|
585
|
+
_pendingRelationPick = null;
|
|
586
|
+
const eventName = detail.mode === 'm2m' ? 'shamar-m2m-pick' : 'shamar-m2o-pick';
|
|
587
|
+
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function withEmbed(url) {
|
|
591
|
+
const next = new URL(url, window.location.origin);
|
|
592
|
+
next.searchParams.set('embed', '1');
|
|
593
|
+
return `${next.pathname}${next.search}`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function withoutEmbed(url) {
|
|
597
|
+
const next = new URL(url, window.location.origin);
|
|
598
|
+
next.searchParams.delete('embed');
|
|
599
|
+
const query = next.searchParams.toString();
|
|
600
|
+
return `${next.pathname}${query ? `?${query}` : ''}`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function parseAdminRecordPath(pathname) {
|
|
604
|
+
const basePath = document.body.dataset.shamarBasePath || '';
|
|
605
|
+
const baseSegments = basePath.split('/').filter(Boolean);
|
|
606
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
607
|
+
if (segments.length < baseSegments.length + 2) return null;
|
|
608
|
+
for (let index = 0; index < baseSegments.length; index += 1) {
|
|
609
|
+
if (segments[index] !== baseSegments[index]) return null;
|
|
610
|
+
}
|
|
611
|
+
const slug = segments[baseSegments.length];
|
|
612
|
+
const id = segments[baseSegments.length + 1];
|
|
613
|
+
if (!slug || !id) return null;
|
|
614
|
+
if (['create', 'edit', 'relation-search', 'relation-quick-create', 'kanban'].includes(id)) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
if (id === 'summary') return null;
|
|
618
|
+
return { slug, id };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function flashFromRedirect(success, error) {
|
|
622
|
+
if (success) return resolveFlashMessage(success, 'success');
|
|
623
|
+
if (error) {
|
|
624
|
+
return {
|
|
625
|
+
type: 'error',
|
|
626
|
+
title: 'Something went wrong',
|
|
627
|
+
message: decodeURIComponent(error),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
document.addEventListener('alpine:init', () => {
|
|
634
|
+
registerShamarAlpineComponents();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
if (window.Alpine) {
|
|
638
|
+
registerShamarAlpineComponents();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function readM2oConfig(el) {
|
|
642
|
+
const raw = el?.getAttribute?.('data-shamar-m2o-config');
|
|
643
|
+
if (!raw) return {};
|
|
644
|
+
try {
|
|
645
|
+
return JSON.parse(raw);
|
|
646
|
+
} catch {
|
|
647
|
+
return {};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function readM2mConfig(el) {
|
|
652
|
+
const raw = el?.getAttribute?.('data-shamar-m2m-config');
|
|
653
|
+
if (!raw) return {};
|
|
654
|
+
try {
|
|
655
|
+
return JSON.parse(raw);
|
|
656
|
+
} catch {
|
|
657
|
+
return {};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function createShamarM2o(cfg) {
|
|
662
|
+
return {
|
|
663
|
+
name: cfg.name,
|
|
664
|
+
relatedResource: cfg.relatedResource,
|
|
665
|
+
singularLabel: cfg.singularLabel || 'Record',
|
|
666
|
+
searchUrl: cfg.searchUrl,
|
|
667
|
+
quickCreateUrl: cfg.quickCreateUrl,
|
|
668
|
+
createUrl: cfg.createUrl,
|
|
669
|
+
detailUrlBase: cfg.detailUrlBase,
|
|
670
|
+
readonly: !!cfg.readonly,
|
|
671
|
+
required: !!cfg.required,
|
|
672
|
+
|
|
673
|
+
value: cfg.initialId != null && cfg.initialId !== '' ? String(cfg.initialId) : null,
|
|
674
|
+
label: cfg.initialLabel || '',
|
|
675
|
+
query: '',
|
|
676
|
+
results: [],
|
|
677
|
+
cursor: 0,
|
|
678
|
+
open: false,
|
|
679
|
+
loading: false,
|
|
680
|
+
_abort: null,
|
|
681
|
+
_initialFetched: false,
|
|
682
|
+
|
|
683
|
+
init() {
|
|
684
|
+
this.query = this.label;
|
|
685
|
+
this.$nextTick?.(() => this.syncHiddenInput());
|
|
686
|
+
this._pickHandler = (event) => {
|
|
687
|
+
const detail = event.detail || {};
|
|
688
|
+
if (detail.field === this.name) {
|
|
689
|
+
this.pick({ id: detail.id, label: detail.label });
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
window.addEventListener('shamar-m2o-pick', this._pickHandler);
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
destroy() {
|
|
696
|
+
if (this._pickHandler) {
|
|
697
|
+
window.removeEventListener('shamar-m2o-pick', this._pickHandler);
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
|
|
701
|
+
get exactMatch() {
|
|
702
|
+
const q = this.query.trim().toLowerCase();
|
|
703
|
+
if (!q) return null;
|
|
704
|
+
return this.results.find((item) => item.label.toLowerCase() === q) || null;
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
get createCandidate() {
|
|
708
|
+
if (this.readonly || !this.quickCreateUrl) return false;
|
|
709
|
+
const q = this.query.trim();
|
|
710
|
+
return q.length > 0 && !this.exactMatch;
|
|
711
|
+
},
|
|
712
|
+
|
|
713
|
+
get canCreateAndEdit() {
|
|
714
|
+
return !this.readonly && !!this.createUrl;
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
async fetchResults() {
|
|
718
|
+
if (this._abort) this._abort.abort();
|
|
719
|
+
const ctl = new AbortController();
|
|
720
|
+
this._abort = ctl;
|
|
721
|
+
this.loading = true;
|
|
722
|
+
try {
|
|
723
|
+
const url = `${this.searchUrl}&q=${encodeURIComponent(this.query)}`;
|
|
724
|
+
const response = await fetch(url, { signal: ctl.signal });
|
|
725
|
+
if (!response.ok) throw new Error('fetch failed');
|
|
726
|
+
const data = await response.json();
|
|
727
|
+
this.results = data.results || [];
|
|
728
|
+
this.cursor = 0;
|
|
729
|
+
} catch (error) {
|
|
730
|
+
if (error.name !== 'AbortError') {
|
|
731
|
+
this.results = [];
|
|
732
|
+
}
|
|
733
|
+
} finally {
|
|
734
|
+
this.loading = false;
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
onFocus() {
|
|
739
|
+
this.open = true;
|
|
740
|
+
if (!this._initialFetched) {
|
|
741
|
+
this._initialFetched = true;
|
|
742
|
+
this.fetchResults();
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
onInput() {
|
|
747
|
+
this.open = true;
|
|
748
|
+
if (this.query !== this.label) {
|
|
749
|
+
this.value = null;
|
|
750
|
+
this.label = '';
|
|
751
|
+
}
|
|
752
|
+
this.fetchResults();
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
close() {
|
|
756
|
+
this.open = false;
|
|
757
|
+
this.query = this.label;
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
onFocusOut(event) {
|
|
761
|
+
const next = event.relatedTarget;
|
|
762
|
+
if (next && this.$el.contains(next)) return;
|
|
763
|
+
// Defer past the option mousedown/click so pick() can run first.
|
|
764
|
+
setTimeout(() => {
|
|
765
|
+
if (!this.open) return;
|
|
766
|
+
if (this.$el.contains(document.activeElement)) return;
|
|
767
|
+
this.close();
|
|
768
|
+
}, 0);
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
moveCursor(delta) {
|
|
772
|
+
if (!this.open) {
|
|
773
|
+
this.open = true;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const extra = this.createCandidate ? 1 : 0;
|
|
777
|
+
const max = this.results.length + extra - 1;
|
|
778
|
+
if (max < 0) return;
|
|
779
|
+
this.cursor = Math.max(0, Math.min(max, this.cursor + delta));
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
onEnter() {
|
|
783
|
+
if (!this.open) return;
|
|
784
|
+
if (this.cursor < this.results.length) {
|
|
785
|
+
this.pick(this.results[this.cursor]);
|
|
786
|
+
} else if (this.createCandidate) {
|
|
787
|
+
this.createFromQuery();
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
|
|
791
|
+
pick(item) {
|
|
792
|
+
if (!item || item.id == null) return;
|
|
793
|
+
this.value = String(item.id);
|
|
794
|
+
this.label = item.label || this.value;
|
|
795
|
+
this.query = this.label;
|
|
796
|
+
this.open = false;
|
|
797
|
+
this.syncHiddenInput();
|
|
798
|
+
notifyRelationFieldChange(this.$el, this.name);
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
syncHiddenInput() {
|
|
802
|
+
const hidden = this.$el.querySelector(`input[type="hidden"][name="${this.name}"]`);
|
|
803
|
+
if (hidden instanceof HTMLInputElement) {
|
|
804
|
+
hidden.value = this.value != null && this.value !== '' ? String(this.value) : '';
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
clearSelection() {
|
|
809
|
+
this.value = null;
|
|
810
|
+
this.label = '';
|
|
811
|
+
this.query = '';
|
|
812
|
+
this.cursor = 0;
|
|
813
|
+
this.open = false;
|
|
814
|
+
this.syncHiddenInput();
|
|
815
|
+
this.$refs.input?.focus();
|
|
816
|
+
notifyRelationFieldChange(this.$el, this.name);
|
|
817
|
+
},
|
|
818
|
+
|
|
819
|
+
openRecord() {
|
|
820
|
+
if (!this.value || !this.detailUrlBase) return;
|
|
821
|
+
window.ShamarUI.openDialog({
|
|
822
|
+
url: `${this.detailUrlBase}/${this.value}/edit`,
|
|
823
|
+
title: this.label || 'Edit record',
|
|
824
|
+
slug: this.relatedResource,
|
|
825
|
+
});
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
async createFromQuery() {
|
|
829
|
+
const name = this.query.trim();
|
|
830
|
+
if (!name) return;
|
|
831
|
+
try {
|
|
832
|
+
const response = await fetch(this.quickCreateUrl, {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
|
|
835
|
+
body: JSON.stringify({
|
|
836
|
+
field: this.name,
|
|
837
|
+
name,
|
|
838
|
+
parentId: this.parentId,
|
|
839
|
+
}),
|
|
840
|
+
});
|
|
841
|
+
if (response.status === 400) {
|
|
842
|
+
if (this.canCreateAndEdit) {
|
|
843
|
+
this.createAndEdit();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const body = await response.json().catch(() => ({}));
|
|
847
|
+
showToast('error', {
|
|
848
|
+
title: 'Create blocked',
|
|
849
|
+
message: body.message || body.detail || 'Cannot create this record inline.',
|
|
850
|
+
});
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (!response.ok) throw new Error('create failed');
|
|
854
|
+
const item = await response.json();
|
|
855
|
+
this.results = [item, ...this.results.filter((entry) => String(entry.id) !== String(item.id))];
|
|
856
|
+
this.pick(item);
|
|
857
|
+
} catch {
|
|
858
|
+
showToast('error', {
|
|
859
|
+
title: 'Error',
|
|
860
|
+
message: 'Unable to create record.',
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
|
|
865
|
+
createAndEdit() {
|
|
866
|
+
if (!this.createUrl) return;
|
|
867
|
+
this.open = false;
|
|
868
|
+
const q = (this.query || '').trim();
|
|
869
|
+
const url = q
|
|
870
|
+
? `${this.createUrl}?name=${encodeURIComponent(q)}`
|
|
871
|
+
: this.createUrl;
|
|
872
|
+
const title = q ? `New ${this.singularLabel}` : `Create ${this.singularLabel}`;
|
|
873
|
+
window.ShamarUI.openDialog({
|
|
874
|
+
url,
|
|
875
|
+
title,
|
|
876
|
+
slug: this.relatedResource,
|
|
877
|
+
onResult: (result) => {
|
|
878
|
+
if (result?.id) {
|
|
879
|
+
_pendingRelationPick = {
|
|
880
|
+
mode: 'm2o',
|
|
881
|
+
field: this.name,
|
|
882
|
+
id: result.id,
|
|
883
|
+
label: result.label,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
},
|
|
887
|
+
});
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function createShamarM2m(cfg) {
|
|
893
|
+
return {
|
|
894
|
+
name: cfg.name,
|
|
895
|
+
relatedResource: cfg.relatedResource,
|
|
896
|
+
singularLabel: cfg.singularLabel || 'Record',
|
|
897
|
+
searchUrl: cfg.searchUrl,
|
|
898
|
+
quickCreateUrl: cfg.quickCreateUrl,
|
|
899
|
+
createUrl: cfg.createUrl,
|
|
900
|
+
detailUrlBase: cfg.detailUrlBase,
|
|
901
|
+
attachUrl: cfg.attachUrl || null,
|
|
902
|
+
detachUrl: cfg.detachUrl || null,
|
|
903
|
+
kind: cfg.kind || 'manyToMany',
|
|
904
|
+
foreignKey: cfg.foreignKey || null,
|
|
905
|
+
parentId: cfg.parentId != null && cfg.parentId !== '' ? String(cfg.parentId) : null,
|
|
906
|
+
readonly: !!cfg.readonly,
|
|
907
|
+
required: !!cfg.required,
|
|
908
|
+
|
|
909
|
+
selected: Array.isArray(cfg.initialItems)
|
|
910
|
+
? cfg.initialItems.map((item) => ({
|
|
911
|
+
id: String(item.id),
|
|
912
|
+
label: item.label || String(item.id),
|
|
913
|
+
name: item.name,
|
|
914
|
+
group: item.group,
|
|
915
|
+
ability: item.ability,
|
|
916
|
+
}))
|
|
917
|
+
: [],
|
|
918
|
+
query: '',
|
|
919
|
+
results: [],
|
|
920
|
+
cursor: 0,
|
|
921
|
+
open: false,
|
|
922
|
+
loading: false,
|
|
923
|
+
_abort: null,
|
|
924
|
+
_initialFetched: false,
|
|
925
|
+
|
|
926
|
+
init() {
|
|
927
|
+
this._pickHandler = (event) => {
|
|
928
|
+
const detail = event.detail || {};
|
|
929
|
+
if (detail.field === this.name) {
|
|
930
|
+
this.pick({ id: detail.id, label: detail.label });
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
window.addEventListener('shamar-m2m-pick', this._pickHandler);
|
|
934
|
+
},
|
|
935
|
+
|
|
936
|
+
destroy() {
|
|
937
|
+
if (this._pickHandler) {
|
|
938
|
+
window.removeEventListener('shamar-m2m-pick', this._pickHandler);
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
|
|
942
|
+
get valueCsv() {
|
|
943
|
+
return this.selected.map((item) => item.id).join(',');
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
get selectedIds() {
|
|
947
|
+
return new Set(this.selected.map((item) => String(item.id)));
|
|
948
|
+
},
|
|
949
|
+
|
|
950
|
+
get availableResults() {
|
|
951
|
+
const selected = this.selectedIds;
|
|
952
|
+
return this.results.filter((item) => !selected.has(String(item.id)));
|
|
953
|
+
},
|
|
954
|
+
|
|
955
|
+
get exactMatch() {
|
|
956
|
+
const q = this.query.trim().toLowerCase();
|
|
957
|
+
if (!q) return null;
|
|
958
|
+
return this.availableResults.find((item) => item.label.toLowerCase() === q) || null;
|
|
959
|
+
},
|
|
960
|
+
|
|
961
|
+
get createCandidate() {
|
|
962
|
+
if (this.readonly || !this.quickCreateUrl) return false;
|
|
963
|
+
const q = this.query.trim();
|
|
964
|
+
return q.length > 0 && !this.exactMatch;
|
|
965
|
+
},
|
|
966
|
+
|
|
967
|
+
get canCreateAndEdit() {
|
|
968
|
+
return !this.readonly && !!this.createUrl;
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
focusInput() {
|
|
972
|
+
if (this.readonly) return;
|
|
973
|
+
this.$refs.input?.focus();
|
|
974
|
+
this.onFocus();
|
|
975
|
+
},
|
|
976
|
+
|
|
977
|
+
async fetchResults() {
|
|
978
|
+
if (this._abort) this._abort.abort();
|
|
979
|
+
const ctl = new AbortController();
|
|
980
|
+
this._abort = ctl;
|
|
981
|
+
this.loading = true;
|
|
982
|
+
try {
|
|
983
|
+
const url = `${this.searchUrl}&q=${encodeURIComponent(this.query)}`;
|
|
984
|
+
const response = await fetch(url, { signal: ctl.signal });
|
|
985
|
+
if (!response.ok) throw new Error('fetch failed');
|
|
986
|
+
const data = await response.json();
|
|
987
|
+
this.results = data.results || [];
|
|
988
|
+
this.cursor = 0;
|
|
989
|
+
} catch (error) {
|
|
990
|
+
if (error.name !== 'AbortError') {
|
|
991
|
+
this.results = [];
|
|
992
|
+
}
|
|
993
|
+
} finally {
|
|
994
|
+
this.loading = false;
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
|
|
998
|
+
onFocus() {
|
|
999
|
+
if (this.readonly) return;
|
|
1000
|
+
this.open = true;
|
|
1001
|
+
if (!this._initialFetched) {
|
|
1002
|
+
this._initialFetched = true;
|
|
1003
|
+
this.fetchResults();
|
|
1004
|
+
}
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
onInput() {
|
|
1008
|
+
this.open = true;
|
|
1009
|
+
this.fetchResults();
|
|
1010
|
+
},
|
|
1011
|
+
|
|
1012
|
+
close() {
|
|
1013
|
+
this.open = false;
|
|
1014
|
+
this.query = '';
|
|
1015
|
+
},
|
|
1016
|
+
|
|
1017
|
+
onFocusOut(event) {
|
|
1018
|
+
const next = event.relatedTarget;
|
|
1019
|
+
if (next && this.$el.contains(next)) return;
|
|
1020
|
+
// Defer past the option mousedown/click so pick() can run first.
|
|
1021
|
+
setTimeout(() => {
|
|
1022
|
+
if (!this.open) return;
|
|
1023
|
+
if (this.$el.contains(document.activeElement)) return;
|
|
1024
|
+
this.close();
|
|
1025
|
+
}, 0);
|
|
1026
|
+
},
|
|
1027
|
+
|
|
1028
|
+
moveCursor(delta) {
|
|
1029
|
+
if (!this.open) {
|
|
1030
|
+
this.open = true;
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const extra = this.createCandidate ? 1 : 0;
|
|
1034
|
+
const max = this.availableResults.length + extra - 1;
|
|
1035
|
+
if (max < 0) return;
|
|
1036
|
+
this.cursor = Math.max(0, Math.min(max, this.cursor + delta));
|
|
1037
|
+
},
|
|
1038
|
+
|
|
1039
|
+
onEnter() {
|
|
1040
|
+
if (!this.open) return;
|
|
1041
|
+
if (this.cursor < this.availableResults.length) {
|
|
1042
|
+
this.pick(this.availableResults[this.cursor]);
|
|
1043
|
+
} else if (this.createCandidate) {
|
|
1044
|
+
this.createFromQuery();
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
|
|
1048
|
+
onBackspace() {
|
|
1049
|
+
if (this.query || this.readonly || this.selected.length === 0) return;
|
|
1050
|
+
this.remove(this.selected[this.selected.length - 1].id);
|
|
1051
|
+
},
|
|
1052
|
+
|
|
1053
|
+
pick(item) {
|
|
1054
|
+
if (!item || item.id == null) return;
|
|
1055
|
+
const id = String(item.id);
|
|
1056
|
+
if (this.selectedIds.has(id)) return;
|
|
1057
|
+
if (this.kind === 'hasMany' && this.attachUrl && this.parentId) {
|
|
1058
|
+
this.attachHasMany(item);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
this.selected = [...this.selected, { id, label: item.label || id }];
|
|
1062
|
+
this.query = '';
|
|
1063
|
+
this.cursor = 0;
|
|
1064
|
+
this.fetchResults();
|
|
1065
|
+
this.$nextTick?.(() => this.$refs.input?.focus());
|
|
1066
|
+
notifyRelationFieldChange(this.$el, this.name);
|
|
1067
|
+
},
|
|
1068
|
+
|
|
1069
|
+
async attachHasMany(item) {
|
|
1070
|
+
try {
|
|
1071
|
+
const response = await fetch(this.attachUrl, {
|
|
1072
|
+
method: 'POST',
|
|
1073
|
+
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
|
|
1074
|
+
body: JSON.stringify({
|
|
1075
|
+
field: this.name,
|
|
1076
|
+
relatedId: item.id,
|
|
1077
|
+
parentId: this.parentId,
|
|
1078
|
+
}),
|
|
1079
|
+
});
|
|
1080
|
+
if (!response.ok) throw new Error('attach failed');
|
|
1081
|
+
const linked = await response.json();
|
|
1082
|
+
const id = String(linked.id);
|
|
1083
|
+
this.selected = [
|
|
1084
|
+
...this.selected.filter((entry) => String(entry.id) !== id),
|
|
1085
|
+
{ id, label: linked.label || item.label || id },
|
|
1086
|
+
];
|
|
1087
|
+
this.query = '';
|
|
1088
|
+
this.cursor = 0;
|
|
1089
|
+
this.fetchResults();
|
|
1090
|
+
this.$nextTick?.(() => this.$refs.input?.focus());
|
|
1091
|
+
} catch {
|
|
1092
|
+
showToast('error', { title: 'Error', message: 'Unable to link record.' });
|
|
1093
|
+
}
|
|
1094
|
+
},
|
|
1095
|
+
|
|
1096
|
+
remove(id) {
|
|
1097
|
+
const target = String(id);
|
|
1098
|
+
if (this.kind === 'hasMany' && this.detachUrl) {
|
|
1099
|
+
this.detachHasMany(target);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
this.selected = this.selected.filter((item) => String(item.id) !== target);
|
|
1103
|
+
notifyRelationFieldChange(this.$el, this.name);
|
|
1104
|
+
},
|
|
1105
|
+
|
|
1106
|
+
async detachHasMany(relatedId) {
|
|
1107
|
+
try {
|
|
1108
|
+
const response = await fetch(this.detachUrl, {
|
|
1109
|
+
method: 'POST',
|
|
1110
|
+
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
|
|
1111
|
+
body: JSON.stringify({ field: this.name, relatedId }),
|
|
1112
|
+
});
|
|
1113
|
+
if (!response.ok) throw new Error('detach failed');
|
|
1114
|
+
this.selected = this.selected.filter((item) => String(item.id) !== String(relatedId));
|
|
1115
|
+
} catch {
|
|
1116
|
+
showToast('error', { title: 'Error', message: 'Unable to unlink record.' });
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
|
|
1120
|
+
openRecord(item) {
|
|
1121
|
+
if (!item?.id || !this.detailUrlBase) return;
|
|
1122
|
+
window.ShamarUI.openDialog({
|
|
1123
|
+
url: `${this.detailUrlBase}/${item.id}/edit`,
|
|
1124
|
+
title: item.label || 'Edit record',
|
|
1125
|
+
slug: this.relatedResource,
|
|
1126
|
+
});
|
|
1127
|
+
},
|
|
1128
|
+
|
|
1129
|
+
async createFromQuery() {
|
|
1130
|
+
const name = this.query.trim();
|
|
1131
|
+
if (!name) return;
|
|
1132
|
+
try {
|
|
1133
|
+
const response = await fetch(this.quickCreateUrl, {
|
|
1134
|
+
method: 'POST',
|
|
1135
|
+
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
|
|
1136
|
+
body: JSON.stringify({
|
|
1137
|
+
field: this.name,
|
|
1138
|
+
name,
|
|
1139
|
+
parentId: this.parentId,
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
if (response.status === 400) {
|
|
1143
|
+
if (this.canCreateAndEdit) {
|
|
1144
|
+
this.createAndEdit();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const body = await response.json().catch(() => ({}));
|
|
1148
|
+
showToast('error', {
|
|
1149
|
+
title: 'Create blocked',
|
|
1150
|
+
message: body.message || body.detail || 'Cannot create this record inline.',
|
|
1151
|
+
});
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (!response.ok) throw new Error('create failed');
|
|
1155
|
+
const item = await response.json();
|
|
1156
|
+
this.results = [item, ...this.results.filter((entry) => String(entry.id) !== String(item.id))];
|
|
1157
|
+
this.pick(item);
|
|
1158
|
+
} catch {
|
|
1159
|
+
showToast('error', {
|
|
1160
|
+
title: 'Error',
|
|
1161
|
+
message: 'Unable to create record.',
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
},
|
|
1165
|
+
|
|
1166
|
+
createAndEdit() {
|
|
1167
|
+
if (!this.createUrl) return;
|
|
1168
|
+
this.open = false;
|
|
1169
|
+
const q = (this.query || '').trim();
|
|
1170
|
+
const url = q
|
|
1171
|
+
? `${this.createUrl}?name=${encodeURIComponent(q)}`
|
|
1172
|
+
: this.createUrl;
|
|
1173
|
+
const title = q ? `New ${this.singularLabel}` : `Create ${this.singularLabel}`;
|
|
1174
|
+
window.ShamarUI.openDialog({
|
|
1175
|
+
url,
|
|
1176
|
+
title,
|
|
1177
|
+
slug: this.relatedResource,
|
|
1178
|
+
onResult: (result) => {
|
|
1179
|
+
if (result?.id) {
|
|
1180
|
+
_pendingRelationPick = {
|
|
1181
|
+
mode: 'm2m',
|
|
1182
|
+
field: this.name,
|
|
1183
|
+
id: result.id,
|
|
1184
|
+
label: result.label,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
1189
|
+
},
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function createShamarM2mCheckbox(cfg) {
|
|
1194
|
+
const self = createShamarM2m(cfg);
|
|
1195
|
+
self.filter = '';
|
|
1196
|
+
self.columns = Math.min(4, Math.max(1, Number(cfg.checkboxColumns) || 1));
|
|
1197
|
+
self.cascadeWildcards = cfg.cascadeWildcards !== false;
|
|
1198
|
+
self.groupBy = cfg.groupBy ? String(cfg.groupBy) : '';
|
|
1199
|
+
self.framed = cfg.checkboxFramed !== false;
|
|
1200
|
+
self.valueAttribute = cfg.valueAttribute ? String(cfg.valueAttribute) : 'id';
|
|
1201
|
+
self.options = Array.isArray(cfg.options)
|
|
1202
|
+
? cfg.options.map((item) => normalizeCheckboxOption(item))
|
|
1203
|
+
: [];
|
|
1204
|
+
const prevInit = self.init;
|
|
1205
|
+
self.init = function initCheckbox() {
|
|
1206
|
+
this.selected = this.selected.map((item) => normalizeCheckboxOption(item));
|
|
1207
|
+
prevInit.call(this);
|
|
1208
|
+
if (this.options.length === 0) {
|
|
1209
|
+
this.loadAllOptions();
|
|
1210
|
+
} else {
|
|
1211
|
+
this.sortOptions();
|
|
1212
|
+
this.options = this.options.map((item) => normalizeCheckboxOption(item));
|
|
1213
|
+
this.mergeSelectedIntoOptions();
|
|
1214
|
+
this.pruneCovered();
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
Object.defineProperty(self, 'filteredOptions', {
|
|
1218
|
+
configurable: true,
|
|
1219
|
+
enumerable: true,
|
|
1220
|
+
get() {
|
|
1221
|
+
const q = this.filter.trim().toLowerCase();
|
|
1222
|
+
if (!q) return this.options;
|
|
1223
|
+
return this.options.filter((item) => {
|
|
1224
|
+
const hay = `${item.label} ${item.ability || ''} ${item.group || ''}`.toLowerCase();
|
|
1225
|
+
return hay.includes(q);
|
|
1226
|
+
});
|
|
1227
|
+
},
|
|
1228
|
+
});
|
|
1229
|
+
Object.defineProperty(self, 'filteredGroups', {
|
|
1230
|
+
configurable: true,
|
|
1231
|
+
enumerable: true,
|
|
1232
|
+
get() {
|
|
1233
|
+
const items = this.filteredOptions;
|
|
1234
|
+
if (!this.groupBy) {
|
|
1235
|
+
return items.length
|
|
1236
|
+
? [{ key: '_all', title: 'All', items }]
|
|
1237
|
+
: [];
|
|
1238
|
+
}
|
|
1239
|
+
const map = new Map();
|
|
1240
|
+
for (const item of items) {
|
|
1241
|
+
const key = item.group || deriveGroupFromLabel(item.label) || '_other';
|
|
1242
|
+
if (!map.has(key)) map.set(key, []);
|
|
1243
|
+
map.get(key).push(item);
|
|
1244
|
+
}
|
|
1245
|
+
const keys = [...map.keys()].sort((a, b) => {
|
|
1246
|
+
if (a === '*') return -1;
|
|
1247
|
+
if (b === '*') return 1;
|
|
1248
|
+
if (a === '_other') return 1;
|
|
1249
|
+
if (b === '_other') return -1;
|
|
1250
|
+
return a.localeCompare(b);
|
|
1251
|
+
});
|
|
1252
|
+
return keys.map((key) => ({
|
|
1253
|
+
key,
|
|
1254
|
+
title: groupTitle(key),
|
|
1255
|
+
items: sortGroupItems(map.get(key), this),
|
|
1256
|
+
}));
|
|
1257
|
+
},
|
|
1258
|
+
});
|
|
1259
|
+
self.itemValue = function itemValue(item) {
|
|
1260
|
+
if (!item) return '';
|
|
1261
|
+
if (this.valueAttribute === 'name') {
|
|
1262
|
+
return String(item.name || this.permissionKey(item) || item.id || '');
|
|
1263
|
+
}
|
|
1264
|
+
return String(item.id ?? '');
|
|
1265
|
+
};
|
|
1266
|
+
self.permissionKey = function permissionKey(item) {
|
|
1267
|
+
if (item?.name) return String(item.name).trim();
|
|
1268
|
+
if (item?.group != null && item?.ability != null) {
|
|
1269
|
+
const group = String(item.group).trim();
|
|
1270
|
+
const ability = String(item.ability).trim();
|
|
1271
|
+
if (group === '*' && ability === '*') return '*';
|
|
1272
|
+
if (group && ability) return `${group}:${ability}`;
|
|
1273
|
+
}
|
|
1274
|
+
const label = String(item?.label || item?.id || '').trim();
|
|
1275
|
+
if (label === '*') return '*';
|
|
1276
|
+
if (label.includes(':')) return label;
|
|
1277
|
+
return label;
|
|
1278
|
+
};
|
|
1279
|
+
self.itemDisplay = function itemDisplay(item) {
|
|
1280
|
+
if (item?.ability) {
|
|
1281
|
+
return item.ability === '*' ? 'All (*)' : item.ability;
|
|
1282
|
+
}
|
|
1283
|
+
const key = this.permissionKey(item);
|
|
1284
|
+
if (key === '*') return 'All (*)';
|
|
1285
|
+
if (key.includes(':')) {
|
|
1286
|
+
const ability = key.slice(key.indexOf(':') + 1);
|
|
1287
|
+
return ability === '*' ? 'All (*)' : ability || key;
|
|
1288
|
+
}
|
|
1289
|
+
return item?.label || key;
|
|
1290
|
+
};
|
|
1291
|
+
self.groupSelectedCount = function groupSelectedCount(group) {
|
|
1292
|
+
if (!group?.items) return 0;
|
|
1293
|
+
return group.items.filter((item) => this.isSelected(item)).length;
|
|
1294
|
+
};
|
|
1295
|
+
self.covers = function covers(wildcard, candidate) {
|
|
1296
|
+
if (!wildcard || !candidate || wildcard === candidate) return false;
|
|
1297
|
+
if (wildcard === '*') return true;
|
|
1298
|
+
if (wildcard.endsWith(':*')) {
|
|
1299
|
+
const prefix = wildcard.slice(0, -1);
|
|
1300
|
+
return candidate.startsWith(prefix);
|
|
1301
|
+
}
|
|
1302
|
+
return false;
|
|
1303
|
+
};
|
|
1304
|
+
self.isCovered = function isCovered(item) {
|
|
1305
|
+
if (!this.cascadeWildcards || !item) return false;
|
|
1306
|
+
const key = this.permissionKey(item);
|
|
1307
|
+
for (const sel of this.selected) {
|
|
1308
|
+
if (String(sel.id) === String(item.id)) continue;
|
|
1309
|
+
if (this.covers(this.permissionKey(sel), key)) return true;
|
|
1310
|
+
}
|
|
1311
|
+
return false;
|
|
1312
|
+
};
|
|
1313
|
+
self.coveredHint = function coveredHint(item) {
|
|
1314
|
+
if (!item) return '';
|
|
1315
|
+
const key = this.permissionKey(item);
|
|
1316
|
+
for (const sel of this.selected) {
|
|
1317
|
+
const wild = this.permissionKey(sel);
|
|
1318
|
+
if (this.covers(wild, key)) {
|
|
1319
|
+
return `Covered by ${wild}`;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return '';
|
|
1323
|
+
};
|
|
1324
|
+
self.pruneCovered = function pruneCovered() {
|
|
1325
|
+
if (!this.cascadeWildcards) return;
|
|
1326
|
+
this.selected = this.selected.filter((item) => !this.isCovered(item));
|
|
1327
|
+
};
|
|
1328
|
+
self.sortOptions = function sortOptions() {
|
|
1329
|
+
this.options = [...this.options].sort((a, b) => {
|
|
1330
|
+
const ag = a.group || deriveGroupFromLabel(a.label) || '';
|
|
1331
|
+
const bg = b.group || deriveGroupFromLabel(b.label) || '';
|
|
1332
|
+
if (ag === '*' && bg !== '*') return -1;
|
|
1333
|
+
if (bg === '*' && ag !== '*') return 1;
|
|
1334
|
+
if (ag !== bg) return ag.localeCompare(bg);
|
|
1335
|
+
return compareAbility(a, b, this);
|
|
1336
|
+
});
|
|
1337
|
+
};
|
|
1338
|
+
self.isSelected = function isSelected(itemOrId) {
|
|
1339
|
+
const id =
|
|
1340
|
+
itemOrId && typeof itemOrId === 'object'
|
|
1341
|
+
? String(itemOrId.id ?? '')
|
|
1342
|
+
: String(itemOrId ?? '');
|
|
1343
|
+
return this.selectedIds.has(id);
|
|
1344
|
+
};
|
|
1345
|
+
self.pick = function pickCheckbox(item) {
|
|
1346
|
+
if (!item || item.id == null || this.readonly) return;
|
|
1347
|
+
const id = String(item.id);
|
|
1348
|
+
if (this.selectedIds.has(id)) return;
|
|
1349
|
+
const next = normalizeCheckboxOption(item);
|
|
1350
|
+
this.selected = [...this.selected, next];
|
|
1351
|
+
this.pruneCovered();
|
|
1352
|
+
};
|
|
1353
|
+
self.toggle = function toggle(item) {
|
|
1354
|
+
if (this.readonly || !item) return;
|
|
1355
|
+
if (this.isCovered(item) && !this.isSelected(item)) return;
|
|
1356
|
+
if (this.isSelected(item)) {
|
|
1357
|
+
this.remove(item.id);
|
|
1358
|
+
} else {
|
|
1359
|
+
this.pick(item);
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
self.mergeSelectedIntoOptions = function mergeSelectedIntoOptions() {
|
|
1363
|
+
const seen = new Set(this.options.map((item) => String(item.id)));
|
|
1364
|
+
for (const item of this.selected) {
|
|
1365
|
+
if (!seen.has(String(item.id))) {
|
|
1366
|
+
const enriched = normalizeCheckboxOption({
|
|
1367
|
+
...item,
|
|
1368
|
+
group: item.group || deriveGroupFromLabel(item.label),
|
|
1369
|
+
});
|
|
1370
|
+
this.options = [enriched, ...this.options];
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
self.loadAllOptions = async function loadAllOptions() {
|
|
1375
|
+
this.loading = true;
|
|
1376
|
+
try {
|
|
1377
|
+
const url = `${this.searchUrl}&q=&limit=250`;
|
|
1378
|
+
const response = await fetch(url);
|
|
1379
|
+
if (!response.ok) throw new Error('fetch failed');
|
|
1380
|
+
const data = await response.json();
|
|
1381
|
+
this.options = (data.results || []).map((item) => normalizeCheckboxOption(item));
|
|
1382
|
+
this.sortOptions();
|
|
1383
|
+
this.mergeSelectedIntoOptions();
|
|
1384
|
+
this.pruneCovered();
|
|
1385
|
+
} catch {
|
|
1386
|
+
this.options = this.selected.map((item) => normalizeCheckboxOption(item));
|
|
1387
|
+
} finally {
|
|
1388
|
+
this.loading = false;
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
return self;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function normalizeCheckboxOption(item) {
|
|
1395
|
+
const id = String(item.id ?? item.value ?? '');
|
|
1396
|
+
const label = item.label || id;
|
|
1397
|
+
const group = item.group || deriveGroupFromLabel(item.name || label);
|
|
1398
|
+
const ability =
|
|
1399
|
+
item.ability || deriveAbilityFromLabel(item.name || label);
|
|
1400
|
+
let name = item.name ? String(item.name).trim() : '';
|
|
1401
|
+
if (!name && group && ability) {
|
|
1402
|
+
name = group === '*' && ability === '*' ? '*' : `${group}:${ability}`;
|
|
1403
|
+
}
|
|
1404
|
+
if (!name) {
|
|
1405
|
+
name = deriveGroupFromLabel(label) === '*' ? '*' : label.includes(':') ? label : id;
|
|
1406
|
+
}
|
|
1407
|
+
return {
|
|
1408
|
+
id,
|
|
1409
|
+
label,
|
|
1410
|
+
name,
|
|
1411
|
+
group,
|
|
1412
|
+
ability,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function deriveGroupFromLabel(label) {
|
|
1417
|
+
const trimmed = String(label || '').trim();
|
|
1418
|
+
if (!trimmed || trimmed === '*') return '*';
|
|
1419
|
+
const colon = trimmed.indexOf(':');
|
|
1420
|
+
if (colon <= 0) return trimmed;
|
|
1421
|
+
return trimmed.slice(0, colon) || '*';
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function deriveAbilityFromLabel(label) {
|
|
1425
|
+
const trimmed = String(label || '').trim();
|
|
1426
|
+
if (!trimmed || trimmed === '*') return '*';
|
|
1427
|
+
const colon = trimmed.indexOf(':');
|
|
1428
|
+
if (colon < 0) return undefined;
|
|
1429
|
+
return trimmed.slice(colon + 1) || undefined;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function groupTitle(key) {
|
|
1433
|
+
if (key === '*' || key === '_all') return 'Global';
|
|
1434
|
+
if (key === '_other') return 'Other';
|
|
1435
|
+
return String(key)
|
|
1436
|
+
.replace(/[-_]/g, ' ')
|
|
1437
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function compareAbility(a, b, ctx) {
|
|
1441
|
+
const aa = a.ability || deriveAbilityFromLabel(a.label) || ctx.permissionKey(a);
|
|
1442
|
+
const ba = b.ability || deriveAbilityFromLabel(b.label) || ctx.permissionKey(b);
|
|
1443
|
+
const aRank = aa === '*' ? 0 : 1;
|
|
1444
|
+
const bRank = ba === '*' ? 0 : 1;
|
|
1445
|
+
if (aRank !== bRank) return aRank - bRank;
|
|
1446
|
+
return String(aa).localeCompare(String(ba));
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function sortGroupItems(items, ctx) {
|
|
1450
|
+
return [...items].sort((a, b) => compareAbility(a, b, ctx));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function createShamarM2mTable(cfg) {
|
|
1454
|
+
const self = createShamarM2m(cfg);
|
|
1455
|
+
self.addOpen = false;
|
|
1456
|
+
const prevInit = self.init;
|
|
1457
|
+
const prevDestroy = self.destroy;
|
|
1458
|
+
const prevPick = self.pick;
|
|
1459
|
+
const prevCreateAndEdit = self.createAndEdit;
|
|
1460
|
+
self.init = function initTable() {
|
|
1461
|
+
prevInit.call(this);
|
|
1462
|
+
this._tablePickHandler = (event) => {
|
|
1463
|
+
const detail = event.detail || {};
|
|
1464
|
+
if (detail.field === this.name) {
|
|
1465
|
+
prevPick.call(this, { id: detail.id, label: detail.label });
|
|
1466
|
+
this.closeAdd();
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
window.addEventListener('shamar-m2m-pick', this._tablePickHandler);
|
|
1470
|
+
};
|
|
1471
|
+
self.destroy = function destroyTable() {
|
|
1472
|
+
prevDestroy.call(this);
|
|
1473
|
+
if (this._tablePickHandler) {
|
|
1474
|
+
window.removeEventListener('shamar-m2m-pick', this._tablePickHandler);
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
self.toggleAdd = function toggleAdd() {
|
|
1478
|
+
this.addOpen = !this.addOpen;
|
|
1479
|
+
if (this.addOpen) {
|
|
1480
|
+
this.query = '';
|
|
1481
|
+
this.fetchResults();
|
|
1482
|
+
this.$nextTick?.(() => this.$refs.addInput?.focus());
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
self.closeAdd = function closeAdd() {
|
|
1486
|
+
this.addOpen = false;
|
|
1487
|
+
this.query = '';
|
|
1488
|
+
};
|
|
1489
|
+
self.pick = function pickTable(item) {
|
|
1490
|
+
prevPick.call(this, item);
|
|
1491
|
+
this.closeAdd();
|
|
1492
|
+
};
|
|
1493
|
+
self.createAndEdit = function createAndEditTable() {
|
|
1494
|
+
this.closeAdd();
|
|
1495
|
+
prevCreateAndEdit.call(this);
|
|
1496
|
+
};
|
|
1497
|
+
return self;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function createShamarForm(cfg = {}) {
|
|
1501
|
+
return {
|
|
1502
|
+
endpoint: cfg.endpoint || '',
|
|
1503
|
+
operation: cfg.operation || 'create',
|
|
1504
|
+
recordId: cfg.recordId != null && cfg.recordId !== '' ? String(cfg.recordId) : null,
|
|
1505
|
+
state: { ...(cfg.state || {}) },
|
|
1506
|
+
fieldMeta: {},
|
|
1507
|
+
liveMap: {},
|
|
1508
|
+
currencyMap: {},
|
|
1509
|
+
_currencyEditing: {},
|
|
1510
|
+
_timers: {},
|
|
1511
|
+
_abort: null,
|
|
1512
|
+
|
|
1513
|
+
init() {
|
|
1514
|
+
const fields = Array.isArray(cfg.fields) ? cfg.fields : [];
|
|
1515
|
+
for (const field of fields) {
|
|
1516
|
+
if (!field?.name) continue;
|
|
1517
|
+
this.liveMap[field.name] = field.live;
|
|
1518
|
+
this.fieldMeta[field.name] = {
|
|
1519
|
+
visible: field.visible !== false,
|
|
1520
|
+
disabled: !!field.disabled,
|
|
1521
|
+
required: !!field.required,
|
|
1522
|
+
readonly: !!field.readonly,
|
|
1523
|
+
label: field.label,
|
|
1524
|
+
help: field.help,
|
|
1525
|
+
placeholder: field.placeholder,
|
|
1526
|
+
};
|
|
1527
|
+
if (field.currency) {
|
|
1528
|
+
this.currencyMap[field.name] = field.currency;
|
|
1529
|
+
}
|
|
1530
|
+
if (!(field.name in this.state) && field.value !== undefined) {
|
|
1531
|
+
this.state[field.name] = field.value;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
},
|
|
1535
|
+
|
|
1536
|
+
isVisible(name) {
|
|
1537
|
+
const meta = this.fieldMeta[name];
|
|
1538
|
+
return !meta || meta.visible !== false;
|
|
1539
|
+
},
|
|
1540
|
+
|
|
1541
|
+
isDisabled(name) {
|
|
1542
|
+
return !!this.fieldMeta[name]?.disabled;
|
|
1543
|
+
},
|
|
1544
|
+
|
|
1545
|
+
isRequired(name) {
|
|
1546
|
+
return !!this.fieldMeta[name]?.required;
|
|
1547
|
+
},
|
|
1548
|
+
|
|
1549
|
+
isReadonly(name) {
|
|
1550
|
+
return !!this.fieldMeta[name]?.readonly;
|
|
1551
|
+
},
|
|
1552
|
+
|
|
1553
|
+
fieldLabel(name, fallback) {
|
|
1554
|
+
return this.fieldMeta[name]?.label || fallback || name;
|
|
1555
|
+
},
|
|
1556
|
+
|
|
1557
|
+
beginCurrencyEdit(name) {
|
|
1558
|
+
this._currencyEditing[name] = true;
|
|
1559
|
+
},
|
|
1560
|
+
|
|
1561
|
+
endCurrencyEdit(name) {
|
|
1562
|
+
this._currencyEditing[name] = false;
|
|
1563
|
+
const parsed = this.parseCurrencyValue(this.state[name]);
|
|
1564
|
+
this.state[name] = parsed;
|
|
1565
|
+
},
|
|
1566
|
+
|
|
1567
|
+
onCurrencyInput(name, event) {
|
|
1568
|
+
const raw = event?.target?.value ?? '';
|
|
1569
|
+
this.state[name] = this.parseCurrencyValue(raw);
|
|
1570
|
+
this.onFieldInput(name);
|
|
1571
|
+
},
|
|
1572
|
+
|
|
1573
|
+
parseCurrencyValue(value) {
|
|
1574
|
+
if (value == null || value === '') return '';
|
|
1575
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : '';
|
|
1576
|
+
const cleaned = String(value).replace(/[^\d.-]/g, '');
|
|
1577
|
+
if (!cleaned || cleaned === '-' || cleaned === '.' || cleaned === '-.') return '';
|
|
1578
|
+
const num = Number(cleaned);
|
|
1579
|
+
return Number.isFinite(num) ? num : '';
|
|
1580
|
+
},
|
|
1581
|
+
|
|
1582
|
+
currencyDisplay(name) {
|
|
1583
|
+
const value = this.state[name];
|
|
1584
|
+
if (this._currencyEditing[name]) {
|
|
1585
|
+
return value == null || value === '' ? '' : String(value);
|
|
1586
|
+
}
|
|
1587
|
+
const cfg = this.currencyMap[name];
|
|
1588
|
+
if (!cfg) return value == null || value === '' ? '' : String(value);
|
|
1589
|
+
if (value == null || value === '') return '';
|
|
1590
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
1591
|
+
if (!Number.isFinite(num)) return String(value);
|
|
1592
|
+
try {
|
|
1593
|
+
return new Intl.NumberFormat(cfg.locale, {
|
|
1594
|
+
minimumFractionDigits: cfg.precision ?? 2,
|
|
1595
|
+
maximumFractionDigits: cfg.precision ?? 2,
|
|
1596
|
+
}).format(num);
|
|
1597
|
+
} catch {
|
|
1598
|
+
return num.toFixed(cfg.precision ?? 2);
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
|
|
1602
|
+
parseLive(mode) {
|
|
1603
|
+
// live() | live({ debounce }) | live({ onBlur: true })
|
|
1604
|
+
if (mode === true) return { event: 'input', debounce: 500 };
|
|
1605
|
+
if (mode && typeof mode === 'object') {
|
|
1606
|
+
if (mode.onBlur) return { event: 'blur', debounce: 0 };
|
|
1607
|
+
let ms = 500;
|
|
1608
|
+
const d = mode.debounce;
|
|
1609
|
+
if (typeof d === 'number' && Number.isFinite(d)) {
|
|
1610
|
+
ms = d;
|
|
1611
|
+
} else if (typeof d === 'string') {
|
|
1612
|
+
const parsed = Number.parseInt(d, 10);
|
|
1613
|
+
if (Number.isFinite(parsed)) ms = parsed;
|
|
1614
|
+
}
|
|
1615
|
+
return { event: 'input', debounce: Math.max(0, ms) };
|
|
1616
|
+
}
|
|
1617
|
+
return { event: 'change', debounce: 0 };
|
|
1618
|
+
},
|
|
1619
|
+
|
|
1620
|
+
onFieldInput(name) {
|
|
1621
|
+
const live = this.liveMap[name];
|
|
1622
|
+
if (!live) return;
|
|
1623
|
+
const parsed = this.parseLive(live);
|
|
1624
|
+
if (parsed.event !== 'input') return;
|
|
1625
|
+
this.scheduleSync(name, parsed.debounce);
|
|
1626
|
+
},
|
|
1627
|
+
|
|
1628
|
+
onFieldChange(name) {
|
|
1629
|
+
const live = this.liveMap[name];
|
|
1630
|
+
if (!live) return;
|
|
1631
|
+
const parsed = this.parseLive(live);
|
|
1632
|
+
// Selects/comboboxes fire change; default live() uses "input" — treat both.
|
|
1633
|
+
if (parsed.event === 'change' || parsed.event === 'input') {
|
|
1634
|
+
this.scheduleSync(name, parsed.event === 'input' ? parsed.debounce : 0);
|
|
1635
|
+
}
|
|
1636
|
+
},
|
|
1637
|
+
|
|
1638
|
+
onFieldBlur(name) {
|
|
1639
|
+
const live = this.liveMap[name];
|
|
1640
|
+
if (!live) return;
|
|
1641
|
+
if (this.parseLive(live).event === 'blur') {
|
|
1642
|
+
this.scheduleSync(name, 0);
|
|
1643
|
+
}
|
|
1644
|
+
},
|
|
1645
|
+
|
|
1646
|
+
scheduleSync(changed, debounceMs) {
|
|
1647
|
+
if (this._timers[changed]) clearTimeout(this._timers[changed]);
|
|
1648
|
+
this._timers[changed] = window.setTimeout(() => {
|
|
1649
|
+
this._timers[changed] = null;
|
|
1650
|
+
this.sync(changed);
|
|
1651
|
+
}, Math.max(0, debounceMs || 0));
|
|
1652
|
+
},
|
|
1653
|
+
|
|
1654
|
+
async sync(changed) {
|
|
1655
|
+
if (!this.endpoint) return;
|
|
1656
|
+
if (this._abort) this._abort.abort();
|
|
1657
|
+
const ctl = new AbortController();
|
|
1658
|
+
this._abort = ctl;
|
|
1659
|
+
// Snapshot before the round-trip so we only apply server patches to
|
|
1660
|
+
// fields the user has not edited while the request was in flight.
|
|
1661
|
+
// Never overwrite `changed` — bodyparsers often trim strings, which
|
|
1662
|
+
// would steal trailing spaces mid-typing.
|
|
1663
|
+
const sent = { ...this.state };
|
|
1664
|
+
try {
|
|
1665
|
+
const res = await fetch(this.endpoint, {
|
|
1666
|
+
method: 'POST',
|
|
1667
|
+
headers: csrfHeaders({
|
|
1668
|
+
'Content-Type': 'application/json',
|
|
1669
|
+
Accept: 'application/json',
|
|
1670
|
+
}),
|
|
1671
|
+
credentials: 'same-origin',
|
|
1672
|
+
signal: ctl.signal,
|
|
1673
|
+
body: JSON.stringify({
|
|
1674
|
+
operation: this.operation,
|
|
1675
|
+
changed,
|
|
1676
|
+
state: sent,
|
|
1677
|
+
id: this.recordId,
|
|
1678
|
+
}),
|
|
1679
|
+
});
|
|
1680
|
+
if (!res.ok) return;
|
|
1681
|
+
const payload = await res.json();
|
|
1682
|
+
if (payload?.state && typeof payload.state === 'object') {
|
|
1683
|
+
const next = { ...this.state };
|
|
1684
|
+
for (const [key, value] of Object.entries(payload.state)) {
|
|
1685
|
+
if (key === changed) continue;
|
|
1686
|
+
if (Object.is(this.state[key], sent[key])) {
|
|
1687
|
+
next[key] = value;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
this.state = next;
|
|
1691
|
+
}
|
|
1692
|
+
if (Array.isArray(payload?.fields)) {
|
|
1693
|
+
for (const field of payload.fields) {
|
|
1694
|
+
if (!field?.name) continue;
|
|
1695
|
+
this.fieldMeta[field.name] = {
|
|
1696
|
+
visible: field.visible !== false,
|
|
1697
|
+
disabled: !!field.disabled,
|
|
1698
|
+
required: !!field.required,
|
|
1699
|
+
readonly: !!field.readonly,
|
|
1700
|
+
label: field.label,
|
|
1701
|
+
help: field.help,
|
|
1702
|
+
placeholder: field.placeholder,
|
|
1703
|
+
};
|
|
1704
|
+
if (field.currency) {
|
|
1705
|
+
this.currencyMap[field.name] = field.currency;
|
|
1706
|
+
}
|
|
1707
|
+
if (field.live != null) {
|
|
1708
|
+
this.liveMap[field.name] = field.live;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
if (error?.name !== 'AbortError') {
|
|
1714
|
+
/* ignore transient form-state errors */
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
},
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// Available before Alpine boots (defer scripts run before Alpine.start).
|
|
1722
|
+
window.shamarForm = createShamarForm;
|
|
1723
|
+
|
|
1724
|
+
function createShamarCombobox(cfg = {}) {
|
|
1725
|
+
const options = Array.isArray(cfg.options)
|
|
1726
|
+
? cfg.options.map((opt) => ({
|
|
1727
|
+
label: String(opt?.label ?? opt?.value ?? ''),
|
|
1728
|
+
value: opt?.value != null ? String(opt.value) : '',
|
|
1729
|
+
}))
|
|
1730
|
+
: [];
|
|
1731
|
+
|
|
1732
|
+
return {
|
|
1733
|
+
name: cfg.name || '',
|
|
1734
|
+
multiple: !!cfg.multiple,
|
|
1735
|
+
options,
|
|
1736
|
+
placeholder: cfg.placeholder || 'Select...',
|
|
1737
|
+
selectablePlaceholder: cfg.selectablePlaceholder !== false,
|
|
1738
|
+
getValue: typeof cfg.getValue === 'function' ? cfg.getValue : () => (cfg.multiple ? [] : ''),
|
|
1739
|
+
setValue: typeof cfg.setValue === 'function' ? cfg.setValue : () => {},
|
|
1740
|
+
isDisabled: typeof cfg.isDisabled === 'function' ? cfg.isDisabled : () => false,
|
|
1741
|
+
|
|
1742
|
+
query: '',
|
|
1743
|
+
open: false,
|
|
1744
|
+
cursor: 0,
|
|
1745
|
+
_syncing: false,
|
|
1746
|
+
|
|
1747
|
+
init() {
|
|
1748
|
+
this.syncFromValue();
|
|
1749
|
+
this.$watch(
|
|
1750
|
+
() => this.getValue(),
|
|
1751
|
+
() => {
|
|
1752
|
+
if (this._syncing) return;
|
|
1753
|
+
this.syncFromValue();
|
|
1754
|
+
},
|
|
1755
|
+
);
|
|
1756
|
+
},
|
|
1757
|
+
|
|
1758
|
+
get disabled() {
|
|
1759
|
+
return !!this.isDisabled();
|
|
1760
|
+
},
|
|
1761
|
+
|
|
1762
|
+
get selectedValues() {
|
|
1763
|
+
const raw = this.getValue();
|
|
1764
|
+
if (this.multiple) {
|
|
1765
|
+
if (Array.isArray(raw)) return raw.map((item) => String(item));
|
|
1766
|
+
if (raw == null || raw === '') return [];
|
|
1767
|
+
return [String(raw)];
|
|
1768
|
+
}
|
|
1769
|
+
if (raw == null || raw === '') return [];
|
|
1770
|
+
return [String(raw)];
|
|
1771
|
+
},
|
|
1772
|
+
|
|
1773
|
+
get selectedOptions() {
|
|
1774
|
+
const selected = new Set(this.selectedValues);
|
|
1775
|
+
return this.options.filter((opt) => selected.has(String(opt.value)));
|
|
1776
|
+
},
|
|
1777
|
+
|
|
1778
|
+
get selectedLabel() {
|
|
1779
|
+
return this.selectedOptions.map((opt) => opt.label).join(', ');
|
|
1780
|
+
},
|
|
1781
|
+
|
|
1782
|
+
get filteredOptions() {
|
|
1783
|
+
const q = this.query.trim().toLowerCase();
|
|
1784
|
+
if (!q) return this.options;
|
|
1785
|
+
return this.options.filter(
|
|
1786
|
+
(opt) =>
|
|
1787
|
+
opt.label.toLowerCase().includes(q) ||
|
|
1788
|
+
String(opt.value).toLowerCase().includes(q),
|
|
1789
|
+
);
|
|
1790
|
+
},
|
|
1791
|
+
|
|
1792
|
+
syncFromValue() {
|
|
1793
|
+
if (this.multiple) return;
|
|
1794
|
+
if (this.open && this.query && this.query !== this.selectedLabel) return;
|
|
1795
|
+
this.query = this.selectedLabel;
|
|
1796
|
+
},
|
|
1797
|
+
|
|
1798
|
+
optionLabel(value) {
|
|
1799
|
+
const found = this.options.find((opt) => String(opt.value) === String(value));
|
|
1800
|
+
return found?.label ?? String(value ?? '');
|
|
1801
|
+
},
|
|
1802
|
+
|
|
1803
|
+
isSelected(opt) {
|
|
1804
|
+
return this.selectedValues.includes(String(opt.value));
|
|
1805
|
+
},
|
|
1806
|
+
|
|
1807
|
+
commit(next) {
|
|
1808
|
+
this._syncing = true;
|
|
1809
|
+
this.setValue(next);
|
|
1810
|
+
queueMicrotask(() => {
|
|
1811
|
+
this._syncing = false;
|
|
1812
|
+
});
|
|
1813
|
+
},
|
|
1814
|
+
|
|
1815
|
+
openDropdown() {
|
|
1816
|
+
if (this.disabled) return;
|
|
1817
|
+
this.open = true;
|
|
1818
|
+
this.cursor = 0;
|
|
1819
|
+
if (!this.multiple) {
|
|
1820
|
+
this.query = '';
|
|
1821
|
+
}
|
|
1822
|
+
this.$nextTick(() => this.$refs.search?.focus());
|
|
1823
|
+
},
|
|
1824
|
+
|
|
1825
|
+
close() {
|
|
1826
|
+
this.open = false;
|
|
1827
|
+
this.cursor = 0;
|
|
1828
|
+
if (!this.multiple) {
|
|
1829
|
+
this.query = this.selectedLabel;
|
|
1830
|
+
} else {
|
|
1831
|
+
this.query = '';
|
|
1832
|
+
}
|
|
1833
|
+
},
|
|
1834
|
+
|
|
1835
|
+
toggleOpen() {
|
|
1836
|
+
if (this.open) this.close();
|
|
1837
|
+
else this.openDropdown();
|
|
1838
|
+
},
|
|
1839
|
+
|
|
1840
|
+
onControlClick() {
|
|
1841
|
+
if (this.disabled) return;
|
|
1842
|
+
if (!this.open) this.openDropdown();
|
|
1843
|
+
else this.$refs.search?.focus();
|
|
1844
|
+
},
|
|
1845
|
+
|
|
1846
|
+
onFocusOut(event) {
|
|
1847
|
+
const next = event.relatedTarget;
|
|
1848
|
+
if (next && this.$el.contains(next)) return;
|
|
1849
|
+
queueMicrotask(() => {
|
|
1850
|
+
if (!this.open) return;
|
|
1851
|
+
if (this.$el.contains(document.activeElement)) return;
|
|
1852
|
+
this.close();
|
|
1853
|
+
});
|
|
1854
|
+
},
|
|
1855
|
+
|
|
1856
|
+
onInput() {
|
|
1857
|
+
if (this.disabled) return;
|
|
1858
|
+
this.open = true;
|
|
1859
|
+
this.cursor = 0;
|
|
1860
|
+
if (!this.multiple && this.selectablePlaceholder && this.query !== this.selectedLabel) {
|
|
1861
|
+
// Typing clears the current single selection until an option is picked.
|
|
1862
|
+
this.commit('');
|
|
1863
|
+
}
|
|
1864
|
+
},
|
|
1865
|
+
|
|
1866
|
+
onKeydown(event) {
|
|
1867
|
+
if (this.disabled) return;
|
|
1868
|
+
const key = event.key;
|
|
1869
|
+
if (key === 'ArrowDown') {
|
|
1870
|
+
event.preventDefault();
|
|
1871
|
+
if (!this.open) {
|
|
1872
|
+
this.openDropdown();
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
this.moveCursor(1);
|
|
1876
|
+
} else if (key === 'ArrowUp') {
|
|
1877
|
+
event.preventDefault();
|
|
1878
|
+
if (!this.open) {
|
|
1879
|
+
this.openDropdown();
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
this.moveCursor(-1);
|
|
1883
|
+
} else if (key === 'Enter') {
|
|
1884
|
+
if (!this.open) return;
|
|
1885
|
+
event.preventDefault();
|
|
1886
|
+
const opt = this.filteredOptions[this.cursor];
|
|
1887
|
+
if (opt) this.pick(opt);
|
|
1888
|
+
} else if (key === 'Escape') {
|
|
1889
|
+
if (this.open) {
|
|
1890
|
+
event.preventDefault();
|
|
1891
|
+
this.close();
|
|
1892
|
+
}
|
|
1893
|
+
} else if (key === 'Backspace' && this.multiple && !this.query) {
|
|
1894
|
+
const values = this.selectedValues;
|
|
1895
|
+
if (values.length) {
|
|
1896
|
+
event.preventDefault();
|
|
1897
|
+
this.remove(values[values.length - 1]);
|
|
1898
|
+
}
|
|
1899
|
+
} else if (key === 'Tab') {
|
|
1900
|
+
this.close();
|
|
1901
|
+
}
|
|
1902
|
+
},
|
|
1903
|
+
|
|
1904
|
+
moveCursor(delta) {
|
|
1905
|
+
const max = this.filteredOptions.length - 1;
|
|
1906
|
+
if (max < 0) {
|
|
1907
|
+
this.cursor = 0;
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
this.cursor = Math.max(0, Math.min(max, this.cursor + delta));
|
|
1911
|
+
this.$nextTick(() => {
|
|
1912
|
+
const el = this.$refs.list?.querySelector(`[data-combobox-index="${this.cursor}"]`);
|
|
1913
|
+
el?.scrollIntoView?.({ block: 'nearest' });
|
|
1914
|
+
});
|
|
1915
|
+
},
|
|
1916
|
+
|
|
1917
|
+
pick(opt) {
|
|
1918
|
+
if (this.disabled || !opt) return;
|
|
1919
|
+
const value = String(opt.value);
|
|
1920
|
+
if (this.multiple) {
|
|
1921
|
+
const current = this.selectedValues;
|
|
1922
|
+
if (current.includes(value)) {
|
|
1923
|
+
this.commit(current.filter((item) => item !== value));
|
|
1924
|
+
} else {
|
|
1925
|
+
this.commit([...current, value]);
|
|
1926
|
+
}
|
|
1927
|
+
this.query = '';
|
|
1928
|
+
this.open = true;
|
|
1929
|
+
this.cursor = 0;
|
|
1930
|
+
this.$nextTick(() => this.$refs.search?.focus());
|
|
1931
|
+
} else {
|
|
1932
|
+
this.commit(value);
|
|
1933
|
+
this.query = opt.label;
|
|
1934
|
+
this.open = false;
|
|
1935
|
+
}
|
|
1936
|
+
},
|
|
1937
|
+
|
|
1938
|
+
remove(value) {
|
|
1939
|
+
if (this.disabled || !this.multiple) return;
|
|
1940
|
+
this.commit(this.selectedValues.filter((item) => item !== String(value)));
|
|
1941
|
+
this.$nextTick(() => this.$refs.search?.focus());
|
|
1942
|
+
},
|
|
1943
|
+
|
|
1944
|
+
clearSelection() {
|
|
1945
|
+
if (this.disabled) return;
|
|
1946
|
+
this.commit(this.multiple ? [] : '');
|
|
1947
|
+
this.query = '';
|
|
1948
|
+
this.open = false;
|
|
1949
|
+
this.$nextTick(() => this.$refs.search?.focus());
|
|
1950
|
+
},
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
window.shamarCombobox = createShamarCombobox;
|
|
1955
|
+
|
|
1956
|
+
function registerShamarAlpineComponents() {
|
|
1957
|
+
if (registerShamarAlpineComponents._done) return;
|
|
1958
|
+
registerShamarAlpineComponents._done = true;
|
|
1959
|
+
|
|
1960
|
+
Alpine.data('shamarM2oFromEl', (el) => createShamarM2o(readM2oConfig(el)));
|
|
1961
|
+
Alpine.data('shamarM2o', (cfg) => createShamarM2o(cfg));
|
|
1962
|
+
Alpine.data('shamarM2mFromEl', (el) => createShamarM2m(readM2mConfig(el)));
|
|
1963
|
+
Alpine.data('shamarM2m', (cfg) => createShamarM2m(cfg));
|
|
1964
|
+
Alpine.data('shamarM2mCheckboxFromEl', (el) => createShamarM2mCheckbox(readM2mConfig(el)));
|
|
1965
|
+
Alpine.data('shamarM2mTableFromEl', (el) => createShamarM2mTable(readM2mConfig(el)));
|
|
1966
|
+
// Global fallback (same pattern as shamarShell) so x-data works even if
|
|
1967
|
+
// Alpine.data registration races; Alpine.data still preferred when available.
|
|
1968
|
+
window.shamarForm = createShamarForm;
|
|
1969
|
+
Alpine.data('shamarForm', (cfg) => createShamarForm(cfg));
|
|
1970
|
+
Alpine.data('shamarCombobox', (cfg) => createShamarCombobox(cfg));
|
|
1971
|
+
|
|
1972
|
+
window.shamarTabs = (active = 1) => ({ active: Number(active) || 1 });
|
|
1973
|
+
Alpine.data('shamarTabs', (active = 1) => window.shamarTabs(active));
|
|
1974
|
+
|
|
1975
|
+
window.shamarWizard = (total = 1) => ({
|
|
1976
|
+
step: 1,
|
|
1977
|
+
total: Math.max(1, Number(total) || 1),
|
|
1978
|
+
next() {
|
|
1979
|
+
if (this.step < this.total) this.step += 1;
|
|
1980
|
+
},
|
|
1981
|
+
prev() {
|
|
1982
|
+
if (this.step > 1) this.step -= 1;
|
|
1983
|
+
},
|
|
1984
|
+
});
|
|
1985
|
+
Alpine.data('shamarWizard', (total = 1) => window.shamarWizard(total));
|
|
1986
|
+
|
|
1987
|
+
window.shamarTags = (name) => ({
|
|
1988
|
+
name,
|
|
1989
|
+
add(input) {
|
|
1990
|
+
const value = String(input?.value || '').trim().replace(/,$/, '');
|
|
1991
|
+
if (!value) return;
|
|
1992
|
+
const current = Array.isArray(this.state?.[name]) ? [...this.state[name]] : [];
|
|
1993
|
+
if (!current.includes(value)) {
|
|
1994
|
+
this.state[name] = [...current, value];
|
|
1995
|
+
if (typeof this.onFieldChange === 'function') this.onFieldChange(name);
|
|
1996
|
+
}
|
|
1997
|
+
if (input) input.value = '';
|
|
1998
|
+
},
|
|
1999
|
+
remove(index) {
|
|
2000
|
+
const current = Array.isArray(this.state?.[name]) ? [...this.state[name]] : [];
|
|
2001
|
+
current.splice(index, 1);
|
|
2002
|
+
this.state[name] = current;
|
|
2003
|
+
if (typeof this.onFieldChange === 'function') this.onFieldChange(name);
|
|
2004
|
+
},
|
|
2005
|
+
});
|
|
2006
|
+
Alpine.data('shamarTags', (name) => window.shamarTags(name));
|
|
2007
|
+
|
|
2008
|
+
Alpine.data('shamarListToolbar', (cfg = {}) => ({
|
|
2009
|
+
searchInput: '',
|
|
2010
|
+
chips: [],
|
|
2011
|
+
groupBy: cfg.groupBy || null,
|
|
2012
|
+
filtersLockedEmpty: cfg.filtersLockedEmpty === true,
|
|
2013
|
+
groupLockedEmpty: cfg.groupLockedEmpty === true,
|
|
2014
|
+
sort: cfg.sort || '',
|
|
2015
|
+
direction: cfg.direction || '',
|
|
2016
|
+
perPage:
|
|
2017
|
+
cfg.perPage === 'all' || Number(cfg.perPage) === Number(cfg.allPerPage)
|
|
2018
|
+
? 'all'
|
|
2019
|
+
: String(cfg.perPage || 15),
|
|
2020
|
+
trashed: cfg.trashed || false,
|
|
2021
|
+
headers: Array.isArray(cfg.headers) ? cfg.headers : [],
|
|
2022
|
+
basePath: cfg.basePath || '',
|
|
2023
|
+
slug: cfg.slug || '',
|
|
2024
|
+
view: cfg.view || 'table',
|
|
2025
|
+
allPerPage: cfg.allPerPage || 1000,
|
|
2026
|
+
panelOpen: false,
|
|
2027
|
+
groupOpen: false,
|
|
2028
|
+
openFilterField: null,
|
|
2029
|
+
m2oQuery: {},
|
|
2030
|
+
m2oResults: {},
|
|
2031
|
+
_searchGen: 0,
|
|
2032
|
+
_softReloading: false,
|
|
2033
|
+
init() {
|
|
2034
|
+
// Free-text search stays in the input — never becomes a chip.
|
|
2035
|
+
this.searchInput = (cfg.search || '').trim();
|
|
2036
|
+
const chips = [];
|
|
2037
|
+
const filters = Array.isArray(cfg.filters) ? cfg.filters : [];
|
|
2038
|
+
for (const chip of filters) {
|
|
2039
|
+
if (chip && chip.field) chips.push(chip);
|
|
2040
|
+
}
|
|
2041
|
+
this.chips = chips;
|
|
2042
|
+
},
|
|
2043
|
+
buildUrl() {
|
|
2044
|
+
const fieldChips = this.chips.filter((c) => c.field !== null);
|
|
2045
|
+
const path =
|
|
2046
|
+
this.view === 'kanban'
|
|
2047
|
+
? `${this.basePath}/${this.slug}/kanban`
|
|
2048
|
+
: `${this.basePath}/${this.slug}`;
|
|
2049
|
+
const params = new URLSearchParams();
|
|
2050
|
+
const search = (this.searchInput || '').trim();
|
|
2051
|
+
if (search) params.set('search', search);
|
|
2052
|
+
if (this.sort) {
|
|
2053
|
+
params.set('sort', this.sort);
|
|
2054
|
+
if (this.direction === 'asc' || this.direction === 'desc') {
|
|
2055
|
+
params.set('direction', this.direction);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
if (this.perPage && this.perPage !== '15') params.set('perPage', String(this.perPage));
|
|
2059
|
+
if (fieldChips.length) {
|
|
2060
|
+
params.set('filters', JSON.stringify(fieldChips));
|
|
2061
|
+
} else if (this.filtersLockedEmpty) {
|
|
2062
|
+
// Explicit empty — do not re-apply resource defaultFilters.
|
|
2063
|
+
params.set('filters', '[]');
|
|
2064
|
+
}
|
|
2065
|
+
if (this.groupBy) {
|
|
2066
|
+
params.set('groupBy', this.groupBy);
|
|
2067
|
+
} else if (this.groupLockedEmpty) {
|
|
2068
|
+
params.set('groupBy', '');
|
|
2069
|
+
}
|
|
2070
|
+
if (this.trashed === true || this.trashed === 'only' || this.trashed === '1') {
|
|
2071
|
+
params.set('trashed', '1');
|
|
2072
|
+
}
|
|
2073
|
+
const qs = params.toString();
|
|
2074
|
+
return qs ? `${path}?${qs}` : path;
|
|
2075
|
+
},
|
|
2076
|
+
reload() {
|
|
2077
|
+
window.location.assign(this.buildUrl());
|
|
2078
|
+
},
|
|
2079
|
+
applyFilters() {
|
|
2080
|
+
const gen = ++this._searchGen;
|
|
2081
|
+
this.softReload(gen);
|
|
2082
|
+
},
|
|
2083
|
+
async softReload(gen) {
|
|
2084
|
+
const url = this.buildUrl();
|
|
2085
|
+
const input = this.$refs.listSearchInput;
|
|
2086
|
+
const keepFocus = Boolean(input && document.activeElement === input);
|
|
2087
|
+
const selStart = input?.selectionStart ?? null;
|
|
2088
|
+
const selEnd = input?.selectionEnd ?? null;
|
|
2089
|
+
|
|
2090
|
+
try {
|
|
2091
|
+
history.replaceState(null, '', url);
|
|
2092
|
+
} catch {
|
|
2093
|
+
/* ignore */
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
this._softReloading = true;
|
|
2097
|
+
let res;
|
|
2098
|
+
try {
|
|
2099
|
+
res = await fetch(url, {
|
|
2100
|
+
credentials: 'same-origin',
|
|
2101
|
+
headers: {
|
|
2102
|
+
Accept: 'text/html',
|
|
2103
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
2104
|
+
},
|
|
2105
|
+
});
|
|
2106
|
+
} catch {
|
|
2107
|
+
this._softReloading = false;
|
|
2108
|
+
window.location.assign(url);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
if (gen != null && gen !== this._searchGen) {
|
|
2113
|
+
this._softReloading = false;
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
if (!res.ok) {
|
|
2118
|
+
this._softReloading = false;
|
|
2119
|
+
window.location.assign(url);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
const html = await res.text();
|
|
2124
|
+
if (gen != null && gen !== this._searchGen) {
|
|
2125
|
+
this._softReloading = false;
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
2130
|
+
const newRoot = doc.querySelector('[data-shamar-list-root]');
|
|
2131
|
+
const curRoot = document.querySelector('[data-shamar-list-root]');
|
|
2132
|
+
if (!newRoot || !curRoot) {
|
|
2133
|
+
this._softReloading = false;
|
|
2134
|
+
window.location.assign(url);
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
const newTbody = newRoot.querySelector('tbody');
|
|
2139
|
+
const curTbody = curRoot.querySelector('tbody');
|
|
2140
|
+
if (newTbody && curTbody) {
|
|
2141
|
+
curTbody.innerHTML = newTbody.innerHTML;
|
|
2142
|
+
if (window.Alpine?.initTree) {
|
|
2143
|
+
window.Alpine.initTree(curTbody);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const newPagination = newRoot.querySelector('[data-shamar-list-pagination]');
|
|
2148
|
+
const curPagination = curRoot.querySelector('[data-shamar-list-pagination]');
|
|
2149
|
+
if (newPagination && curPagination) {
|
|
2150
|
+
curPagination.replaceWith(document.importNode(newPagination, true));
|
|
2151
|
+
const nextPagination = curRoot.querySelector('[data-shamar-list-pagination]');
|
|
2152
|
+
if (nextPagination && window.Alpine?.initTree) {
|
|
2153
|
+
window.Alpine.initTree(nextPagination);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
for (const key of ['total', 'search', 'sort', 'direction']) {
|
|
2158
|
+
const attr = `data-shamar-${key}`;
|
|
2159
|
+
const value = newRoot.getAttribute(attr);
|
|
2160
|
+
if (value == null) curRoot.removeAttribute(attr);
|
|
2161
|
+
else curRoot.setAttribute(attr, value);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const listData = window.Alpine?.$data?.(curRoot);
|
|
2165
|
+
if (listData) {
|
|
2166
|
+
listData.total = Number(curRoot.getAttribute('data-shamar-total') || 0);
|
|
2167
|
+
if (typeof listData.clearSelection === 'function') {
|
|
2168
|
+
listData.clearSelection();
|
|
2169
|
+
} else if (typeof listData.refreshPageState === 'function') {
|
|
2170
|
+
listData.refreshPageState();
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
this._softReloading = false;
|
|
2175
|
+
|
|
2176
|
+
if (keepFocus && input) {
|
|
2177
|
+
input.focus();
|
|
2178
|
+
try {
|
|
2179
|
+
const len = input.value.length;
|
|
2180
|
+
input.setSelectionRange(
|
|
2181
|
+
selStart == null ? len : selStart,
|
|
2182
|
+
selEnd == null ? len : selEnd,
|
|
2183
|
+
);
|
|
2184
|
+
} catch {
|
|
2185
|
+
/* ignore */
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
},
|
|
2189
|
+
onSearchChange() {
|
|
2190
|
+
const gen = ++this._searchGen;
|
|
2191
|
+
this.softReload(gen);
|
|
2192
|
+
},
|
|
2193
|
+
clearSearch() {
|
|
2194
|
+
this.searchInput = '';
|
|
2195
|
+
this.onSearchChange();
|
|
2196
|
+
},
|
|
2197
|
+
removeChip(i) {
|
|
2198
|
+
this.chips.splice(i, 1);
|
|
2199
|
+
this.filtersLockedEmpty = this.chips.length === 0;
|
|
2200
|
+
this.applyFilters();
|
|
2201
|
+
},
|
|
2202
|
+
clearAll() {
|
|
2203
|
+
this.chips = [];
|
|
2204
|
+
this.searchInput = '';
|
|
2205
|
+
this.groupBy = null;
|
|
2206
|
+
this.filtersLockedEmpty = true;
|
|
2207
|
+
this.groupLockedEmpty = true;
|
|
2208
|
+
this.panelOpen = false;
|
|
2209
|
+
this.groupOpen = false;
|
|
2210
|
+
this.openFilterField = null;
|
|
2211
|
+
this.reload();
|
|
2212
|
+
},
|
|
2213
|
+
clearGroupBy() {
|
|
2214
|
+
this.groupBy = null;
|
|
2215
|
+
this.groupLockedEmpty = true;
|
|
2216
|
+
this.groupOpen = false;
|
|
2217
|
+
this.applyFilters();
|
|
2218
|
+
},
|
|
2219
|
+
filterableHeaders() {
|
|
2220
|
+
return this.headers.filter(
|
|
2221
|
+
(h) => h.filter_kind === 'm2o' || h.filter_kind === 'boolean' || h.filter_kind === 'select',
|
|
2222
|
+
);
|
|
2223
|
+
},
|
|
2224
|
+
groupableHeaders() {
|
|
2225
|
+
return this.headers.filter(
|
|
2226
|
+
(h) => h.group_kind === 'm2o' || h.group_kind === 'boolean' || h.group_kind === 'select',
|
|
2227
|
+
);
|
|
2228
|
+
},
|
|
2229
|
+
toggleFilterField(name) {
|
|
2230
|
+
this.openFilterField = this.openFilterField === name ? null : name;
|
|
2231
|
+
},
|
|
2232
|
+
addBooleanFilter(h, value) {
|
|
2233
|
+
const field = h.filterField || h.name;
|
|
2234
|
+
this.chips = this.chips.filter((chip) => chip.field !== field);
|
|
2235
|
+
this.chips.push({
|
|
2236
|
+
field,
|
|
2237
|
+
op: '=',
|
|
2238
|
+
value,
|
|
2239
|
+
label: `${h.label}: ${value ? 'Yes' : 'No'}`,
|
|
2240
|
+
});
|
|
2241
|
+
this.filtersLockedEmpty = false;
|
|
2242
|
+
this.openFilterField = null;
|
|
2243
|
+
this.panelOpen = false;
|
|
2244
|
+
this.applyFilters();
|
|
2245
|
+
},
|
|
2246
|
+
addSelectFilter(h, opt) {
|
|
2247
|
+
const field = h.filterField || h.name;
|
|
2248
|
+
this.chips = this.chips.filter((chip) => chip.field !== field);
|
|
2249
|
+
this.chips.push({
|
|
2250
|
+
field,
|
|
2251
|
+
op: '=',
|
|
2252
|
+
value: opt.value,
|
|
2253
|
+
label: `${h.label}: ${opt.label}`,
|
|
2254
|
+
});
|
|
2255
|
+
this.filtersLockedEmpty = false;
|
|
2256
|
+
this.openFilterField = null;
|
|
2257
|
+
this.panelOpen = false;
|
|
2258
|
+
this.applyFilters();
|
|
2259
|
+
},
|
|
2260
|
+
async searchM2o(h) {
|
|
2261
|
+
const q = this.m2oQuery[h.name] || '';
|
|
2262
|
+
const fieldName = h.filterField || h.name;
|
|
2263
|
+
try {
|
|
2264
|
+
const url = `${this.basePath}/${this.slug}/relation-search?field=${encodeURIComponent(fieldName)}&q=${encodeURIComponent(q)}`;
|
|
2265
|
+
const res = await fetch(url, { headers: csrfHeaders() });
|
|
2266
|
+
if (!res.ok) throw new Error('fetch failed');
|
|
2267
|
+
const data = await res.json();
|
|
2268
|
+
this.m2oResults[h.name] = data.results || data.options || data || [];
|
|
2269
|
+
} catch {
|
|
2270
|
+
this.m2oResults[h.name] = [];
|
|
2271
|
+
}
|
|
2272
|
+
},
|
|
2273
|
+
addM2oFilter(h, opt) {
|
|
2274
|
+
const field = h.filterField || h.name;
|
|
2275
|
+
this.chips = this.chips.filter((chip) => chip.field !== field);
|
|
2276
|
+
this.chips.push({
|
|
2277
|
+
field,
|
|
2278
|
+
op: '=',
|
|
2279
|
+
value: opt.id,
|
|
2280
|
+
label: `${h.label}: ${opt.label}`,
|
|
2281
|
+
});
|
|
2282
|
+
this.filtersLockedEmpty = false;
|
|
2283
|
+
this.openFilterField = null;
|
|
2284
|
+
this.panelOpen = false;
|
|
2285
|
+
this.m2oQuery[h.name] = '';
|
|
2286
|
+
this.m2oResults[h.name] = [];
|
|
2287
|
+
this.applyFilters();
|
|
2288
|
+
},
|
|
2289
|
+
setGroupBy(name) {
|
|
2290
|
+
this.groupBy = name;
|
|
2291
|
+
this.groupLockedEmpty = false;
|
|
2292
|
+
this.groupOpen = false;
|
|
2293
|
+
this.panelOpen = false;
|
|
2294
|
+
this.applyFilters();
|
|
2295
|
+
},
|
|
2296
|
+
groupByLabel() {
|
|
2297
|
+
const h = this.headers.find((item) => item.name === this.groupBy);
|
|
2298
|
+
return h ? h.label : this.groupBy;
|
|
2299
|
+
},
|
|
2300
|
+
}));
|
|
2301
|
+
|
|
2302
|
+
Alpine.data('shamarListSelection', () => ({
|
|
2303
|
+
selected: [],
|
|
2304
|
+
selectAllMatching: false,
|
|
2305
|
+
total: 0,
|
|
2306
|
+
pageFullySelected: false,
|
|
2307
|
+
bulkOpen: false,
|
|
2308
|
+
init() {
|
|
2309
|
+
const root = this.$el;
|
|
2310
|
+
this.total = Number(root?.getAttribute?.('data-shamar-total') || 0);
|
|
2311
|
+
this.$watch('selected', () => this.refreshPageState());
|
|
2312
|
+
this.refreshPageState();
|
|
2313
|
+
},
|
|
2314
|
+
pageIds() {
|
|
2315
|
+
return Array.from(document.querySelectorAll('[data-shamar-record-id]'))
|
|
2316
|
+
.map((row) => row.getAttribute('data-shamar-record-id'))
|
|
2317
|
+
.filter(Boolean)
|
|
2318
|
+
.map(String);
|
|
2319
|
+
},
|
|
2320
|
+
refreshPageState() {
|
|
2321
|
+
if (this.selectAllMatching) {
|
|
2322
|
+
this.pageFullySelected = true;
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
const ids = this.pageIds();
|
|
2326
|
+
this.pageFullySelected =
|
|
2327
|
+
ids.length > 0 && ids.every((id) => this.selected.includes(id));
|
|
2328
|
+
},
|
|
2329
|
+
toggleId(id) {
|
|
2330
|
+
this.selectAllMatching = false;
|
|
2331
|
+
const value = String(id);
|
|
2332
|
+
if (this.selected.includes(value)) {
|
|
2333
|
+
this.selected = this.selected.filter((item) => item !== value);
|
|
2334
|
+
} else {
|
|
2335
|
+
this.selected = [...this.selected, value];
|
|
2336
|
+
}
|
|
2337
|
+
},
|
|
2338
|
+
toggleAll(checked) {
|
|
2339
|
+
this.selectAllMatching = false;
|
|
2340
|
+
const ids = this.pageIds();
|
|
2341
|
+
this.selected = checked ? ids : [];
|
|
2342
|
+
},
|
|
2343
|
+
selectAllInResultSet() {
|
|
2344
|
+
this.selectAllMatching = true;
|
|
2345
|
+
this.selected = this.pageIds();
|
|
2346
|
+
this.pageFullySelected = true;
|
|
2347
|
+
},
|
|
2348
|
+
clearSelection() {
|
|
2349
|
+
this.selectAllMatching = false;
|
|
2350
|
+
this.selected = [];
|
|
2351
|
+
this.pageFullySelected = false;
|
|
2352
|
+
this.bulkOpen = false;
|
|
2353
|
+
},
|
|
2354
|
+
submitBulk(url, action, confirmMessage) {
|
|
2355
|
+
if (!this.selected.length && !this.selectAllMatching) return;
|
|
2356
|
+
this.bulkOpen = false;
|
|
2357
|
+
const countLabel = this.selectAllMatching ? this.total : this.selected.length;
|
|
2358
|
+
const run = () => this.executeBulk(url, action);
|
|
2359
|
+
if (confirmMessage) {
|
|
2360
|
+
window.dispatchEvent(
|
|
2361
|
+
new CustomEvent('shamar-open-confirm', {
|
|
2362
|
+
detail: {
|
|
2363
|
+
title: 'Confirm',
|
|
2364
|
+
message: String(confirmMessage).replace('%d', String(countLabel)),
|
|
2365
|
+
variant: action === 'delete' ? 'danger' : 'primary',
|
|
2366
|
+
confirmLabel: action === 'delete' ? 'Delete' : 'Confirm',
|
|
2367
|
+
onConfirm: run,
|
|
2368
|
+
},
|
|
2369
|
+
}),
|
|
2370
|
+
);
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
run();
|
|
2374
|
+
},
|
|
2375
|
+
executeBulk(url, action) {
|
|
2376
|
+
const root = this.$el;
|
|
2377
|
+
const form = document.createElement('form');
|
|
2378
|
+
form.method = 'post';
|
|
2379
|
+
form.action = url;
|
|
2380
|
+
const token = csrfToken();
|
|
2381
|
+
if (token) {
|
|
2382
|
+
const csrfInput = document.createElement('input');
|
|
2383
|
+
csrfInput.type = 'hidden';
|
|
2384
|
+
csrfInput.name = '_csrf';
|
|
2385
|
+
csrfInput.value = token;
|
|
2386
|
+
form.appendChild(csrfInput);
|
|
2387
|
+
}
|
|
2388
|
+
const actionInput = document.createElement('input');
|
|
2389
|
+
actionInput.type = 'hidden';
|
|
2390
|
+
actionInput.name = 'action';
|
|
2391
|
+
actionInput.value = action;
|
|
2392
|
+
form.appendChild(actionInput);
|
|
2393
|
+
if (this.selectAllMatching) {
|
|
2394
|
+
const selectAllInput = document.createElement('input');
|
|
2395
|
+
selectAllInput.type = 'hidden';
|
|
2396
|
+
selectAllInput.name = 'selectAll';
|
|
2397
|
+
selectAllInput.value = '1';
|
|
2398
|
+
form.appendChild(selectAllInput);
|
|
2399
|
+
for (const key of ['search', 'sort', 'direction', 'trashed']) {
|
|
2400
|
+
const value = root?.getAttribute?.(`data-shamar-${key}`) || '';
|
|
2401
|
+
if (!value || value === 'false' || value === 'undefined') continue;
|
|
2402
|
+
const input = document.createElement('input');
|
|
2403
|
+
input.type = 'hidden';
|
|
2404
|
+
input.name = key;
|
|
2405
|
+
input.value = value;
|
|
2406
|
+
form.appendChild(input);
|
|
2407
|
+
}
|
|
2408
|
+
} else {
|
|
2409
|
+
for (const id of this.selected) {
|
|
2410
|
+
const input = document.createElement('input');
|
|
2411
|
+
input.type = 'hidden';
|
|
2412
|
+
input.name = 'ids';
|
|
2413
|
+
input.value = id;
|
|
2414
|
+
form.appendChild(input);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
document.body.appendChild(form);
|
|
2418
|
+
form.submit();
|
|
2419
|
+
},
|
|
2420
|
+
}));
|
|
2421
|
+
|
|
2422
|
+
Alpine.data('shamarDialogHost', () => ({
|
|
2423
|
+
open: false,
|
|
2424
|
+
loading: false,
|
|
2425
|
+
confirmMode: false,
|
|
2426
|
+
promptMode: 'confirm',
|
|
2427
|
+
promptVariant: 'danger',
|
|
2428
|
+
confirmLabel: 'Confirm',
|
|
2429
|
+
cancelLabel: 'Cancel',
|
|
2430
|
+
title: '',
|
|
2431
|
+
fullPageUrl: '',
|
|
2432
|
+
confirmAction: '',
|
|
2433
|
+
confirmMessage: '',
|
|
2434
|
+
secretText: '',
|
|
2435
|
+
secretCopied: false,
|
|
2436
|
+
fullscreen: false,
|
|
2437
|
+
x: 0,
|
|
2438
|
+
y: 0,
|
|
2439
|
+
width: 720,
|
|
2440
|
+
height: 520,
|
|
2441
|
+
minWidth: 400,
|
|
2442
|
+
minHeight: 280,
|
|
2443
|
+
dragging: false,
|
|
2444
|
+
resizing: false,
|
|
2445
|
+
dragOffsetX: 0,
|
|
2446
|
+
dragOffsetY: 0,
|
|
2447
|
+
resizeStartX: 0,
|
|
2448
|
+
resizeStartY: 0,
|
|
2449
|
+
resizeStartW: 0,
|
|
2450
|
+
resizeStartH: 0,
|
|
2451
|
+
resourceSlug: '',
|
|
2452
|
+
dialogStack: [],
|
|
2453
|
+
currentEmbedUrl: '',
|
|
2454
|
+
hasContentFooter: false,
|
|
2455
|
+
_previousFocus: null,
|
|
2456
|
+
_confirmCallback: null,
|
|
2457
|
+
|
|
2458
|
+
focusables() {
|
|
2459
|
+
const root = this.$refs.panel;
|
|
2460
|
+
if (!root) return [];
|
|
2461
|
+
return Array.from(
|
|
2462
|
+
root.querySelectorAll(
|
|
2463
|
+
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
|
|
2464
|
+
),
|
|
2465
|
+
).filter((el) => !el.hasAttribute('disabled') && el.offsetParent !== null);
|
|
2466
|
+
},
|
|
2467
|
+
|
|
2468
|
+
trapFocus(event) {
|
|
2469
|
+
if (!this.open) return;
|
|
2470
|
+
const items = this.focusables();
|
|
2471
|
+
if (items.length === 0) return;
|
|
2472
|
+
const first = items[0];
|
|
2473
|
+
const last = items[items.length - 1];
|
|
2474
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
2475
|
+
event.preventDefault();
|
|
2476
|
+
last.focus();
|
|
2477
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
2478
|
+
event.preventDefault();
|
|
2479
|
+
first.focus();
|
|
2480
|
+
}
|
|
2481
|
+
},
|
|
2482
|
+
|
|
2483
|
+
focusDialog() {
|
|
2484
|
+
this.$nextTick(() => {
|
|
2485
|
+
const items = this.focusables();
|
|
2486
|
+
const preferred =
|
|
2487
|
+
this.bodyEl()?.querySelector('input,select,textarea,button') ||
|
|
2488
|
+
this.$refs.closeBtn ||
|
|
2489
|
+
items[0];
|
|
2490
|
+
preferred?.focus?.();
|
|
2491
|
+
});
|
|
2492
|
+
},
|
|
2493
|
+
|
|
2494
|
+
panelEl() {
|
|
2495
|
+
return this.$refs.panel || document.querySelector('.shamar-dialog-panel') || null;
|
|
2496
|
+
},
|
|
2497
|
+
|
|
2498
|
+
bodyEl() {
|
|
2499
|
+
const panel = this.panelEl();
|
|
2500
|
+
if (panel) {
|
|
2501
|
+
const fromPanel = panel.querySelector('[data-shamar-dialog-body]');
|
|
2502
|
+
if (fromPanel) return fromPanel;
|
|
2503
|
+
}
|
|
2504
|
+
return document.querySelector('.shamar-dialog-panel [data-shamar-dialog-body]');
|
|
2505
|
+
},
|
|
2506
|
+
|
|
2507
|
+
footerEl() {
|
|
2508
|
+
const panel = this.panelEl();
|
|
2509
|
+
if (panel) {
|
|
2510
|
+
const fromPanel = panel.querySelector('[data-shamar-dialog-footer]');
|
|
2511
|
+
if (fromPanel) return fromPanel;
|
|
2512
|
+
}
|
|
2513
|
+
return document.querySelector('.shamar-dialog-panel [data-shamar-dialog-footer]');
|
|
2514
|
+
},
|
|
2515
|
+
|
|
2516
|
+
clearContentFooter() {
|
|
2517
|
+
this.hasContentFooter = false;
|
|
2518
|
+
const footer = this.footerEl();
|
|
2519
|
+
if (footer) footer.innerHTML = '';
|
|
2520
|
+
},
|
|
2521
|
+
|
|
2522
|
+
hoistDialogActions() {
|
|
2523
|
+
const body = this.bodyEl();
|
|
2524
|
+
const actions = body?.querySelector('[data-shamar-dialog-actions]');
|
|
2525
|
+
const footer = this.footerEl();
|
|
2526
|
+
if (!actions || !footer) {
|
|
2527
|
+
this.hasContentFooter = false;
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
footer.innerHTML = '';
|
|
2531
|
+
while (actions.firstChild) {
|
|
2532
|
+
footer.appendChild(actions.firstChild);
|
|
2533
|
+
}
|
|
2534
|
+
actions.remove();
|
|
2535
|
+
footer.querySelectorAll('[data-shamar-dialog-close]').forEach((btn) => {
|
|
2536
|
+
btn.addEventListener('click', () => this.dismissDialog());
|
|
2537
|
+
});
|
|
2538
|
+
const form =
|
|
2539
|
+
body.querySelector('form[data-shamar-embed-form]') ||
|
|
2540
|
+
body.querySelector('form[data-shamar-embed-import]');
|
|
2541
|
+
if (form?.id) {
|
|
2542
|
+
footer.querySelectorAll('button[type="submit"]').forEach((btn) => {
|
|
2543
|
+
if (!btn.getAttribute('form')) btn.setAttribute('form', form.id);
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
try {
|
|
2547
|
+
if (typeof Alpine !== 'undefined' && Alpine.initTree) {
|
|
2548
|
+
Alpine.initTree(footer);
|
|
2549
|
+
}
|
|
2550
|
+
} catch {
|
|
2551
|
+
/* ignore */
|
|
2552
|
+
}
|
|
2553
|
+
this.hasContentFooter = true;
|
|
2554
|
+
},
|
|
2555
|
+
|
|
2556
|
+
mountBody(html) {
|
|
2557
|
+
this.clearContentFooter();
|
|
2558
|
+
const body = this.bodyEl();
|
|
2559
|
+
if (!body) return false;
|
|
2560
|
+
body.innerHTML = typeof html === 'string' ? html : '';
|
|
2561
|
+
try {
|
|
2562
|
+
if (typeof Alpine !== 'undefined' && Alpine.initTree) {
|
|
2563
|
+
Alpine.initTree(body);
|
|
2564
|
+
}
|
|
2565
|
+
} catch {
|
|
2566
|
+
/* keep raw HTML */
|
|
2567
|
+
}
|
|
2568
|
+
this.hoistDialogActions();
|
|
2569
|
+
this.bindForm(body);
|
|
2570
|
+
this.focusDialog();
|
|
2571
|
+
return true;
|
|
2572
|
+
},
|
|
2573
|
+
|
|
2574
|
+
async mountBodyWhenReady(html) {
|
|
2575
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
2576
|
+
if (this.mountBody(html)) return true;
|
|
2577
|
+
await this.$nextTick();
|
|
2578
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
2579
|
+
}
|
|
2580
|
+
const panel = this.panelEl();
|
|
2581
|
+
if (!panel) return false;
|
|
2582
|
+
let body = panel.querySelector('[data-shamar-dialog-body]');
|
|
2583
|
+
if (!body) {
|
|
2584
|
+
body = document.createElement('div');
|
|
2585
|
+
body.setAttribute('data-shamar-dialog-body', '');
|
|
2586
|
+
const region = panel.querySelector('.shamar-dialog-body');
|
|
2587
|
+
(region || panel).appendChild(body);
|
|
2588
|
+
}
|
|
2589
|
+
body.innerHTML = typeof html === 'string' ? html : '';
|
|
2590
|
+
this.hoistDialogActions();
|
|
2591
|
+
this.bindForm(body);
|
|
2592
|
+
this.focusDialog();
|
|
2593
|
+
return true;
|
|
2594
|
+
},
|
|
2595
|
+
|
|
2596
|
+
init() {
|
|
2597
|
+
window.addEventListener('mousemove', (e) => {
|
|
2598
|
+
this.onDrag(e);
|
|
2599
|
+
this.onResize(e);
|
|
2600
|
+
});
|
|
2601
|
+
window.addEventListener('mouseup', () => {
|
|
2602
|
+
this.dragging = false;
|
|
2603
|
+
this.resizing = false;
|
|
2604
|
+
});
|
|
2605
|
+
},
|
|
2606
|
+
|
|
2607
|
+
panelStyle() {
|
|
2608
|
+
if (this.fullscreen) {
|
|
2609
|
+
return 'inset: 0.75rem; width: auto; height: auto; transform: none;';
|
|
2610
|
+
}
|
|
2611
|
+
return `width: ${this.width}px; height: ${this.height}px; transform: translate(calc(-50% + ${this.x}px), calc(-50% + ${this.y}px));`;
|
|
2612
|
+
},
|
|
2613
|
+
|
|
2614
|
+
async openModal(detail) {
|
|
2615
|
+
if (!this.open) {
|
|
2616
|
+
this._previousFocus = document.activeElement;
|
|
2617
|
+
}
|
|
2618
|
+
this.confirmMode = false;
|
|
2619
|
+
const nextSlug = detail.slug || '';
|
|
2620
|
+
const replace = detail.replace === true;
|
|
2621
|
+
const shouldStack =
|
|
2622
|
+
this.open &&
|
|
2623
|
+
!replace &&
|
|
2624
|
+
!this.confirmMode &&
|
|
2625
|
+
this.currentEmbedUrl &&
|
|
2626
|
+
nextSlug &&
|
|
2627
|
+
nextSlug !== this.resourceSlug;
|
|
2628
|
+
|
|
2629
|
+
if (shouldStack) {
|
|
2630
|
+
this.dialogStack.push({
|
|
2631
|
+
title: this.title,
|
|
2632
|
+
fullPageUrl: this.fullPageUrl,
|
|
2633
|
+
resourceSlug: this.resourceSlug,
|
|
2634
|
+
url: this.currentEmbedUrl,
|
|
2635
|
+
onResult: _dialogOnResult,
|
|
2636
|
+
});
|
|
2637
|
+
} else if (!this.open) {
|
|
2638
|
+
document.body.classList.add('overflow-hidden');
|
|
2639
|
+
}
|
|
2640
|
+
this.open = true;
|
|
2641
|
+
this.loading = true;
|
|
2642
|
+
this.clearContentFooter();
|
|
2643
|
+
this.title = detail.title || 'Record';
|
|
2644
|
+
this.fullPageUrl = withoutEmbed(detail.url);
|
|
2645
|
+
this.resourceSlug = nextSlug;
|
|
2646
|
+
this.fullscreen = false;
|
|
2647
|
+
this.width = 720;
|
|
2648
|
+
this.height = 520;
|
|
2649
|
+
this.x = 0;
|
|
2650
|
+
this.y = 0;
|
|
2651
|
+
if (!replace) {
|
|
2652
|
+
_dialogOnResult = typeof detail.onResult === 'function' ? detail.onResult : null;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
const embedUrl = withEmbed(detail.url);
|
|
2656
|
+
this.currentEmbedUrl = embedUrl;
|
|
2657
|
+
await this.$nextTick();
|
|
2658
|
+
|
|
2659
|
+
let html = '';
|
|
2660
|
+
try {
|
|
2661
|
+
const res = await fetch(embedUrl, {
|
|
2662
|
+
headers: csrfHeaders({ Accept: 'text/html' }),
|
|
2663
|
+
credentials: 'same-origin',
|
|
2664
|
+
});
|
|
2665
|
+
html = await res.text();
|
|
2666
|
+
if (!res.ok) {
|
|
2667
|
+
html = `<p class="text-sm text-fg-danger">Could not load dialog (${res.status}).</p>`;
|
|
2668
|
+
}
|
|
2669
|
+
} catch {
|
|
2670
|
+
html = '<p class="text-sm text-fg-danger">Could not load dialog.</p>';
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
this.loading = false;
|
|
2674
|
+
await this.$nextTick();
|
|
2675
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
2676
|
+
await this.mountBodyWhenReady(html);
|
|
2677
|
+
},
|
|
2678
|
+
|
|
2679
|
+
async restoreDialog(stackItem) {
|
|
2680
|
+
this.title = stackItem.title;
|
|
2681
|
+
this.fullPageUrl = stackItem.fullPageUrl;
|
|
2682
|
+
this.resourceSlug = stackItem.resourceSlug;
|
|
2683
|
+
this.currentEmbedUrl = stackItem.url;
|
|
2684
|
+
_dialogOnResult = stackItem.onResult || null;
|
|
2685
|
+
this.confirmMode = false;
|
|
2686
|
+
this.loading = true;
|
|
2687
|
+
await this.$nextTick();
|
|
2688
|
+
let html = '';
|
|
2689
|
+
try {
|
|
2690
|
+
const res = await fetch(stackItem.url, {
|
|
2691
|
+
headers: csrfHeaders({ Accept: 'text/html' }),
|
|
2692
|
+
credentials: 'same-origin',
|
|
2693
|
+
});
|
|
2694
|
+
html = await res.text();
|
|
2695
|
+
} catch {
|
|
2696
|
+
html = '<p class="text-sm text-fg-danger">Could not load dialog.</p>';
|
|
2697
|
+
}
|
|
2698
|
+
this.loading = false;
|
|
2699
|
+
await this.$nextTick();
|
|
2700
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
2701
|
+
await this.mountBodyWhenReady(html);
|
|
2702
|
+
},
|
|
2703
|
+
|
|
2704
|
+
openConfirm(detail) {
|
|
2705
|
+
if (!this.open) {
|
|
2706
|
+
this._previousFocus = document.activeElement;
|
|
2707
|
+
}
|
|
2708
|
+
this.confirmMode = true;
|
|
2709
|
+
this.open = true;
|
|
2710
|
+
this.loading = false;
|
|
2711
|
+
this.clearContentFooter();
|
|
2712
|
+
const rawMode = detail.mode;
|
|
2713
|
+
const mode =
|
|
2714
|
+
rawMode === 'alert' || rawMode === 'info' || rawMode === 'secret'
|
|
2715
|
+
? rawMode
|
|
2716
|
+
: 'confirm';
|
|
2717
|
+
const variant =
|
|
2718
|
+
detail.variant ||
|
|
2719
|
+
(mode === 'secret'
|
|
2720
|
+
? 'warning'
|
|
2721
|
+
: mode === 'alert' || mode === 'info'
|
|
2722
|
+
? 'info'
|
|
2723
|
+
: detail.color === 'danger'
|
|
2724
|
+
? 'danger'
|
|
2725
|
+
: 'danger');
|
|
2726
|
+
this.promptMode = mode === 'info' ? 'alert' : mode;
|
|
2727
|
+
this.promptVariant = variant;
|
|
2728
|
+
this.title =
|
|
2729
|
+
detail.title ||
|
|
2730
|
+
(this.promptMode === 'secret'
|
|
2731
|
+
? 'Copy your secret'
|
|
2732
|
+
: this.promptMode === 'alert'
|
|
2733
|
+
? this.promptVariant === 'danger'
|
|
2734
|
+
? 'Alert'
|
|
2735
|
+
: 'Notice'
|
|
2736
|
+
: 'Confirm');
|
|
2737
|
+
this.confirmMessage = detail.message || 'Are you sure?';
|
|
2738
|
+
this.secretText = this.promptMode === 'secret' ? String(detail.secret || '') : '';
|
|
2739
|
+
this.secretCopied = false;
|
|
2740
|
+
this.confirmAction = detail.action || '';
|
|
2741
|
+
this._confirmCallback = typeof detail.onConfirm === 'function' ? detail.onConfirm : null;
|
|
2742
|
+
this.confirmLabel =
|
|
2743
|
+
detail.confirmLabel ||
|
|
2744
|
+
(this.promptMode === 'secret'
|
|
2745
|
+
? "I've copied it"
|
|
2746
|
+
: this.promptMode === 'alert'
|
|
2747
|
+
? 'OK'
|
|
2748
|
+
: detail.variant === 'danger'
|
|
2749
|
+
? 'Delete'
|
|
2750
|
+
: 'Confirm');
|
|
2751
|
+
this.cancelLabel = detail.cancelLabel || 'Cancel';
|
|
2752
|
+
this.fullPageUrl = '';
|
|
2753
|
+
this.fullscreen = false;
|
|
2754
|
+
this.width = this.promptMode === 'secret' ? 560 : 420;
|
|
2755
|
+
this.height = this.promptMode === 'secret' ? 360 : 240;
|
|
2756
|
+
this.x = 0;
|
|
2757
|
+
this.y = 0;
|
|
2758
|
+
document.body.classList.add('overflow-hidden');
|
|
2759
|
+
this.focusDialog();
|
|
2760
|
+
},
|
|
2761
|
+
|
|
2762
|
+
async copySecret() {
|
|
2763
|
+
const value = String(this.secretText || '');
|
|
2764
|
+
if (!value) return;
|
|
2765
|
+
try {
|
|
2766
|
+
await navigator.clipboard.writeText(value);
|
|
2767
|
+
this.secretCopied = true;
|
|
2768
|
+
window.setTimeout(() => {
|
|
2769
|
+
this.secretCopied = false;
|
|
2770
|
+
}, 2000);
|
|
2771
|
+
} catch {
|
|
2772
|
+
showToast('error', {
|
|
2773
|
+
title: 'Copy failed',
|
|
2774
|
+
message: 'Select the key and copy it manually.',
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
},
|
|
2778
|
+
|
|
2779
|
+
confirmButtonClass() {
|
|
2780
|
+
const base = '';
|
|
2781
|
+
if (this.promptVariant === 'danger') return 'bg-fg-danger';
|
|
2782
|
+
if (this.promptVariant === 'warning') return 'bg-warning-strong';
|
|
2783
|
+
if (this.promptVariant === 'success') return 'bg-success-strong';
|
|
2784
|
+
return 'bg-fg-brand';
|
|
2785
|
+
},
|
|
2786
|
+
|
|
2787
|
+
close() {
|
|
2788
|
+
this.open = false;
|
|
2789
|
+
this.confirmMode = false;
|
|
2790
|
+
this.fullscreen = false;
|
|
2791
|
+
this.dialogStack = [];
|
|
2792
|
+
this._confirmCallback = null;
|
|
2793
|
+
this.secretText = '';
|
|
2794
|
+
this.secretCopied = false;
|
|
2795
|
+
_dialogOnResult = null;
|
|
2796
|
+
this.currentEmbedUrl = '';
|
|
2797
|
+
this.clearContentFooter();
|
|
2798
|
+
const body = this.bodyEl();
|
|
2799
|
+
if (body) body.innerHTML = '';
|
|
2800
|
+
document.body.classList.remove('overflow-hidden');
|
|
2801
|
+
const prev = this._previousFocus;
|
|
2802
|
+
this._previousFocus = null;
|
|
2803
|
+
if (prev && typeof prev.focus === 'function') {
|
|
2804
|
+
prev.focus();
|
|
2805
|
+
}
|
|
2806
|
+
},
|
|
2807
|
+
|
|
2808
|
+
async dismissDialog(options = {}) {
|
|
2809
|
+
// One-time secrets must be acknowledged — no backdrop / × dismiss.
|
|
2810
|
+
if (this.confirmMode && this.promptMode === 'secret') {
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
if (this.confirmMode) {
|
|
2814
|
+
this.close();
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
if (this.dialogStack.length > 0) {
|
|
2818
|
+
const item = this.dialogStack.pop();
|
|
2819
|
+
await this.restoreDialog(item);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
const shouldRefresh = !options.skipRefresh && !!this.currentEmbedUrl;
|
|
2823
|
+
this.close();
|
|
2824
|
+
if (shouldRefresh) {
|
|
2825
|
+
reloadParentView();
|
|
2826
|
+
}
|
|
2827
|
+
},
|
|
2828
|
+
|
|
2829
|
+
toggleFullscreen() {
|
|
2830
|
+
this.fullscreen = !this.fullscreen;
|
|
2831
|
+
if (this.fullscreen) {
|
|
2832
|
+
this.x = 0;
|
|
2833
|
+
this.y = 0;
|
|
2834
|
+
}
|
|
2835
|
+
},
|
|
2836
|
+
|
|
2837
|
+
startDrag(event) {
|
|
2838
|
+
if (this.fullscreen || this.confirmMode) return;
|
|
2839
|
+
this.dragging = true;
|
|
2840
|
+
this.dragOffsetX = event.clientX - this.x;
|
|
2841
|
+
this.dragOffsetY = event.clientY - this.y;
|
|
2842
|
+
},
|
|
2843
|
+
|
|
2844
|
+
onDrag(event) {
|
|
2845
|
+
if (!this.dragging) return;
|
|
2846
|
+
this.x = event.clientX - this.dragOffsetX;
|
|
2847
|
+
this.y = event.clientY - this.dragOffsetY;
|
|
2848
|
+
},
|
|
2849
|
+
|
|
2850
|
+
startResize(event) {
|
|
2851
|
+
if (this.fullscreen || this.confirmMode) return;
|
|
2852
|
+
event.preventDefault();
|
|
2853
|
+
this.resizing = true;
|
|
2854
|
+
this.resizeStartX = event.clientX;
|
|
2855
|
+
this.resizeStartY = event.clientY;
|
|
2856
|
+
this.resizeStartW = this.width;
|
|
2857
|
+
this.resizeStartH = this.height;
|
|
2858
|
+
},
|
|
2859
|
+
|
|
2860
|
+
onResize(event) {
|
|
2861
|
+
if (!this.resizing) return;
|
|
2862
|
+
this.width = Math.max(this.minWidth, this.resizeStartW + (event.clientX - this.resizeStartX));
|
|
2863
|
+
this.height = Math.max(this.minHeight, this.resizeStartH + (event.clientY - this.resizeStartY));
|
|
2864
|
+
},
|
|
2865
|
+
|
|
2866
|
+
bindForm(root) {
|
|
2867
|
+
const importForm = root.querySelector('form[data-shamar-embed-import]');
|
|
2868
|
+
if (importForm) {
|
|
2869
|
+
this.bindImportForm(importForm);
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
const form = root.querySelector('form[data-shamar-embed-form]');
|
|
2873
|
+
if (!form) return;
|
|
2874
|
+
form.addEventListener('submit', async (event) => {
|
|
2875
|
+
event.preventDefault();
|
|
2876
|
+
const submitBtn =
|
|
2877
|
+
this.footerEl()?.querySelector('[type="submit"]') ||
|
|
2878
|
+
form.querySelector('[type="submit"]');
|
|
2879
|
+
if (submitBtn) submitBtn.disabled = true;
|
|
2880
|
+
try {
|
|
2881
|
+
const body = new URLSearchParams(new FormData(form));
|
|
2882
|
+
if (!body.has('_csrf')) {
|
|
2883
|
+
const token = csrfToken();
|
|
2884
|
+
if (token) body.set('_csrf', token);
|
|
2885
|
+
}
|
|
2886
|
+
const res = await fetch(form.action, {
|
|
2887
|
+
method: 'POST',
|
|
2888
|
+
headers: csrfHeaders({
|
|
2889
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
2890
|
+
Accept: 'application/json',
|
|
2891
|
+
}),
|
|
2892
|
+
body,
|
|
2893
|
+
redirect: 'follow',
|
|
2894
|
+
});
|
|
2895
|
+
if (res.redirected) {
|
|
2896
|
+
const done = new URL(res.url);
|
|
2897
|
+
this.handleRedirect(`${done.pathname}${done.search}`);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
if (res.status === 422 || !res.ok) {
|
|
2901
|
+
let payload = null;
|
|
2902
|
+
try {
|
|
2903
|
+
payload = await res.json();
|
|
2904
|
+
} catch {
|
|
2905
|
+
/* ignore */
|
|
2906
|
+
}
|
|
2907
|
+
const messages = applyFieldErrors(form, payload?.errors);
|
|
2908
|
+
showToast('error', {
|
|
2909
|
+
title: 'Could not save',
|
|
2910
|
+
message: validationToastMessage(messages, payload?.message),
|
|
2911
|
+
});
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
let payload = null;
|
|
2915
|
+
try {
|
|
2916
|
+
payload = await res.json();
|
|
2917
|
+
} catch {
|
|
2918
|
+
/* ignore */
|
|
2919
|
+
}
|
|
2920
|
+
if (payload?.id != null || payload?._id != null) {
|
|
2921
|
+
const base = (
|
|
2922
|
+
form.getAttribute('data-shamar-after-create') ||
|
|
2923
|
+
form.action.replace(/\/$/, '')
|
|
2924
|
+
).replace(/\/$/, '');
|
|
2925
|
+
const id = payload.id ?? payload._id;
|
|
2926
|
+
const view =
|
|
2927
|
+
form.getAttribute('data-shamar-after-create-view') ||
|
|
2928
|
+
(payload.plainText ? 'show' : 'edit');
|
|
2929
|
+
const next =
|
|
2930
|
+
view === 'show' ? `${base}/${id}` : `${base}/${id}/edit`;
|
|
2931
|
+
if (payload.plainText) {
|
|
2932
|
+
revealOneTimeSecret({
|
|
2933
|
+
secret: payload.plainText,
|
|
2934
|
+
title: 'Copy your API key',
|
|
2935
|
+
message:
|
|
2936
|
+
'This secret will only be shown once. Copy it now and store it somewhere safe before continuing.',
|
|
2937
|
+
onConfirm: () => this.handleRedirect(next),
|
|
2938
|
+
});
|
|
2939
|
+
} else {
|
|
2940
|
+
this.handleRedirect(next);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
} catch {
|
|
2944
|
+
showToast('error', {
|
|
2945
|
+
title: 'Could not save',
|
|
2946
|
+
message: 'Please check the form and try again.',
|
|
2947
|
+
});
|
|
2948
|
+
} finally {
|
|
2949
|
+
if (submitBtn) submitBtn.disabled = false;
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
},
|
|
2953
|
+
|
|
2954
|
+
bindImportForm(form) {
|
|
2955
|
+
form.addEventListener('submit', async (event) => {
|
|
2956
|
+
event.preventDefault();
|
|
2957
|
+
const submitBtn =
|
|
2958
|
+
this.footerEl()?.querySelector('[type="submit"]') ||
|
|
2959
|
+
form.querySelector('[type="submit"]');
|
|
2960
|
+
if (submitBtn) submitBtn.disabled = true;
|
|
2961
|
+
try {
|
|
2962
|
+
const body = new URLSearchParams(new FormData(form));
|
|
2963
|
+
if (!body.has('_csrf')) {
|
|
2964
|
+
const token = csrfToken();
|
|
2965
|
+
if (token) body.set('_csrf', token);
|
|
2966
|
+
}
|
|
2967
|
+
body.set('_shamar_embed', '1');
|
|
2968
|
+
const res = await fetch(form.action, {
|
|
2969
|
+
method: 'POST',
|
|
2970
|
+
headers: csrfHeaders({
|
|
2971
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
2972
|
+
Accept: 'application/json',
|
|
2973
|
+
}),
|
|
2974
|
+
body,
|
|
2975
|
+
});
|
|
2976
|
+
const payload = await res.json().catch(() => null);
|
|
2977
|
+
if (!res.ok || !payload) {
|
|
2978
|
+
showToast('error', {
|
|
2979
|
+
title: 'Import failed',
|
|
2980
|
+
message: 'Could not import the CSV file.',
|
|
2981
|
+
});
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
const created = Number(payload.created || 0);
|
|
2985
|
+
const failed = Number(payload.failed || 0);
|
|
2986
|
+
if (created > 0 && failed === 0) {
|
|
2987
|
+
showToast('success', {
|
|
2988
|
+
title: 'Import complete',
|
|
2989
|
+
message: `Created ${created} record(s).`,
|
|
2990
|
+
});
|
|
2991
|
+
} else if (created > 0) {
|
|
2992
|
+
showToast('warning', {
|
|
2993
|
+
title: 'Import finished with errors',
|
|
2994
|
+
message: `Created ${created}, failed ${failed}.`,
|
|
2995
|
+
});
|
|
2996
|
+
} else {
|
|
2997
|
+
showToast('error', {
|
|
2998
|
+
title: 'Import failed',
|
|
2999
|
+
message: (payload.errors && payload.errors[0]) || 'No rows were imported.',
|
|
3000
|
+
});
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
this.close();
|
|
3004
|
+
window.location.reload();
|
|
3005
|
+
} catch {
|
|
3006
|
+
showToast('error', {
|
|
3007
|
+
title: 'Import failed',
|
|
3008
|
+
message: 'Could not import the CSV file.',
|
|
3009
|
+
});
|
|
3010
|
+
} finally {
|
|
3011
|
+
if (submitBtn) submitBtn.disabled = false;
|
|
3012
|
+
}
|
|
3013
|
+
});
|
|
3014
|
+
},
|
|
3015
|
+
|
|
3016
|
+
async handleRedirect(location) {
|
|
3017
|
+
if (!location) return;
|
|
3018
|
+
const url = new URL(location, window.location.origin);
|
|
3019
|
+
const success = url.searchParams.get('success');
|
|
3020
|
+
const error = url.searchParams.get('error');
|
|
3021
|
+
const isEmbed = url.searchParams.get('embed') === '1';
|
|
3022
|
+
const basePath = document.body.dataset.shamarBasePath || '';
|
|
3023
|
+
const flash = flashFromRedirect(success, error);
|
|
3024
|
+
|
|
3025
|
+
if (isEmbed && _dialogOnResult && success === 'created') {
|
|
3026
|
+
const record = parseAdminRecordPath(url.pathname);
|
|
3027
|
+
if (record) {
|
|
3028
|
+
try {
|
|
3029
|
+
const response = await fetch(`${basePath}/${record.slug}/${record.id}/summary`);
|
|
3030
|
+
if (response.ok) {
|
|
3031
|
+
const item = await response.json();
|
|
3032
|
+
const callback = _dialogOnResult;
|
|
3033
|
+
_dialogOnResult = null;
|
|
3034
|
+
if (callback) callback({ id: item.id, label: item.label });
|
|
3035
|
+
if (flash) showToast(flash);
|
|
3036
|
+
if (this.dialogStack.length > 0) {
|
|
3037
|
+
await this.restoreDialog(this.dialogStack.pop());
|
|
3038
|
+
applyPendingRelationPick();
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
applyPendingRelationPick();
|
|
3042
|
+
this.close();
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
} catch {
|
|
3046
|
+
/* fall through */
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
_dialogOnResult = null;
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
if (isEmbed && this.open) {
|
|
3053
|
+
if (success === 'updated' || (success === 'created' && !_dialogOnResult)) {
|
|
3054
|
+
// Nested embed dialog: restore the parent dialog form.
|
|
3055
|
+
if (this.dialogStack.length > 0) {
|
|
3056
|
+
if (flash) showToast(flash);
|
|
3057
|
+
await this.restoreDialog(this.dialogStack.pop());
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
// Top-level embed: close and refresh the page that opened the dialog.
|
|
3061
|
+
this.dialogStack = [];
|
|
3062
|
+
this.close();
|
|
3063
|
+
reloadParentView({ success: success || undefined, error: error || undefined });
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
if (error) {
|
|
3068
|
+
if (flash) showToast(flash);
|
|
3069
|
+
this.loading = true;
|
|
3070
|
+
try {
|
|
3071
|
+
const res = await fetch(url.pathname + url.search, {
|
|
3072
|
+
headers: csrfHeaders({ Accept: 'text/html' }),
|
|
3073
|
+
credentials: 'same-origin',
|
|
3074
|
+
});
|
|
3075
|
+
await this.mountBodyWhenReady(await res.text());
|
|
3076
|
+
} finally {
|
|
3077
|
+
this.loading = false;
|
|
3078
|
+
}
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// Non-embed success while a dialog is open.
|
|
3084
|
+
if (this.open && (success === 'updated' || success === 'created') && !_dialogOnResult) {
|
|
3085
|
+
this.dialogStack = [];
|
|
3086
|
+
this.close();
|
|
3087
|
+
reloadParentView({ success: success || undefined, error: error || undefined });
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
if (this.dialogStack.length > 0) {
|
|
3092
|
+
await this.restoreDialog(this.dialogStack.pop());
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
this.close();
|
|
3097
|
+
reloadParentView({ success: success || undefined, error: error || undefined });
|
|
3098
|
+
},
|
|
3099
|
+
|
|
3100
|
+
submitConfirm() {
|
|
3101
|
+
if (this._confirmCallback) {
|
|
3102
|
+
const callback = this._confirmCallback;
|
|
3103
|
+
this._confirmCallback = null;
|
|
3104
|
+
this.close();
|
|
3105
|
+
callback();
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
if (this.promptMode === 'alert' || !this.confirmAction) {
|
|
3109
|
+
this.close();
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
const form = document.createElement('form');
|
|
3113
|
+
form.method = 'post';
|
|
3114
|
+
form.action = this.confirmAction;
|
|
3115
|
+
const token = csrfToken();
|
|
3116
|
+
if (token) {
|
|
3117
|
+
const input = document.createElement('input');
|
|
3118
|
+
input.type = 'hidden';
|
|
3119
|
+
input.name = '_csrf';
|
|
3120
|
+
input.value = token;
|
|
3121
|
+
form.appendChild(input);
|
|
3122
|
+
}
|
|
3123
|
+
document.body.appendChild(form);
|
|
3124
|
+
form.submit();
|
|
3125
|
+
},
|
|
3126
|
+
}));
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
3130
|
+
if (!consumeInitialFlash()) {
|
|
3131
|
+
consumeQueryFlash();
|
|
3132
|
+
}
|
|
3133
|
+
maybeRedirectToStoredListView();
|
|
3134
|
+
applyListHrefs();
|
|
3135
|
+
bindListViewSwitcher();
|
|
3136
|
+
bindListRefresh();
|
|
3137
|
+
bindMediaUploads();
|
|
3138
|
+
bindFormSaveShortcut();
|
|
3139
|
+
bindFormAutosave();
|
|
3140
|
+
bindRecordPagerNav();
|
|
3141
|
+
});
|
|
3142
|
+
|
|
3143
|
+
function relationConfigsFromForm(form) {
|
|
3144
|
+
const configs = [];
|
|
3145
|
+
if (!(form instanceof HTMLFormElement)) return configs;
|
|
3146
|
+
form.querySelectorAll('[data-shamar-m2o-config],[data-shamar-m2m-config]').forEach((el) => {
|
|
3147
|
+
const raw =
|
|
3148
|
+
el.getAttribute('data-shamar-m2o-config') ||
|
|
3149
|
+
el.getAttribute('data-shamar-m2m-config');
|
|
3150
|
+
if (!raw) return;
|
|
3151
|
+
try {
|
|
3152
|
+
const parsed = JSON.parse(raw);
|
|
3153
|
+
if (parsed?.name) configs.push(parsed);
|
|
3154
|
+
} catch {
|
|
3155
|
+
/* ignore */
|
|
3156
|
+
}
|
|
3157
|
+
});
|
|
3158
|
+
return configs;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function notifyRelationFieldChange(componentEl, fieldName) {
|
|
3162
|
+
const form = componentEl?.closest?.('form');
|
|
3163
|
+
if (!form) return;
|
|
3164
|
+
queueMicrotask(() => {
|
|
3165
|
+
try {
|
|
3166
|
+
const alpine = window.Alpine?.$data?.(form);
|
|
3167
|
+
if (typeof alpine?.onFieldChange === 'function') {
|
|
3168
|
+
alpine.onFieldChange(fieldName);
|
|
3169
|
+
}
|
|
3170
|
+
} catch {
|
|
3171
|
+
/* ignore */
|
|
3172
|
+
}
|
|
3173
|
+
const hidden =
|
|
3174
|
+
componentEl.querySelector(`input[type="hidden"][name="${fieldName}"]`) ||
|
|
3175
|
+
componentEl.querySelector(`input[type="hidden"][name="${fieldName}[]"]`);
|
|
3176
|
+
if (hidden) {
|
|
3177
|
+
hidden.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3178
|
+
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
form.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3182
|
+
form.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
function syncRelationValuesToFormData(form, data) {
|
|
3187
|
+
const alpine = window.Alpine;
|
|
3188
|
+
if (!alpine?.$data) return;
|
|
3189
|
+
|
|
3190
|
+
form.querySelectorAll('[data-shamar-m2o-config]').forEach((el) => {
|
|
3191
|
+
let cfg;
|
|
3192
|
+
try {
|
|
3193
|
+
cfg = JSON.parse(el.getAttribute('data-shamar-m2o-config') || '{}');
|
|
3194
|
+
} catch {
|
|
3195
|
+
return;
|
|
3196
|
+
}
|
|
3197
|
+
if (!cfg.name) return;
|
|
3198
|
+
const component = alpine.$data(el);
|
|
3199
|
+
const value =
|
|
3200
|
+
component?.value != null && component.value !== ''
|
|
3201
|
+
? String(component.value)
|
|
3202
|
+
: '';
|
|
3203
|
+
data.set(cfg.name, value);
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
form.querySelectorAll('[data-shamar-m2m-config]').forEach((el) => {
|
|
3207
|
+
let cfg;
|
|
3208
|
+
try {
|
|
3209
|
+
cfg = JSON.parse(el.getAttribute('data-shamar-m2m-config') || '{}');
|
|
3210
|
+
} catch {
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
if (!cfg.name || cfg.kind === 'hasMany') return;
|
|
3214
|
+
const component = alpine.$data(el);
|
|
3215
|
+
const selected = Array.isArray(component?.selected) ? component.selected : [];
|
|
3216
|
+
data.delete(cfg.name);
|
|
3217
|
+
data.delete(`${cfg.name}[]`);
|
|
3218
|
+
if (selected.length === 0) {
|
|
3219
|
+
if (cfg.kind === 'manyToMany') {
|
|
3220
|
+
data.append(`${cfg.name}[]`, '');
|
|
3221
|
+
}
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
for (const item of selected) {
|
|
3225
|
+
const valueAttr = cfg.valueAttribute ? String(cfg.valueAttribute) : 'id';
|
|
3226
|
+
let value = '';
|
|
3227
|
+
if (valueAttr === 'name') {
|
|
3228
|
+
value = String(item.name || item.id || '').trim();
|
|
3229
|
+
} else {
|
|
3230
|
+
value = item?.id != null ? String(item.id).trim() : '';
|
|
3231
|
+
}
|
|
3232
|
+
if (value) {
|
|
3233
|
+
data.append(`${cfg.name}[]`, value);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
function formDataFrom(form) {
|
|
3240
|
+
const data = new FormData(form);
|
|
3241
|
+
const relationConfigs = relationConfigsFromForm(form);
|
|
3242
|
+
const relationFields = new Set(relationConfigs.map((cfg) => cfg.name));
|
|
3243
|
+
try {
|
|
3244
|
+
const alpine = window.Alpine?.$data?.(form);
|
|
3245
|
+
const state = alpine?.state;
|
|
3246
|
+
if (state && typeof state === 'object') {
|
|
3247
|
+
// Prefer Alpine state so `.disabled()` fields (excluded from FormData) still save.
|
|
3248
|
+
// Relation widgets manage their own hidden inputs — do not clobber them with stale state.
|
|
3249
|
+
for (const [key, value] of Object.entries(state)) {
|
|
3250
|
+
if (value === undefined || relationFields.has(key)) continue;
|
|
3251
|
+
if (typeof value === 'boolean') {
|
|
3252
|
+
if (value) data.set(key, 'true');
|
|
3253
|
+
else data.delete(key);
|
|
3254
|
+
continue;
|
|
3255
|
+
}
|
|
3256
|
+
if (Array.isArray(value)) {
|
|
3257
|
+
data.delete(key);
|
|
3258
|
+
data.delete(`${key}[]`);
|
|
3259
|
+
for (const item of value) {
|
|
3260
|
+
data.append(`${key}[]`, item == null ? '' : String(item));
|
|
3261
|
+
}
|
|
3262
|
+
continue;
|
|
3263
|
+
}
|
|
3264
|
+
data.set(key, value == null ? '' : String(value));
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
} catch {
|
|
3268
|
+
/* ignore */
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
syncRelationValuesToFormData(form, data);
|
|
3272
|
+
|
|
3273
|
+
return data;
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
function bindFormSaveShortcut() {
|
|
3277
|
+
document.addEventListener('keydown', (event) => {
|
|
3278
|
+
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
|
|
3279
|
+
const target = event.target;
|
|
3280
|
+
if (
|
|
3281
|
+
target instanceof Element &&
|
|
3282
|
+
(target.closest('[contenteditable="true"]') ||
|
|
3283
|
+
target.closest('.cm-editor, .monaco-editor'))
|
|
3284
|
+
) {
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
const form = document.querySelector('#shamar-form');
|
|
3288
|
+
if (!form) return;
|
|
3289
|
+
event.preventDefault();
|
|
3290
|
+
if (typeof window.shamarSave === 'function') {
|
|
3291
|
+
window.shamarSave();
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
const btn =
|
|
3295
|
+
document.querySelector('[data-shamar-form-save]:not([disabled])') ||
|
|
3296
|
+
form.querySelector('button[type="submit"]:not([disabled])');
|
|
3297
|
+
if (btn) btn.click();
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
function bindFormAutosave() {
|
|
3302
|
+
function getForm() {
|
|
3303
|
+
return document.querySelector('#shamar-form[data-shamar-autosave]');
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
let allowUnload = false;
|
|
3307
|
+
let savingInPlace = false;
|
|
3308
|
+
let savingForHref = null;
|
|
3309
|
+
|
|
3310
|
+
function takeSnapshot(form) {
|
|
3311
|
+
try {
|
|
3312
|
+
form.dataset.shamarSnapshot = JSON.stringify([...formDataFrom(form)]);
|
|
3313
|
+
} catch {
|
|
3314
|
+
form.dataset.shamarSnapshot = '';
|
|
3315
|
+
}
|
|
3316
|
+
form.removeAttribute('data-shamar-dirty');
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
function isDirty(form) {
|
|
3320
|
+
if (!form) return false;
|
|
3321
|
+
let now = '';
|
|
3322
|
+
try {
|
|
3323
|
+
now = JSON.stringify([...formDataFrom(form)]);
|
|
3324
|
+
} catch {
|
|
3325
|
+
/* ignore */
|
|
3326
|
+
}
|
|
3327
|
+
return now !== (form.dataset.shamarSnapshot || '');
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
function markDirtyFromEvent(event) {
|
|
3331
|
+
const form = getForm();
|
|
3332
|
+
if (!form || !form.contains(event.target)) return;
|
|
3333
|
+
if (isDirty(form)) form.setAttribute('data-shamar-dirty', 'true');
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
document.addEventListener('input', markDirtyFromEvent);
|
|
3337
|
+
document.addEventListener('change', markDirtyFromEvent);
|
|
3338
|
+
|
|
3339
|
+
const initial = getForm();
|
|
3340
|
+
if (initial) {
|
|
3341
|
+
// Alpine may hydrate checkbox/x-model after DOMContentLoaded.
|
|
3342
|
+
window.setTimeout(() => takeSnapshot(initial), 0);
|
|
3343
|
+
window.setTimeout(() => takeSnapshot(initial), 50);
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
function editUrlAfterCreate(form, record) {
|
|
3347
|
+
const base =
|
|
3348
|
+
form.getAttribute('data-shamar-after-create') ||
|
|
3349
|
+
form.getAttribute('action') ||
|
|
3350
|
+
'';
|
|
3351
|
+
const id = record?.id ?? record?._id;
|
|
3352
|
+
if (!base || id == null) return null;
|
|
3353
|
+
const view =
|
|
3354
|
+
form.getAttribute('data-shamar-after-create-view') ||
|
|
3355
|
+
(record?.plainText ? 'show' : 'edit');
|
|
3356
|
+
const suffix = view === 'show' ? '' : '/edit';
|
|
3357
|
+
return `${base.replace(/\/$/, '')}/${id}${suffix}`;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
async function saveFormInPlace(form, { silent = false } = {}) {
|
|
3361
|
+
const url =
|
|
3362
|
+
form.getAttribute('data-shamar-autosave') ||
|
|
3363
|
+
form.getAttribute('action');
|
|
3364
|
+
if (!url) return false;
|
|
3365
|
+
const res = await fetch(url, {
|
|
3366
|
+
method: 'POST',
|
|
3367
|
+
body: formDataFrom(form),
|
|
3368
|
+
credentials: 'same-origin',
|
|
3369
|
+
headers: csrfHeaders({
|
|
3370
|
+
Accept: 'application/json',
|
|
3371
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
3372
|
+
}),
|
|
3373
|
+
});
|
|
3374
|
+
if (res.status === 422) {
|
|
3375
|
+
let payload = null;
|
|
3376
|
+
try {
|
|
3377
|
+
payload = await res.json();
|
|
3378
|
+
} catch {
|
|
3379
|
+
/* ignore */
|
|
3380
|
+
}
|
|
3381
|
+
const messages = applyFieldErrors(form, payload?.errors);
|
|
3382
|
+
if (!silent) {
|
|
3383
|
+
showToast({
|
|
3384
|
+
type: 'error',
|
|
3385
|
+
title: 'Save failed',
|
|
3386
|
+
message: validationToastMessage(messages, payload?.message),
|
|
3387
|
+
});
|
|
3388
|
+
}
|
|
3389
|
+
return false;
|
|
3390
|
+
}
|
|
3391
|
+
if (!res.ok) {
|
|
3392
|
+
let payload = null;
|
|
3393
|
+
try {
|
|
3394
|
+
payload = await res.json();
|
|
3395
|
+
} catch {
|
|
3396
|
+
/* ignore */
|
|
3397
|
+
}
|
|
3398
|
+
if (payload?.errors) {
|
|
3399
|
+
const messages = applyFieldErrors(form, payload.errors);
|
|
3400
|
+
if (!silent) {
|
|
3401
|
+
showToast({
|
|
3402
|
+
type: 'error',
|
|
3403
|
+
title: 'Save failed',
|
|
3404
|
+
message: validationToastMessage(messages, payload?.message),
|
|
3405
|
+
});
|
|
3406
|
+
}
|
|
3407
|
+
return false;
|
|
3408
|
+
}
|
|
3409
|
+
if (!silent) {
|
|
3410
|
+
showToast({
|
|
3411
|
+
type: 'error',
|
|
3412
|
+
title: 'Save failed',
|
|
3413
|
+
message: validationToastMessage([], payload?.message),
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
return false;
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
let record = null;
|
|
3420
|
+
try {
|
|
3421
|
+
record = await res.json();
|
|
3422
|
+
} catch {
|
|
3423
|
+
/* ignore non-JSON success */
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
clearFieldErrors(form);
|
|
3427
|
+
takeSnapshot(form);
|
|
3428
|
+
|
|
3429
|
+
const mode = form.getAttribute('data-shamar-save-mode') || 'edit';
|
|
3430
|
+
if (mode === 'create') {
|
|
3431
|
+
const next = editUrlAfterCreate(form, record);
|
|
3432
|
+
if (next) {
|
|
3433
|
+
const go = () => {
|
|
3434
|
+
allowUnload = true;
|
|
3435
|
+
window.location.href = next;
|
|
3436
|
+
};
|
|
3437
|
+
if (record?.plainText) {
|
|
3438
|
+
revealOneTimeSecret({
|
|
3439
|
+
secret: record.plainText,
|
|
3440
|
+
title: 'Copy your API key',
|
|
3441
|
+
message:
|
|
3442
|
+
'This secret will only be shown once. Copy it now and store it somewhere safe before continuing.',
|
|
3443
|
+
onConfirm: go,
|
|
3444
|
+
});
|
|
3445
|
+
return true;
|
|
3446
|
+
}
|
|
3447
|
+
go();
|
|
3448
|
+
return true;
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
if (!silent) {
|
|
3453
|
+
showToast({
|
|
3454
|
+
type: 'success',
|
|
3455
|
+
title: 'Saved',
|
|
3456
|
+
message: 'Your changes have been saved.',
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
return true;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
async function runSave() {
|
|
3463
|
+
const form = getForm() || document.querySelector('#shamar-form');
|
|
3464
|
+
if (!form || savingInPlace) return false;
|
|
3465
|
+
if (!form.hasAttribute('data-shamar-autosave') && !form.getAttribute('action')) {
|
|
3466
|
+
return false;
|
|
3467
|
+
}
|
|
3468
|
+
savingInPlace = true;
|
|
3469
|
+
const btn = document.querySelector('[data-shamar-form-save]');
|
|
3470
|
+
if (btn instanceof HTMLButtonElement) btn.disabled = true;
|
|
3471
|
+
try {
|
|
3472
|
+
return await saveFormInPlace(form);
|
|
3473
|
+
} catch (error) {
|
|
3474
|
+
console.error('shamar save failed', error);
|
|
3475
|
+
showToast({
|
|
3476
|
+
type: 'error',
|
|
3477
|
+
title: 'Save failed',
|
|
3478
|
+
message: 'Could not save your changes.',
|
|
3479
|
+
});
|
|
3480
|
+
return false;
|
|
3481
|
+
} finally {
|
|
3482
|
+
savingInPlace = false;
|
|
3483
|
+
if (btn instanceof HTMLButtonElement) btn.disabled = false;
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
window.shamarSave = runSave;
|
|
3488
|
+
|
|
3489
|
+
document.addEventListener(
|
|
3490
|
+
'click',
|
|
3491
|
+
(event) => {
|
|
3492
|
+
const btn = event.target?.closest?.('[data-shamar-form-save]');
|
|
3493
|
+
if (!btn) return;
|
|
3494
|
+
// Embed dialog keep native submit unless autosave is present.
|
|
3495
|
+
const form = document.querySelector('#shamar-form');
|
|
3496
|
+
if (!form?.hasAttribute('data-shamar-autosave')) return;
|
|
3497
|
+
event.preventDefault();
|
|
3498
|
+
runSave();
|
|
3499
|
+
},
|
|
3500
|
+
true,
|
|
3501
|
+
);
|
|
3502
|
+
|
|
3503
|
+
document.addEventListener(
|
|
3504
|
+
'submit',
|
|
3505
|
+
(event) => {
|
|
3506
|
+
const form = event.target;
|
|
3507
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
3508
|
+
if (form.id !== 'shamar-form' || !form.hasAttribute('data-shamar-autosave')) return;
|
|
3509
|
+
event.preventDefault();
|
|
3510
|
+
runSave();
|
|
3511
|
+
},
|
|
3512
|
+
true,
|
|
3513
|
+
);
|
|
3514
|
+
|
|
3515
|
+
async function autosaveThenNavigate(href) {
|
|
3516
|
+
const form = getForm();
|
|
3517
|
+
if (!form || !isDirty(form)) {
|
|
3518
|
+
allowUnload = true;
|
|
3519
|
+
window.location.href = href;
|
|
3520
|
+
return;
|
|
3521
|
+
}
|
|
3522
|
+
try {
|
|
3523
|
+
const ok = await saveFormInPlace(form, { silent: true });
|
|
3524
|
+
if (!ok) return;
|
|
3525
|
+
allowUnload = true;
|
|
3526
|
+
window.location.href = href;
|
|
3527
|
+
} catch (error) {
|
|
3528
|
+
console.error('shamar autosave failed', error);
|
|
3529
|
+
allowUnload = true;
|
|
3530
|
+
window.location.href = href;
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
window.shamarNavigate = function shamarNavigate(href) {
|
|
3535
|
+
if (savingForHref) return;
|
|
3536
|
+
savingForHref = href;
|
|
3537
|
+
autosaveThenNavigate(href).finally(() => {
|
|
3538
|
+
savingForHref = null;
|
|
3539
|
+
});
|
|
3540
|
+
};
|
|
3541
|
+
|
|
3542
|
+
/** Allow the next navigation without a dirty-form beforeunload prompt (e.g. Cancel). */
|
|
3543
|
+
window.shamarAllowUnload = function shamarAllowUnload() {
|
|
3544
|
+
allowUnload = true;
|
|
3545
|
+
};
|
|
3546
|
+
|
|
3547
|
+
document.addEventListener(
|
|
3548
|
+
'click',
|
|
3549
|
+
(event) => {
|
|
3550
|
+
if (savingForHref) return;
|
|
3551
|
+
const anchor = event.target?.closest?.('a[href]');
|
|
3552
|
+
if (!anchor) return;
|
|
3553
|
+
if (anchor.hasAttribute('data-shamar-skip-autosave')) {
|
|
3554
|
+
// Intentional discard / leave without saving — don't trap in beforeunload.
|
|
3555
|
+
allowUnload = true;
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
const href = anchor.getAttribute('href');
|
|
3559
|
+
if (
|
|
3560
|
+
!href ||
|
|
3561
|
+
href.startsWith('#') ||
|
|
3562
|
+
href.startsWith('javascript:') ||
|
|
3563
|
+
href.startsWith('mailto:') ||
|
|
3564
|
+
href.startsWith('tel:')
|
|
3565
|
+
) {
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
if (anchor.target && anchor.target !== '_self') return;
|
|
3569
|
+
if (event.defaultPrevented) return;
|
|
3570
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
|
|
3571
|
+
return;
|
|
3572
|
+
}
|
|
3573
|
+
const form = getForm();
|
|
3574
|
+
if (!form || !isDirty(form)) return;
|
|
3575
|
+
event.preventDefault();
|
|
3576
|
+
window.shamarNavigate(href);
|
|
3577
|
+
},
|
|
3578
|
+
true,
|
|
3579
|
+
);
|
|
3580
|
+
|
|
3581
|
+
window.addEventListener('beforeunload', (event) => {
|
|
3582
|
+
if (allowUnload) return;
|
|
3583
|
+
const form = getForm();
|
|
3584
|
+
if (form && isDirty(form)) {
|
|
3585
|
+
event.preventDefault();
|
|
3586
|
+
event.returnValue = '';
|
|
3587
|
+
}
|
|
3588
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
function bindRecordPagerNav() {
|
|
3592
|
+
function pagerRoot() {
|
|
3593
|
+
return document.querySelector('[data-shamar-record-pager]');
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
let wheelLockUntil = 0;
|
|
3597
|
+
|
|
3598
|
+
function go(direction) {
|
|
3599
|
+
const root = pagerRoot();
|
|
3600
|
+
if (!root) return;
|
|
3601
|
+
const href =
|
|
3602
|
+
direction < 0
|
|
3603
|
+
? root.getAttribute('data-shamar-pager-prev')
|
|
3604
|
+
: root.getAttribute('data-shamar-pager-next');
|
|
3605
|
+
if (!href) return;
|
|
3606
|
+
if (typeof window.shamarNavigate === 'function') {
|
|
3607
|
+
window.shamarNavigate(href);
|
|
3608
|
+
} else {
|
|
3609
|
+
window.location.href = href;
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
document.addEventListener('keydown', (event) => {
|
|
3614
|
+
if (!pagerRoot()) return;
|
|
3615
|
+
if (!event.altKey) return;
|
|
3616
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
|
3617
|
+
const target = event.target;
|
|
3618
|
+
if (
|
|
3619
|
+
target instanceof HTMLElement &&
|
|
3620
|
+
(target.closest('input, textarea, select, [contenteditable="true"]') ||
|
|
3621
|
+
target.isContentEditable)
|
|
3622
|
+
) {
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
event.preventDefault();
|
|
3626
|
+
go(event.key === 'ArrowLeft' ? -1 : 1);
|
|
3627
|
+
});
|
|
3628
|
+
|
|
3629
|
+
// Rotational scroll: Alt+wheel anywhere, or wheel directly over the pager.
|
|
3630
|
+
document.addEventListener(
|
|
3631
|
+
'wheel',
|
|
3632
|
+
(event) => {
|
|
3633
|
+
const root = pagerRoot();
|
|
3634
|
+
if (!root) return;
|
|
3635
|
+
const overPager =
|
|
3636
|
+
event.target instanceof Element && event.target.closest('[data-shamar-record-pager]');
|
|
3637
|
+
if (!event.altKey && !overPager) return;
|
|
3638
|
+
const target = event.target;
|
|
3639
|
+
if (
|
|
3640
|
+
target instanceof HTMLElement &&
|
|
3641
|
+
target.closest('input, textarea, select, [contenteditable="true"]')
|
|
3642
|
+
) {
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
if (Math.abs(event.deltaY) < 8) return;
|
|
3646
|
+
const now = Date.now();
|
|
3647
|
+
if (now < wheelLockUntil) {
|
|
3648
|
+
event.preventDefault();
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
event.preventDefault();
|
|
3652
|
+
wheelLockUntil = now + 450;
|
|
3653
|
+
go(event.deltaY > 0 ? 1 : -1);
|
|
3654
|
+
},
|
|
3655
|
+
{ passive: false },
|
|
3656
|
+
);
|
|
3657
|
+
}
|
|
3658
|
+
})();
|