style(haproxy): Migrate vhosts.js to KISS theme

Rewrote HAProxy Virtual Hosts dashboard to use KissTheme:
- Self-contained inline CSS using KISS variables
- Removed external dashboard.css dependency
- Add vhost form with domain/backend/SSL inputs
- Vhosts table with status badges and actions
- Edit modal and delete confirmation dialogs
- Toast notifications for user feedback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-12 11:35:11 +01:00
parent c69ae43961
commit d5f7da4774

View File

@ -6,7 +6,7 @@
'require secubox/kiss-theme'; 'require secubox/kiss-theme';
/** /**
* HAProxy Virtual Hosts Management * HAProxy Virtual Hosts Management - KISS Style
* Copyright (C) 2025 CyberMind.fr * Copyright (C) 2025 CyberMind.fr
*/ */
@ -14,12 +14,6 @@ return view.extend({
title: _('Virtual Hosts'), title: _('Virtual Hosts'),
load: function() { load: function() {
// Load CSS
var cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = L.resource('haproxy/dashboard.css');
document.head.appendChild(cssLink);
return Promise.all([ return Promise.all([
api.listVhosts(), api.listVhosts(),
api.listBackends() api.listBackends()
@ -30,150 +24,133 @@ return view.extend({
var self = this; var self = this;
var vhosts = (data[0] && data[0].vhosts) || data[0] || []; var vhosts = (data[0] && data[0].vhosts) || data[0] || [];
var backends = (data[1] && data[1].backends) || data[1] || []; var backends = (data[1] && data[1].backends) || data[1] || [];
var K = KissTheme;
var content = E('div', { 'class': 'haproxy-dashboard' }, [ var content = K.E('div', {}, [
// Page Header // Page Header
E('div', { 'class': 'hp-page-header' }, [ K.E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;' }, [
E('div', {}, [ K.E('div', {}, [
E('h1', { 'class': 'hp-page-title' }, [ K.E('h2', { 'style': 'margin: 0; font-size: 24px; display: flex; align-items: center; gap: 10px;' }, [
E('span', { 'class': 'hp-page-title-icon' }, '\u{1F310}'), K.E('span', {}, '🌐'),
'Virtual Hosts' 'Virtual Hosts'
]), ]),
E('p', { 'class': 'hp-page-subtitle' }, 'Configure domain-based routing to backend servers') K.E('p', { 'style': 'margin: 4px 0 0; color: var(--kiss-muted, #94a3b8); font-size: 14px;' },
]), 'Configure domain-based routing to backend servers')
E('a', {
'href': L.url('admin/services/haproxy/overview'),
'class': 'hp-btn hp-btn-secondary'
}, ['\u2190', ' Back to Overview'])
]),
// Add Virtual Host Card
E('div', { 'class': 'hp-card' }, [
E('div', { 'class': 'hp-card-header' }, [
E('div', { 'class': 'hp-card-title' }, [
E('span', { 'class': 'hp-card-title-icon' }, '\u2795'),
'Add Virtual Host'
])
]),
E('div', { 'class': 'hp-card-body' }, [
E('div', { 'class': 'hp-grid hp-grid-2', 'style': 'gap: 16px;' }, [
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Domain'),
E('input', {
'type': 'text',
'id': 'new-domain',
'class': 'hp-form-input',
'placeholder': 'example.com or *.example.com'
})
]),
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Backend'),
E('select', { 'id': 'new-backend', 'class': 'hp-form-input' },
[E('option', { 'value': '' }, '-- Select Backend --')].concat(
backends.map(function(b) {
return E('option', { 'value': b.id || b.name }, b.name);
})
)
)
])
]),
E('div', { 'style': 'display: flex; gap: 24px; flex-wrap: wrap; margin: 16px 0;' }, [
E('label', { 'class': 'hp-form-checkbox' }, [
E('input', { 'type': 'checkbox', 'id': 'new-ssl', 'checked': true }),
E('span', {}, 'Enable SSL/TLS')
]),
E('label', { 'class': 'hp-form-checkbox' }, [
E('input', { 'type': 'checkbox', 'id': 'new-ssl-redirect', 'checked': true }),
E('span', {}, 'Force HTTPS redirect')
]),
E('label', { 'class': 'hp-form-checkbox' }, [
E('input', { 'type': 'checkbox', 'id': 'new-acme', 'checked': true }),
E('span', {}, 'Auto-renew with ACME (Let\'s Encrypt)')
])
]),
E('button', {
'class': 'hp-btn hp-btn-primary',
'click': function() { self.handleAddVhost(backends); }
}, ['\u2795', ' Add Virtual Host'])
]) ])
]), ]),
// Virtual Hosts List // Add Virtual Host Card
E('div', { 'class': 'hp-card' }, [ K.E('div', { 'class': 'kiss-card' }, [
E('div', { 'class': 'hp-card-header' }, [ K.E('div', { 'class': 'kiss-card-title' }, [' ', 'Add Virtual Host']),
E('div', { 'class': 'hp-card-title' }, [ K.E('div', { 'class': 'kiss-grid kiss-grid-2', 'style': 'gap: 16px; margin-bottom: 16px;' }, [
E('span', { 'class': 'hp-card-title-icon' }, '\u{1F4CB}'), K.E('div', {}, [
'Configured Virtual Hosts (' + vhosts.length + ')' K.E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted); text-transform: uppercase; display: block; margin-bottom: 6px;' }, 'Domain'),
K.E('input', {
'type': 'text',
'id': 'new-domain',
'placeholder': 'example.com or *.example.com',
'style': 'width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--kiss-line, #1e293b); background: var(--kiss-bg2, #111827); color: var(--kiss-text, #e2e8f0); font-size: 14px;'
})
]),
K.E('div', {}, [
K.E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted); text-transform: uppercase; display: block; margin-bottom: 6px;' }, 'Backend'),
K.E('select', {
'id': 'new-backend',
'style': 'width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--kiss-line, #1e293b); background: var(--kiss-bg2, #111827); color: var(--kiss-text, #e2e8f0); font-size: 14px;'
}, [K.E('option', { 'value': '' }, '-- Select Backend --')].concat(
backends.map(function(b) {
return K.E('option', { 'value': b.id || b.name }, b.name);
})
))
]) ])
]), ]),
E('div', { 'class': 'hp-card-body no-padding' }, K.E('div', { 'style': 'display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 16px;' }, [
vhosts.length === 0 ? [ K.E('label', { 'style': 'display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px;' }, [
E('div', { 'class': 'hp-empty' }, [ K.E('input', { 'type': 'checkbox', 'id': 'new-ssl', 'checked': true }),
E('div', { 'class': 'hp-empty-icon' }, '\u{1F310}'), '🔐 Enable SSL/TLS'
E('div', { 'class': 'hp-empty-text' }, 'No virtual hosts configured'), ]),
E('div', { 'class': 'hp-empty-hint' }, 'Add a virtual host above to start routing traffic') K.E('label', { 'style': 'display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px;' }, [
]) K.E('input', { 'type': 'checkbox', 'id': 'new-ssl-redirect', 'checked': true }),
] : [ '↗️ Force HTTPS'
this.renderVhostsTable(vhosts, backends) ]),
] K.E('label', { 'style': 'display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px;' }, [
) K.E('input', { 'type': 'checkbox', 'id': 'new-acme', 'checked': true }),
'🔄 Auto-renew (ACME)'
])
]),
K.E('button', {
'class': 'kiss-btn kiss-btn-green',
'onClick': function() { self.handleAddVhost(backends); }
}, ' Add Virtual Host')
]),
// Virtual Hosts List
K.E('div', { 'class': 'kiss-card' }, [
K.E('div', { 'class': 'kiss-card-title' }, ['📋 ', 'Configured Virtual Hosts (', String(vhosts.length), ')']),
vhosts.length === 0 ?
K.E('div', { 'style': 'text-align: center; padding: 40px 20px; color: var(--kiss-muted);' }, [
K.E('div', { 'style': 'font-size: 48px; margin-bottom: 12px;' }, '🌐'),
K.E('div', { 'style': 'font-size: 16px;' }, 'No virtual hosts configured'),
K.E('div', { 'style': 'font-size: 13px; margin-top: 6px;' }, 'Add a virtual host above to start routing traffic')
]) :
this.renderVhostsTable(vhosts, backends)
]) ])
]); ]);
return KissTheme.wrap(content, 'admin/services/haproxy'); return KissTheme.wrap(content, 'admin/services/haproxy/vhosts');
}, },
renderVhostsTable: function(vhosts, backends) { renderVhostsTable: function(vhosts, backends) {
var self = this; var self = this;
var K = KissTheme;
var backendMap = {}; var backendMap = {};
backends.forEach(function(b) { backends.forEach(function(b) {
backendMap[b.id || b.name] = b.name; backendMap[b.id || b.name] = b.name;
}); });
return E('table', { 'class': 'hp-table' }, [ return K.E('table', { 'class': 'kiss-table' }, [
E('thead', {}, [ K.E('thead', {}, [
E('tr', {}, [ K.E('tr', {}, [
E('th', {}, 'Domain'), K.E('th', {}, 'Domain'),
E('th', {}, 'Backend'), K.E('th', {}, 'Backend'),
E('th', {}, 'SSL Configuration'), K.E('th', {}, 'SSL'),
E('th', {}, 'Status'), K.E('th', {}, 'Status'),
E('th', { 'style': 'width: 220px; text-align: right;' }, 'Actions') K.E('th', { 'style': 'text-align: right;' }, 'Actions')
]) ])
]), ]),
E('tbody', {}, vhosts.map(function(vh) { K.E('tbody', {}, vhosts.map(function(vh) {
return E('tr', { 'data-id': vh.id }, [ return K.E('tr', { 'data-id': vh.id }, [
E('td', {}, [ K.E('td', {}, [
E('div', { 'style': 'font-weight: 600;' }, vh.domain), K.E('div', { 'style': 'font-weight: 600; font-family: monospace;' }, vh.domain),
vh.ssl_redirect ? E('small', { 'style': 'color: var(--hp-text-muted); font-size: 12px;' }, vh.ssl_redirect ? K.E('small', { 'style': 'color: var(--kiss-muted); font-size: 11px;' },
'\u{1F512} Redirects HTTP \u2192 HTTPS') : null '🔒 HTTP → HTTPS') : null
]), ]),
E('td', {}, [ K.E('td', {}, [
E('span', { 'class': 'hp-mono' }, backendMap[vh.backend] || vh.backend || '-') K.E('span', { 'style': 'font-family: monospace; font-size: 13px;' }, backendMap[vh.backend] || vh.backend || '-')
]), ]),
E('td', {}, [ K.E('td', {}, [
vh.ssl ? E('span', { 'class': 'hp-badge hp-badge-info', 'style': 'margin-right: 6px;' }, '\u{1F512} SSL') : null, vh.ssl ? K.badge('🔐 SSL', 'blue') : null,
vh.acme ? E('span', { 'class': 'hp-badge hp-badge-success' }, '\u{1F504} ACME') : null, vh.ssl && vh.acme ? K.E('span', { 'style': 'margin-left: 6px;' }, K.badge('🔄 ACME', 'green')) : null,
!vh.ssl && !vh.acme ? E('span', { 'class': 'hp-badge hp-badge-warning' }, 'No SSL') : null !vh.ssl ? K.badge('No SSL', 'yellow') : null
].filter(function(e) { return e !== null; })), ]),
E('td', {}, E('span', { K.E('td', {}, K.badge(vh.enabled ? '✅ Active' : '⛔ Disabled', vh.enabled ? 'green' : 'red')),
'class': 'hp-badge ' + (vh.enabled ? 'hp-badge-success' : 'hp-badge-danger') K.E('td', { 'style': 'text-align: right;' }, [
}, vh.enabled ? '\u2705 Active' : '\u26D4 Disabled')), K.E('button', {
E('td', { 'style': 'text-align: right;' }, [ 'class': 'kiss-btn',
E('button', { 'style': 'padding: 6px 12px; font-size: 12px; margin-right: 6px;',
'class': 'hp-btn hp-btn-sm hp-btn-primary', 'onClick': function() { self.showEditVhostModal(vh, backends); }
'style': 'margin-right: 8px;', }, '✏️ Edit'),
'click': function() { self.showEditVhostModal(vh, backends); } K.E('button', {
}, '\u270F Edit'), 'class': 'kiss-btn ' + (vh.enabled ? '' : 'kiss-btn-green'),
E('button', { 'style': 'padding: 6px 12px; font-size: 12px; margin-right: 6px;',
'class': 'hp-btn hp-btn-sm ' + (vh.enabled ? 'hp-btn-secondary' : 'hp-btn-success'), 'onClick': function() { self.handleToggleVhost(vh); }
'style': 'margin-right: 8px;', }, vh.enabled ? '⏸️' : '▶️'),
'click': function() { self.handleToggleVhost(vh); } K.E('button', {
}, vh.enabled ? 'Disable' : 'Enable'), 'class': 'kiss-btn kiss-btn-red',
E('button', { 'style': 'padding: 6px 12px; font-size: 12px;',
'class': 'hp-btn hp-btn-sm hp-btn-danger', 'onClick': function() { self.handleDeleteVhost(vh); }
'click': function() { self.handleDeleteVhost(vh); } }, '🗑️')
}, 'Delete')
]) ])
]); ]);
})) }))
@ -182,71 +159,61 @@ return view.extend({
showEditVhostModal: function(vh, backends) { showEditVhostModal: function(vh, backends) {
var self = this; var self = this;
var K = KissTheme;
ui.showModal('Edit Virtual Host: ' + vh.domain, [ var modalContent = K.E('div', { 'style': 'max-width: 480px;' }, [
E('div', { 'style': 'max-width: 500px;' }, [ K.E('div', { 'style': 'margin-bottom: 16px;' }, [
E('div', { 'class': 'cbi-value' }, [ K.E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted); text-transform: uppercase; display: block; margin-bottom: 6px;' }, 'Domain'),
E('label', { 'class': 'cbi-value-title' }, 'Domain'), K.E('input', {
E('div', { 'class': 'cbi-value-field' }, [ 'type': 'text',
E('input', { 'id': 'edit-domain',
'type': 'text', 'value': vh.domain,
'id': 'edit-domain', 'style': 'width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--kiss-line, #1e293b); background: var(--kiss-bg2, #111827); color: var(--kiss-text, #e2e8f0); font-size: 14px;'
'class': 'cbi-input-text', })
'value': vh.domain, ]),
'style': 'width: 100%;' K.E('div', { 'style': 'margin-bottom: 16px;' }, [
}) K.E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted); text-transform: uppercase; display: block; margin-bottom: 6px;' }, 'Backend'),
]) K.E('select', {
]), 'id': 'edit-backend',
E('div', { 'class': 'cbi-value' }, [ 'style': 'width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--kiss-line, #1e293b); background: var(--kiss-bg2, #111827); color: var(--kiss-text, #e2e8f0); font-size: 14px;'
E('label', { 'class': 'cbi-value-title' }, 'Backend'), }, [K.E('option', { 'value': '' }, '-- Select Backend --')].concat(
E('div', { 'class': 'cbi-value-field' }, [ backends.map(function(b) {
E('select', { 'id': 'edit-backend', 'class': 'cbi-input-select', 'style': 'width: 100%;' }, var selected = (vh.backend === (b.id || b.name)) ? { 'selected': true } : {};
[E('option', { 'value': '' }, '-- Select Backend --')].concat( return K.E('option', Object.assign({ 'value': b.id || b.name }, selected), b.name);
backends.map(function(b) { })
var selected = (vh.backend === (b.id || b.name)) ? { 'selected': true } : {}; ))
return E('option', Object.assign({ 'value': b.id || b.name }, selected), b.name); ]),
}) K.E('div', { 'style': 'margin-bottom: 16px;' }, [
) K.E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted); text-transform: uppercase; display: block; margin-bottom: 10px;' }, 'SSL Options'),
) K.E('div', { 'style': 'display: flex; flex-direction: column; gap: 10px;' }, [
]) K.E('label', { 'style': 'display: flex; align-items: center; gap: 8px; cursor: pointer;' }, [
]), K.E('input', { 'type': 'checkbox', 'id': 'edit-ssl', 'checked': vh.ssl }),
E('div', { 'class': 'cbi-value' }, [ '🔐 Enable SSL/TLS'
E('label', { 'class': 'cbi-value-title' }, 'SSL Options'), ]),
E('div', { 'class': 'cbi-value-field' }, [ K.E('label', { 'style': 'display: flex; align-items: center; gap: 8px; cursor: pointer;' }, [
E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [ K.E('input', { 'type': 'checkbox', 'id': 'edit-ssl-redirect', 'checked': vh.ssl_redirect }),
E('label', {}, [ '↗️ Force HTTPS redirect'
E('input', { 'type': 'checkbox', 'id': 'edit-ssl', 'checked': vh.ssl }), ]),
' Enable SSL/TLS' K.E('label', { 'style': 'display: flex; align-items: center; gap: 8px; cursor: pointer;' }, [
]), K.E('input', { 'type': 'checkbox', 'id': 'edit-acme', 'checked': vh.acme }),
E('label', {}, [ '🔄 Auto-renew with ACME'
E('input', { 'type': 'checkbox', 'id': 'edit-ssl-redirect', 'checked': vh.ssl_redirect }),
' Force HTTPS redirect'
]),
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-acme', 'checked': vh.acme }),
' Auto-renew with ACME (Let\'s Encrypt)'
])
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Status'),
E('div', { 'class': 'cbi-value-field' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-enabled', 'checked': vh.enabled }),
' Enabled'
])
]) ])
]) ])
]), ]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [ K.E('div', { 'style': 'margin-bottom: 20px;' }, [
E('button', { K.E('label', { 'style': 'display: flex; align-items: center; gap: 8px; cursor: pointer;' }, [
'class': 'hp-btn hp-btn-secondary', K.E('input', { 'type': 'checkbox', 'id': 'edit-enabled', 'checked': vh.enabled }),
'click': ui.hideModal '✅ Enabled'
])
]),
K.E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px;' }, [
K.E('button', {
'class': 'kiss-btn',
'onClick': ui.hideModal
}, 'Cancel'), }, 'Cancel'),
E('button', { K.E('button', {
'class': 'hp-btn hp-btn-primary', 'class': 'kiss-btn kiss-btn-green',
'click': function() { 'onClick': function() {
var domain = document.getElementById('edit-domain').value.trim(); var domain = document.getElementById('edit-domain').value.trim();
var backend = document.getElementById('edit-backend').value; var backend = document.getElementById('edit-backend').value;
var ssl = document.getElementById('edit-ssl').checked ? 1 : 0; var ssl = document.getElementById('edit-ssl').checked ? 1 : 0;
@ -269,9 +236,11 @@ return view.extend({
} }
}); });
} }
}, 'Save Changes') }, '💾 Save Changes')
]) ])
]); ]);
ui.showModal('Edit: ' + vh.domain, [modalContent]);
}, },
handleAddVhost: function(backends) { handleAddVhost: function(backends) {
@ -287,7 +256,6 @@ return view.extend({
return; return;
} }
// Validate domain format
if (!/^(\*\.)?[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$/.test(domain)) { if (!/^(\*\.)?[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$/.test(domain)) {
self.showToast('Invalid domain format', 'error'); self.showToast('Invalid domain format', 'error');
return; return;
@ -320,22 +288,21 @@ return view.extend({
handleDeleteVhost: function(vh) { handleDeleteVhost: function(vh) {
var self = this; var self = this;
var K = KissTheme;
ui.showModal('Delete Virtual Host', [ var modalContent = K.E('div', {}, [
E('div', { 'style': 'margin-bottom: 16px;' }, [ K.E('p', { 'style': 'margin: 0 0 12px;' }, 'Are you sure you want to delete this virtual host?'),
E('p', { 'style': 'margin: 0;' }, 'Are you sure you want to delete this virtual host?'), K.E('div', {
E('div', { 'style': 'padding: 12px 16px; background: var(--kiss-bg2, #111827); border-radius: 8px; font-family: monospace; margin-bottom: 20px;'
'style': 'margin-top: 12px; padding: 12px; background: var(--hp-bg-tertiary, #f5f5f5); border-radius: 8px; font-family: monospace;' }, vh.domain),
}, vh.domain) K.E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px;' }, [
]), K.E('button', {
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px;' }, [ 'class': 'kiss-btn',
E('button', { 'onClick': ui.hideModal
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'), }, 'Cancel'),
E('button', { K.E('button', {
'class': 'hp-btn hp-btn-danger', 'class': 'kiss-btn kiss-btn-red',
'click': function() { 'onClick': function() {
ui.hideModal(); ui.hideModal();
api.deleteVhost(vh.id).then(function(res) { api.deleteVhost(vh.id).then(function(res) {
if (res.success) { if (res.success) {
@ -346,30 +313,31 @@ return view.extend({
} }
}); });
} }
}, 'Delete') }, '🗑️ Delete')
]) ])
]); ]);
ui.showModal('Delete Virtual Host', [modalContent]);
}, },
showToast: function(message, type) { showToast: function(message, type) {
var existing = document.querySelector('.hp-toast'); var existing = document.querySelector('.kiss-toast');
if (existing) existing.remove(); if (existing) existing.remove();
var iconMap = { var icons = { success: '✅', error: '❌', warning: '⚠️' };
'success': '\u2705', var colors = {
'error': '\u274C', success: 'var(--kiss-green, #00C853)',
'warning': '\u26A0\uFE0F' error: 'var(--kiss-red, #FF1744)',
warning: 'var(--kiss-yellow, #fbbf24)'
}; };
var toast = E('div', { 'class': 'hp-toast ' + (type || '') }, [ var toast = document.createElement('div');
E('span', {}, iconMap[type] || '\u2139\uFE0F'), toast.className = 'kiss-toast';
message toast.style.cssText = 'position: fixed; bottom: 80px; right: 20px; padding: 12px 20px; border-radius: 8px; background: var(--kiss-card, #161e2e); border: 1px solid ' + (colors[type] || 'var(--kiss-line)') + '; color: var(--kiss-text, #e2e8f0); font-size: 14px; display: flex; align-items: center; gap: 10px; z-index: 9999; box-shadow: 0 4px 20px rgba(0,0,0,0.3);';
]); toast.innerHTML = (icons[type] || '') + ' ' + message;
document.body.appendChild(toast);
setTimeout(function() { document.body.appendChild(toast);
toast.remove(); setTimeout(function() { toast.remove(); }, 4000);
}, 4000);
}, },
handleSaveApply: null, handleSaveApply: null,