From 7527192f9aae6864ffe9ac3814de3a4e7dd05704 Mon Sep 17 00:00:00 2001 From: anhth+quangdv Date: Tue, 20 Jan 2026 18:15:13 +0900 Subject: [PATCH 01/12] fix: view_only field showing default value on edit and not saving to DB --- resources/views/form/field/display.blade.php | 2 ++ src/ColumnItems/CustomItem.php | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/resources/views/form/field/display.blade.php b/resources/views/form/field/display.blade.php index 24cfd7ee3..b41b531b0 100644 --- a/resources/views/form/field/display.blade.php +++ b/resources/views/form/field/display.blade.php @@ -19,6 +19,8 @@ @endif @endif + {{-- Hidden input to save value to database --}} + diff --git a/src/ColumnItems/CustomItem.php b/src/ColumnItems/CustomItem.php index 3df8648d8..c04e2529f 100644 --- a/src/ColumnItems/CustomItem.php +++ b/src/ColumnItems/CustomItem.php @@ -452,8 +452,8 @@ protected function getCustomField($classname, $column_name_prefix = null) if (!$this->hidden()) { if ($this->initonly()) { $field->displayText($this->html())->escape(false)->default($this->value)->prepareDefault(); - } elseif ($this->viewonly() && !isset($this->value)) { - // if view only and create, set default value + } elseif ($this->viewonly() && is_null($this->id) && !isset($this->value)) { + // if view only and create (no id), set default value $this->value = $this->getDefaultValue(); $field->displayText($this->html())->escape(false); $this->value = null; From 8263c3e7a39583d513b94782177cb37bd5381a4e Mon Sep 17 00:00:00 2001 From: SuDC Date: Thu, 29 Jan 2026 16:28:14 +0700 Subject: [PATCH 02/12] fix: update handling of hidden fields in HasManyTable --- src/Form/Field/HasManyTable.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Form/Field/HasManyTable.php b/src/Form/Field/HasManyTable.php index 07f7fc33a..a3661ce05 100644 --- a/src/Form/Field/HasManyTable.php +++ b/src/Form/Field/HasManyTable.php @@ -182,14 +182,15 @@ protected function setTableFieldItem(&$field, &$tableitems, &$hiddens, &$require return; } - // if hidden, set $hiddens + // if hidden, set $hiddens (do not add header metadata like required/help) if ($field instanceof Hidden) { $hiddens[] = $field; - } else { - $tableitems[] = $field; + return; } - // if required true false + $tableitems[] = $field; + + // if required true false (header only) $requires[] = is_array($field->getAttributes()) && array_has($field->getAttributes(), 'required'); // set label viewclass hidden From 8ccbdce9bc720b4fd1cc6e7599b1841070f6df3e Mon Sep 17 00:00:00 2001 From: anhth+quangdv Date: Mon, 2 Feb 2026 11:55:55 +0900 Subject: [PATCH 03/12] alert toaster for hidden field --- .../exment/js/hasmanytable-validation.js | 244 ++++++++++++++++++ src/Middleware/Bootstrap.php | 1 + 2 files changed, 245 insertions(+) create mode 100644 public/vendor/exment/js/hasmanytable-validation.js diff --git a/public/vendor/exment/js/hasmanytable-validation.js b/public/vendor/exment/js/hasmanytable-validation.js new file mode 100644 index 000000000..fc8d77818 --- /dev/null +++ b/public/vendor/exment/js/hasmanytable-validation.js @@ -0,0 +1,244 @@ +/** + * Has-Many Table Validation + * Check for hidden required fields before form submission + */ +(function($) { + 'use strict'; + + /** + * Check if an element is hidden (display:none or visibility:hidden or parent hidden) + */ + function isElementHidden($element) { + if (!$element || $element.length === 0) { + return false; + } + + // Check if element or any parent has display:none or visibility:hidden + return $element.is(':hidden') || + $element.css('visibility') === 'hidden' || + $element.parents().is(':hidden'); + } + + /** + * Get field label from table header + */ + function getFieldLabel($field) { + var $row = $field.closest('tr'); + var $table = $field.closest('table.has-many-table'); + var cellIndex = $field.closest('td').index(); + + // Get header cell + var $headerCell = $table.find('thead tr th').eq(cellIndex); + + if ($headerCell.length) { + // Get text and remove help icon if exists + var label = $headerCell.clone() + .find('i.fa-info-circle').remove().end() + .text().trim(); + return label; + } + + // Fallback: try to get from label or placeholder + var $label = $field.closest('.form-group').find('label'); + if ($label.length) { + return $label.text().trim(); + } + + return $field.attr('placeholder') || $field.attr('name') || 'Unknown field'; + } + + /** + * Get table name + */ + function getTableName($table) { + // Try to get from header + var $header = $table.closest('.has-many-table-div').find('.field-header'); + if ($header.length) { + return $header.text().trim(); + } + + // Fallback to table class name + var classes = $table.attr('class').split(' '); + for (var i = 0; i < classes.length; i++) { + if (classes[i].indexOf('has-many-table-') === 0 && classes[i].indexOf('-table') > 0) { + var tableName = classes[i].replace('has-many-table-', '').replace('-table', ''); + return tableName.replace(/_/g, ' ').replace(/\b\w/g, function(l) { return l.toUpperCase(); }); + } + } + + return 'Table'; + } + + /** + * Check has-many tables for hidden required fields + */ + function checkHasManyTableValidation() { + var hiddenRequiredFields = []; + + // Find all has-many tables + $('.has-many-table').each(function() { + var $table = $(this); + var tableName = getTableName($table); + + // Find all rows (excluding template rows) + $table.find('tbody tr.has-many-table-row').not('.template').each(function(rowIndex) { + var $row = $(this); + var rowNumber = rowIndex + 1; + + // Find all required fields in this row + $row.find('input[required], select[required], textarea[required]').each(function() { + var $field = $(this); + var fieldLabel = getFieldLabel($field); + + // Check if field is hidden + if (isElementHidden($field) || isElementHidden($field.closest('td'))) { + hiddenRequiredFields.push({ + table: tableName, + row: rowNumber, + field: fieldLabel, + element: $field + }); + } + }); + }); + }); + + return hiddenRequiredFields; + } + + /** + * Show alert with hidden required fields + * Priority: toastr (lightweight) > SweetAlert (prettier) > alert (fallback) + */ + function showHiddenRequiredFieldsAlert(hiddenFields) { + if (hiddenFields.length === 0) { + return; + } + + // Group by table + var groupedByTable = {}; + hiddenFields.forEach(function(item) { + if (!groupedByTable[item.table]) { + groupedByTable[item.table] = []; + } + groupedByTable[item.table].push(item); + }); + + // Build message + var messageLines = []; + for (var table in groupedByTable) { + messageLines.push(table + ':'); + groupedByTable[table].forEach(function(item) { + messageLines.push(' • 行 ' + item.row + ': ' + item.field); + }); + } + var plainMessage = '以下の必須項目が非表示になっています:\n\n' + messageLines.join('\n'); + + // Try toastr first (lightweight and non-blocking) + if (typeof toastr !== 'undefined') { + toastr.error(plainMessage, 'バリデーションエラー', { + timeOut: 10000, + extendedTimeOut: 5000, + closeButton: true, + progressBar: true, + positionClass: 'toast-top-right' + }); + return; + } + + // Build HTML message for SweetAlert + var messageHtml = '
以下の必須項目が非表示になっており、表示する必要があります:

'; + + for (var table in groupedByTable) { + console.log(table); + + messageHtml += '' + table + ':
    '; + groupedByTable[table].forEach(function(item) { + messageHtml += '
  • 行 ' + item.row + ': ' + item.field + '
  • '; + }); + messageHtml += '
'; + } + + messageHtml += '
'; + + // Try SweetAlert + if (typeof swal !== 'undefined') { + // SweetAlert 1.x + swal({ + title: 'バリデーションエラー', + text: messageHtml, + html: true, + type: 'error', + confirmButtonText: 'OK' + }); + } else if (typeof Swal !== 'undefined') { + // SweetAlert 2.x + Swal.fire({ + title: 'バリデーションエラー', + html: messageHtml, + icon: 'error', + confirmButtonText: 'OK' + }); + } else { + // Fallback to native alert + alert(plainMessage); + } + } + + /** + * Initialize validation check on form submit + */ + function initHasManyTableValidation() { + // Find the form containing has-many tables + var $form = $('form').has('.has-many-table'); + + if ($form.length === 0) { + return; + } + + // Intercept form submit + $form.on('submit', function(e) { + // Check for hidden required fields + var hiddenRequiredFields = checkHasManyTableValidation(); + + if (hiddenRequiredFields.length > 0) { + // Prevent form submission + e.preventDefault(); + e.stopImmediatePropagation(); + console.log(hiddenRequiredFields); + + + // Show alert + showHiddenRequiredFieldsAlert(hiddenRequiredFields); + + // Scroll to first problematic field + if (hiddenRequiredFields[0].element) { + $('html, body').animate({ + scrollTop: hiddenRequiredFields[0].element.closest('.has-many-table-div').offset().top - 100 + }, 500); + } + + return false; + } + }); + + // Also check on any custom form submission (like PJAX) + $(document).on('pjax:beforeSend', function(e) { + if ($(e.relatedTarget).closest('form').has('.has-many-table').length > 0) { + var hiddenRequiredFields = checkHasManyTableValidation(); + + if (hiddenRequiredFields.length > 0) { + e.preventDefault(); + showHiddenRequiredFieldsAlert(hiddenRequiredFields); + return false; + } + } + }); + } + + // Initialize when document is ready + $(function() { + initHasManyTableValidation(); + }); + +})(jQuery); diff --git a/src/Middleware/Bootstrap.php b/src/Middleware/Bootstrap.php index 4c57caa8d..809a26eee 100644 --- a/src/Middleware/Bootstrap.php +++ b/src/Middleware/Bootstrap.php @@ -86,6 +86,7 @@ protected function setCssJs(Request $request, \Closure $next) 'vendor/exment/js/customcolumn.js', 'vendor/exment/js/customformitem.js', 'vendor/exment/js/customform.js', + 'vendor/exment/js/hasmanytable-validation.js', 'vendor/exment/js/preview.js', 'vendor/exment/js/webapi.js', 'vendor/exment/js/admin.webapi.js', From d0448ab0099b1f0cebccec8fa4f76fe4979f39c2 Mon Sep 17 00:00:00 2001 From: SuDC Date: Mon, 2 Feb 2026 10:35:58 +0700 Subject: [PATCH 04/12] fix: improve hidden field validation in HasManyTable --- .../exment/js/hasmanytable-validation.js | 340 ++++++++---------- 1 file changed, 148 insertions(+), 192 deletions(-) diff --git a/public/vendor/exment/js/hasmanytable-validation.js b/public/vendor/exment/js/hasmanytable-validation.js index fc8d77818..ae57f8bc2 100644 --- a/public/vendor/exment/js/hasmanytable-validation.js +++ b/public/vendor/exment/js/hasmanytable-validation.js @@ -1,142 +1,138 @@ /** * Has-Many Table Validation - * Check for hidden required fields before form submission */ (function($) { 'use strict'; - /** - * Check if an element is hidden (display:none or visibility:hidden or parent hidden) - */ - function isElementHidden($element) { - if (!$element || $element.length === 0) { - return false; + function keyFromName(name) { + if (!name) return null; + var m = name.match(/\[value\]\[([^\]]+)\]|\[([^\]]+)\]$/); + return m ? (m[1] || m[2]) : null; + } + + function isHidden($el) { + if (!$el || !$el.length) return false; + if ($el.is(':hidden')) return true; + return $el.parents().addBack().filter(function() { + return $(this).css('visibility') === 'hidden'; + }).length > 0; + } + + function outerCell($field) { + var $row = $field.closest('tr.has-many-table-row'); + if ($row.length) { + var $td = $row.children('td').filter(function() { + return this.contains($field[0]); + }).first(); + if ($td.length) return $td; } - - // Check if element or any parent has display:none or visibility:hidden - return $element.is(':hidden') || - $element.css('visibility') === 'hidden' || - $element.parents().is(':hidden'); + return $field.closest('td'); + } + + function colPos($td) { + var pos = 0; + $td.prevAll('td').each(function() { + var span = parseInt($(this).attr('colspan'), 10); + pos += isNaN(span) ? 1 : span; + }); + return pos; + } + + function headerAt($table, pos) { + var $hit = $(); + var cur = 0; + $table.find('thead tr').first().children('th').each(function() { + var span = parseInt($(this).attr('colspan'), 10); + var w = isNaN(span) ? 1 : span; + if (pos >= cur && pos < cur + w) { + $hit = $(this); + return false; + } + cur += w; + }); + return $hit; + } + + function headerText($th) { + return $th.clone().find('i.fa-info-circle, i.fa, .fa').remove().end().text().trim(); } - /** - * Get field label from table header - */ - function getFieldLabel($field) { - var $row = $field.closest('tr'); + function fieldLabel($field) { + var nameKey = keyFromName($field.attr('name')); var $table = $field.closest('table.has-many-table'); - var cellIndex = $field.closest('td').index(); - - // Get header cell - var $headerCell = $table.find('thead tr th').eq(cellIndex); - - if ($headerCell.length) { - // Get text and remove help icon if exists - var label = $headerCell.clone() - .find('i.fa-info-circle').remove().end() - .text().trim(); - return label; - } - - // Fallback: try to get from label or placeholder - var $label = $field.closest('.form-group').find('label'); - if ($label.length) { - return $label.text().trim(); + + var label = ''; + if ($table.length) { + var $td = outerCell($field); + if ($td.length) label = headerText(headerAt($table, colPos($td))); } - - return $field.attr('placeholder') || $field.attr('name') || 'Unknown field'; + + if (label && nameKey && (/^(Action|操作)$/i).test(label)) return nameKey; + if (label) return label; + + var $lbl = $field.closest('.form-group').find('label'); + if ($lbl.length) return $lbl.text().trim(); + + return $field.attr('placeholder') || nameKey || $field.attr('name') || 'Unknown field'; } - /** - * Get table name - */ - function getTableName($table) { - // Try to get from header - var $header = $table.closest('.has-many-table-div').find('.field-header'); - if ($header.length) { - return $header.text().trim(); - } - - // Fallback to table class name - var classes = $table.attr('class').split(' '); - for (var i = 0; i < classes.length; i++) { - if (classes[i].indexOf('has-many-table-') === 0 && classes[i].indexOf('-table') > 0) { - var tableName = classes[i].replace('has-many-table-', '').replace('-table', ''); - return tableName.replace(/_/g, ' ').replace(/\b\w/g, function(l) { return l.toUpperCase(); }); - } + function tableName($table) { + var $h = $table.closest('.has-many-table-div').find('.field-header'); + if ($h.length) return $h.text().trim(); + + var cls = ($table.attr('class') || '').match(/\bhas-many-table-([^\s]+?)-table\b/); + if (cls && cls[1]) { + return cls[1].replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }); } - return 'Table'; } - /** - * Check has-many tables for hidden required fields - */ - function checkHasManyTableValidation() { - var hiddenRequiredFields = []; - - // Find all has-many tables + function findHiddenRequired() { + var bad = []; + $('.has-many-table').each(function() { var $table = $(this); - var tableName = getTableName($table); - - // Find all rows (excluding template rows) - $table.find('tbody tr.has-many-table-row').not('.template').each(function(rowIndex) { - var $row = $(this); - var rowNumber = rowIndex + 1; - - // Find all required fields in this row - $row.find('input[required], select[required], textarea[required]').each(function() { - var $field = $(this); - var fieldLabel = getFieldLabel($field); - - // Check if field is hidden - if (isElementHidden($field) || isElementHidden($field.closest('td'))) { - hiddenRequiredFields.push({ - table: tableName, - row: rowNumber, - field: fieldLabel, - element: $field - }); + var tname = tableName($table); + + $table.find('tbody tr.has-many-table-row').not('.template').each(function(i) { + var rowNo = i + 1; + + $(this).find('input[required], select[required], textarea[required]').each(function() { + var $f = $(this); + if ($f.is('input[type="hidden"]')) { + var n = $f.attr('name') || ''; + if (n.indexOf('[value][') === -1) return; + } + + if (isHidden($f) || isHidden($f.closest('td'))) { + bad.push({ table: tname, row: rowNo, field: fieldLabel($f), element: $f }); } }); }); }); - - return hiddenRequiredFields; + + return bad; } - /** - * Show alert with hidden required fields - * Priority: toastr (lightweight) > SweetAlert (prettier) > alert (fallback) - */ - function showHiddenRequiredFieldsAlert(hiddenFields) { - if (hiddenFields.length === 0) { - return; - } - - // Group by table - var groupedByTable = {}; - hiddenFields.forEach(function(item) { - if (!groupedByTable[item.table]) { - groupedByTable[item.table] = []; - } - groupedByTable[item.table].push(item); + function showAlert(fields) { + if (!fields.length) return; + + var byTable = {}; + fields.forEach(function(x) { + (byTable[x.table] = byTable[x.table] || []).push(x); }); - - // Build message - var messageLines = []; - for (var table in groupedByTable) { - messageLines.push(table + ':'); - groupedByTable[table].forEach(function(item) { - messageLines.push(' • 行 ' + item.row + ': ' + item.field); + + var lines = []; + Object.keys(byTable).forEach(function(t) { + lines.push(t + ':'); + byTable[t].forEach(function(x) { + lines.push(' • 行 ' + x.row + ': ' + x.field); }); - } - var plainMessage = '以下の必須項目が非表示になっています:\n\n' + messageLines.join('\n'); - - // Try toastr first (lightweight and non-blocking) + }); + var plain = '以下の必須項目が非表示になっています:\n\n' + lines.join('\n'); + if (typeof toastr !== 'undefined') { - toastr.error(plainMessage, 'バリデーションエラー', { + toastr.error(plain, 'バリデーションエラー', { timeOut: 10000, extendedTimeOut: 5000, closeButton: true, @@ -145,100 +141,60 @@ }); return; } - - // Build HTML message for SweetAlert - var messageHtml = '
以下の必須項目が非表示になっており、表示する必要があります:

'; - - for (var table in groupedByTable) { - console.log(table); - - messageHtml += '' + table + ':
    '; - groupedByTable[table].forEach(function(item) { - messageHtml += '
  • 行 ' + item.row + ': ' + item.field + '
  • '; + + var html = '
    以下の必須項目が非表示になっており、表示する必要があります:

    '; + Object.keys(byTable).forEach(function(t) { + html += '' + t + ':
      '; + byTable[t].forEach(function(x) { + html += '
    • 行 ' + x.row + ': ' + x.field + '
    • '; }); - messageHtml += '
    '; - } - - messageHtml += '
    '; - - // Try SweetAlert + html += '
'; + }); + html += '
'; + if (typeof swal !== 'undefined') { - // SweetAlert 1.x - swal({ - title: 'バリデーションエラー', - text: messageHtml, - html: true, - type: 'error', - confirmButtonText: 'OK' - }); + swal({ title: 'バリデーションエラー', text: html, html: true, type: 'error', confirmButtonText: 'OK' }); } else if (typeof Swal !== 'undefined') { - // SweetAlert 2.x - Swal.fire({ - title: 'バリデーションエラー', - html: messageHtml, - icon: 'error', - confirmButtonText: 'OK' - }); + Swal.fire({ title: 'バリデーションエラー', html: html, icon: 'error', confirmButtonText: 'OK' }); } else { - // Fallback to native alert - alert(plainMessage); + alert(plain); } } - /** - * Initialize validation check on form submit - */ - function initHasManyTableValidation() { - // Find the form containing has-many tables + function init() { var $form = $('form').has('.has-many-table'); - - if ($form.length === 0) { - return; - } - - // Intercept form submit + if (!$form.length) return; + $form.on('submit', function(e) { - // Check for hidden required fields - var hiddenRequiredFields = checkHasManyTableValidation(); - - if (hiddenRequiredFields.length > 0) { - // Prevent form submission - e.preventDefault(); - e.stopImmediatePropagation(); - console.log(hiddenRequiredFields); - - - // Show alert - showHiddenRequiredFieldsAlert(hiddenRequiredFields); - - // Scroll to first problematic field - if (hiddenRequiredFields[0].element) { - $('html, body').animate({ - scrollTop: hiddenRequiredFields[0].element.closest('.has-many-table-div').offset().top - 100 - }, 500); - } - - return false; + var fields = findHiddenRequired(); + if (!fields.length) return; + + e.preventDefault(); + e.stopImmediatePropagation(); + + showAlert(fields); + + if (fields[0].element) { + $('html, body').animate({ + scrollTop: fields[0].element.closest('.has-many-table-div').offset().top - 100 + }, 500); } + + return false; }); - - // Also check on any custom form submission (like PJAX) + $(document).on('pjax:beforeSend', function(e) { - if ($(e.relatedTarget).closest('form').has('.has-many-table').length > 0) { - var hiddenRequiredFields = checkHasManyTableValidation(); - - if (hiddenRequiredFields.length > 0) { - e.preventDefault(); - showHiddenRequiredFieldsAlert(hiddenRequiredFields); - return false; - } - } + var $targetForm = $(e.relatedTarget).closest('form'); + if (!$targetForm.has('.has-many-table').length) return; + + var fields = findHiddenRequired(); + if (!fields.length) return; + + e.preventDefault(); + showAlert(fields); + return false; }); } - // Initialize when document is ready - $(function() { - initHasManyTableValidation(); - }); - + $(init); })(jQuery); From 1a774689dc45fe105fa5b6c76be26791e83a49b9 Mon Sep 17 00:00:00 2001 From: SuDC Date: Mon, 2 Feb 2026 10:59:50 +0700 Subject: [PATCH 05/12] fix: enhance validation messages for hidden fields in HasManyTable --- .../exment/js/hasmanytable-validation.js | 62 ++++++++++++++----- resources/lang/en/exment.php | 4 ++ resources/lang/ja/exment.php | 4 ++ src/Form/Navbar/Hidden.php | 6 ++ 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/public/vendor/exment/js/hasmanytable-validation.js b/public/vendor/exment/js/hasmanytable-validation.js index ae57f8bc2..7c3cfbef6 100644 --- a/public/vendor/exment/js/hasmanytable-validation.js +++ b/public/vendor/exment/js/hasmanytable-validation.js @@ -117,45 +117,79 @@ function showAlert(fields) { if (!fields.length) return; - var byTable = {}; + function escHtml(s) { + return String(s).replace(/[&<>"']/g, function(c) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; + }); + } + + function hval(id, fallback) { + var $el = $('#' + id); + return $el.length ? $el.val() : fallback; + } + + var TITLE = hval('exment_hm_validation_title', 'バリデーションエラー'); + var PLAIN_PREFIX = hval('exment_hm_validation_plain_prefix', '以下の必須項目が非表示になっています:'); + var HTML_PREFIX = hval('exment_hm_validation_html_prefix', '以下の必須項目が非表示になっており、表示する必要があります:'); + var OK_TEXT = hval('exment_hm_validation_ok', 'OK'); + var ROW_TEXT = hval('exment_common_row', '行'); + + // Group: table -> row -> [field...] + var byTableRow = {}; fields.forEach(function(x) { - (byTable[x.table] = byTable[x.table] || []).push(x); + byTableRow[x.table] = byTableRow[x.table] || {}; + byTableRow[x.table][x.row] = byTableRow[x.table][x.row] || []; + byTableRow[x.table][x.row].push(x.field); }); var lines = []; - Object.keys(byTable).forEach(function(t) { + Object.keys(byTableRow).forEach(function(t) { lines.push(t + ':'); - byTable[t].forEach(function(x) { - lines.push(' • 行 ' + x.row + ': ' + x.field); + + Object.keys(byTableRow[t]).sort(function(a, b) { + return parseInt(a, 10) - parseInt(b, 10); + }).forEach(function(r) { + var parts = byTableRow[t][r].map(function(f) { + return '• ' + ROW_TEXT + ' ' + r + ': ' + f; + }); + lines.push(parts.join(' ')); }); }); - var plain = '以下の必須項目が非表示になっています:\n\n' + lines.join('\n'); + + var plain = PLAIN_PREFIX + '\n\n' + lines.join('\n'); if (typeof toastr !== 'undefined') { - toastr.error(plain, 'バリデーションエラー', { + var toastHtml = escHtml(plain).replace(/\n/g, '
'); + toastr.error(toastHtml, TITLE, { timeOut: 10000, extendedTimeOut: 5000, closeButton: true, progressBar: true, - positionClass: 'toast-top-right' + positionClass: 'toast-top-right', + escapeHtml: false }); return; } - var html = '
以下の必須項目が非表示になっており、表示する必要があります:

'; - Object.keys(byTable).forEach(function(t) { + var html = '
' + escHtml(HTML_PREFIX) + '

'; + Object.keys(byTableRow).forEach(function(t) { html += '' + t + ':
    '; - byTable[t].forEach(function(x) { - html += '
  • 行 ' + x.row + ': ' + x.field + '
  • '; + Object.keys(byTableRow[t]).sort(function(a, b) { + return parseInt(a, 10) - parseInt(b, 10); + }).forEach(function(r) { + var parts = byTableRow[t][r].map(function(f) { + return escHtml(ROW_TEXT) + ' ' + r + ': ' + escHtml(f) + ''; + }); + html += '
  • ' + parts.join(' / ') + '
  • '; }); html += '
'; }); html += '
'; if (typeof swal !== 'undefined') { - swal({ title: 'バリデーションエラー', text: html, html: true, type: 'error', confirmButtonText: 'OK' }); + swal({ title: TITLE, text: html, html: true, type: 'error', confirmButtonText: OK_TEXT }); } else if (typeof Swal !== 'undefined') { - Swal.fire({ title: 'バリデーションエラー', html: html, icon: 'error', confirmButtonText: 'OK' }); + Swal.fire({ title: TITLE, html: html, icon: 'error', confirmButtonText: OK_TEXT }); } else { alert(plain); } diff --git a/resources/lang/en/exment.php b/resources/lang/en/exment.php index 3b959a6c3..0992248f9 100644 --- a/resources/lang/en/exment.php +++ b/resources/lang/en/exment.php @@ -226,6 +226,10 @@ ], 'validation' => [ + 'hasmany_hidden_required_title' => 'Validation Error', + 'hasmany_hidden_required_plain_prefix' => 'The following required fields are hidden:', + 'hasmany_hidden_required_html_prefix' => 'The following required fields are hidden and must be shown:', + 'hasmany_hidden_required_ok' => 'OK', 'current_password' => 'The current password is incorrect.', 'password_history' => 'The password is the same as the password registered in the past. Please enter another password.', 'complex_password' => 'The password must be at least 12 characters long and must contain three types of characters (uppercase letters, lowercase letters, numbers, symbols).', diff --git a/resources/lang/ja/exment.php b/resources/lang/ja/exment.php index 713a41358..1719ea4bd 100644 --- a/resources/lang/ja/exment.php +++ b/resources/lang/ja/exment.php @@ -226,6 +226,10 @@ ], 'validation' => [ + 'hasmany_hidden_required_title' => 'バリデーションエラー', + 'hasmany_hidden_required_plain_prefix' => '以下の必須項目が非表示になっています:', + 'hasmany_hidden_required_html_prefix' => '以下の必須項目が非表示になっており、表示する必要があります:', + 'hasmany_hidden_required_ok' => 'OK', 'current_password' => '現在のパスワードが正しくありません。', 'password_history' => '過去に登録したパスワードと同一のパスワードとなっています。他のパスワードを入力してください。', 'complex_password' => 'パスワードは12文字以上で、必ず3種類の文字種(英大文字、英小文字、数字、記号)を含む必要があります。', diff --git a/src/Form/Navbar/Hidden.php b/src/Form/Navbar/Hidden.php index 270151935..79e1cdaf8 100644 --- a/src/Form/Navbar/Hidden.php +++ b/src/Form/Navbar/Hidden.php @@ -18,6 +18,12 @@ public function render() 'exment_undefined_error' => exmtrans('error.undefined_error'), 'exment_error_title' => exmtrans('common.error'), 'exment_expired_error' => exmtrans('error.expired_error'), + // has-many table validation (JS) + 'exment_hm_validation_title' => exmtrans('validation.hasmany_hidden_required_title'), + 'exment_hm_validation_plain_prefix' => exmtrans('validation.hasmany_hidden_required_plain_prefix'), + 'exment_hm_validation_html_prefix' => exmtrans('validation.hasmany_hidden_required_html_prefix'), + 'exment_hm_validation_ok' => exmtrans('validation.hasmany_hidden_required_ok'), + 'exment_common_row' => exmtrans('common.row'), ], static::getHiddenItemsCommon()); $html = ''; From 641fa5c03f1b8bea64aff5256058102951a37856 Mon Sep 17 00:00:00 2001 From: SuDC Date: Tue, 3 Feb 2026 13:56:08 +0700 Subject: [PATCH 06/12] fix: add validation for empty required fields in HasManyTable --- .../exment/js/hasmanytable-validation.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/public/vendor/exment/js/hasmanytable-validation.js b/public/vendor/exment/js/hasmanytable-validation.js index 7c3cfbef6..e78b5278e 100644 --- a/public/vendor/exment/js/hasmanytable-validation.js +++ b/public/vendor/exment/js/hasmanytable-validation.js @@ -87,6 +87,24 @@ return 'Table'; } + function isEmptyRequiredValue($f) { + if (!$f || !$f.length) return true; + if ($f.is(':disabled')) return false; + + if ($f.is('input[type="checkbox"], input[type="radio"]')) { + return !$f.is(':checked'); + } + + if ($f.is('select')) { + var v = $f.val(); + if (Array.isArray(v)) return v.length === 0; + return $.trim(v) === ''; + } + + var val = $f.val(); + return val == null || $.trim(val) === ''; + } + function findHiddenRequired() { var bad = []; @@ -104,6 +122,9 @@ if (n.indexOf('[value][') === -1) return; } + // Only block when required field is hidden AND empty + if (!isEmptyRequiredValue($f)) return; + if (isHidden($f) || isHidden($f.closest('td'))) { bad.push({ table: tname, row: rowNo, field: fieldLabel($f), element: $f }); } From 410d611c758c954041e9219ce965430e651ae691 Mon Sep 17 00:00:00 2001 From: anhth+quangdv Date: Tue, 10 Feb 2026 19:10:03 +0900 Subject: [PATCH 07/12] fix: enhance validation for nested HasMany forms in FileRequredRule --- src/DataItems/Show/DefaultShow.php | 19 ++++++++++++++++--- src/Validator/FileRequredRule.php | 27 ++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/DataItems/Show/DefaultShow.php b/src/DataItems/Show/DefaultShow.php index ec99bb5fa..b4f476a8f 100644 --- a/src/DataItems/Show/DefaultShow.php +++ b/src/DataItems/Show/DefaultShow.php @@ -831,6 +831,21 @@ public function filedelete(Request $request, $form) // get key name for delete $del_key = $request->input('key'); + // get original updated_at from PARENT table BEFORE deleting file to avoid validator lock + // check if this is child table and has parent + $parent_value = $this->custom_value->getParentValue(null, true); + if ($parent_value) { + // get parent's updated_at using getValueQuery (same way as validatorLock) + $updated_value = $parent_value->custom_table->getValueQuery() + ->select(['updated_at']) + ->find($parent_value->id)->updated_at ?? null; + } else { + // no parent, use current table's updated_at + $updated_value = $this->custom_table->getValueQuery() + ->select(['updated_at']) + ->find($this->custom_value->id)->updated_at ?? null; + } + // get custom column and item $custom_column = CustomColumn::getEloquent($del_column_name, $this->custom_table); /** @var ColumnItems\CustomItem|null $custom_item */ @@ -839,14 +854,12 @@ public function filedelete(Request $request, $form) $custom_item->setCustomValue($this->custom_value)->deleteFile($del_key); } - // reget custom value - $updated_value = getModelName($this->custom_table)::find($this->custom_value->id); return getAjaxResponse([ 'result' => true, 'message' => trans('admin.delete_succeeded'), 'reload' => false, 'updateValue' => [ - 'updated_at' => $updated_value->updated_at->format('Y-m-d H:i:s'), + 'updated_at' => $updated_value->format('Y-m-d H:i:s'), ], ]); } diff --git a/src/Validator/FileRequredRule.php b/src/Validator/FileRequredRule.php index 4bacf11c9..69ce3f380 100644 --- a/src/Validator/FileRequredRule.php +++ b/src/Validator/FileRequredRule.php @@ -38,9 +38,34 @@ public function passes($attribute, $value) // if has custom_value, checking value if (isset($this->custom_value) && $this->custom_value->exists) { $v = array_get($this->custom_value->value, $this->custom_column->column_name); - return !is_nullorempty($v); } + + // For HasMany nested forms - extract child record ID from attribute name + // Attribute format examples: + // - pivot__{hash}.{id}.value.{column_name} + // - {relation_name}.{id}.value.{column_name} + // Only numeric IDs that exist in DB are valid edit cases + if (preg_match('/\.(\d+)\.value\.([^.]+)$/', $attribute, $matches)) { + $childId = (int)$matches[1]; + $columnName = $matches[2]; + + // Verify column name matches to avoid false positives + if ($columnName !== $this->custom_column->column_name) { + return false; + } + + $customTable = $this->custom_column->custom_table; + if ($customTable && $childId > 0) { + $childRecord = $customTable->getValueModel($childId); + + // Record must exist and belong to correct table + if ($childRecord && $childRecord->exists) { + $existingValue = array_get($childRecord->value, $this->custom_column->column_name); + return !is_nullorempty($existingValue); + } + } + } return false; } From 20a0bf33235b7f0f55dd64c5f8262389cd66211a Mon Sep 17 00:00:00 2001 From: anhth+quangdv Date: Wed, 11 Feb 2026 23:02:47 +0900 Subject: [PATCH 08/12] fix: streamline file deletion process by removing redundant parent updated_at checks --- src/DataItems/Show/DefaultShow.php | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/DataItems/Show/DefaultShow.php b/src/DataItems/Show/DefaultShow.php index b4f476a8f..f7c7584c2 100644 --- a/src/DataItems/Show/DefaultShow.php +++ b/src/DataItems/Show/DefaultShow.php @@ -831,21 +831,6 @@ public function filedelete(Request $request, $form) // get key name for delete $del_key = $request->input('key'); - // get original updated_at from PARENT table BEFORE deleting file to avoid validator lock - // check if this is child table and has parent - $parent_value = $this->custom_value->getParentValue(null, true); - if ($parent_value) { - // get parent's updated_at using getValueQuery (same way as validatorLock) - $updated_value = $parent_value->custom_table->getValueQuery() - ->select(['updated_at']) - ->find($parent_value->id)->updated_at ?? null; - } else { - // no parent, use current table's updated_at - $updated_value = $this->custom_table->getValueQuery() - ->select(['updated_at']) - ->find($this->custom_value->id)->updated_at ?? null; - } - // get custom column and item $custom_column = CustomColumn::getEloquent($del_column_name, $this->custom_table); /** @var ColumnItems\CustomItem|null $custom_item */ @@ -854,16 +839,17 @@ public function filedelete(Request $request, $form) $custom_item->setCustomValue($this->custom_value)->deleteFile($del_key); } + // reget custom value + $updated_value = getModelName($this->custom_table)::find($this->custom_value->id); return getAjaxResponse([ 'result' => true, 'message' => trans('admin.delete_succeeded'), 'reload' => false, 'updateValue' => [ - 'updated_at' => $updated_value->format('Y-m-d H:i:s'), + 'updated_at' => $updated_value->updated_at->format('Y-m-d H:i:s'), ], ]); } - /** * add comment. */ From e4eadb98a0a1dd32d2f0cdffb934f83fbd8f7f2e Mon Sep 17 00:00:00 2001 From: anhth+quangdv Date: Thu, 12 Feb 2026 15:30:51 +0900 Subject: [PATCH 09/12] fix: add file-required.js to handle required attribute for removed files in Bootstrap Fileinput --- public/vendor/exment/css/common.css | 6 ++++- public/vendor/exment/js/file-required.js | 32 ++++++++++++++++++++++++ src/DataItems/Show/DefaultShow.php | 20 ++++++++++++--- src/Middleware/Bootstrap.php | 1 + src/Middleware/BootstrapPublicForm.php | 1 + 5 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 public/vendor/exment/js/file-required.js diff --git a/public/vendor/exment/css/common.css b/public/vendor/exment/css/common.css index b81236cfe..8ef70181c 100644 --- a/public/vendor/exment/css/common.css +++ b/public/vendor/exment/css/common.css @@ -4245,4 +4245,8 @@ label.radio-inline:has(input[readonly][type=radio]) { label.checkboxone-label:has(input[readonly][type=checkbox]) { pointer-events: none; -} \ No newline at end of file +} + +.btn.btn-file>input[type='file'] { + font-size: 0px !important; +} diff --git a/public/vendor/exment/js/file-required.js b/public/vendor/exment/js/file-required.js new file mode 100644 index 000000000..ea3eb1e10 --- /dev/null +++ b/public/vendor/exment/js/file-required.js @@ -0,0 +1,32 @@ +/** + * File Required Handler + * Handle adding required attribute when file is removed from Bootstrap Fileinput + */ +(function($) { + 'use strict'; + + $(document).ready(function() { + // Handle click on kv-file-remove button + $(document).on('click', '.kv-file-remove', function(event) { + var $removeBtn = $(this); + // Find the closest file input (for both file and image types) + var $fileInputContainer = $removeBtn.closest('.file-input'); + if ($fileInputContainer.length > 0) { + var $fileInput = $fileInputContainer.find('input[type="file"][data-column_type="file"], input[type="file"][data-column_type="image"]'); + if ($fileInput.length > 0) { + // Wait a bit for the file to be actually removed, then check if all files are removed + setTimeout(function() { + // Check if there are any remaining file previews + var hasFiles = $fileInputContainer.find('.file-preview-frame:not(.file-preview-initial)').length > 0; + if (!hasFiles) { + // No files left, add required attribute + $fileInput.attr('required', '1'); + $fileInput.prop('required', true); + } + }, 100); + } + } + }); + }); + +})(jQuery); diff --git a/src/DataItems/Show/DefaultShow.php b/src/DataItems/Show/DefaultShow.php index f7c7584c2..b2601c3cc 100644 --- a/src/DataItems/Show/DefaultShow.php +++ b/src/DataItems/Show/DefaultShow.php @@ -39,6 +39,7 @@ use Exceedone\Exment\Enums\ShowPositionType; use Exceedone\Exment\Services\PartialCrudService; use Exceedone\Exment\ColumnItems\ItemInterface; +use Illuminate\Support\Str; /** @phpstan-consistent-constructor */ class DefaultShow extends ShowBase @@ -839,14 +840,27 @@ public function filedelete(Request $request, $form) $custom_item->setCustomValue($this->custom_value)->deleteFile($del_key); } - // reget custom value - $updated_value = getModelName($this->custom_table)::find($this->custom_value->id); + // Reget custom value and updated_at + $updated_at = null; + $parent_value = $this->custom_value->getParentValue(); + $referer = $request->header('referer'); + + if ($parent_value && $referer && Str::contains($referer, '/' . $parent_value->custom_table->table_name . '/')) { + $updated_value = $parent_value->custom_table->getValueQuery() + ->select(['updated_at']) + ->find($parent_value->id); + $updated_at = $updated_value->updated_at ?? null; + } else { + $updated_value = getModelName($this->custom_table)::find($this->custom_value->id); + $updated_at = $updated_value->updated_at ?? null; + } + return getAjaxResponse([ 'result' => true, 'message' => trans('admin.delete_succeeded'), 'reload' => false, 'updateValue' => [ - 'updated_at' => $updated_value->updated_at->format('Y-m-d H:i:s'), + 'updated_at' => $updated_at ? $updated_at->format('Y-m-d H:i:s') : null, ], ]); } diff --git a/src/Middleware/Bootstrap.php b/src/Middleware/Bootstrap.php index 809a26eee..d41301545 100644 --- a/src/Middleware/Bootstrap.php +++ b/src/Middleware/Bootstrap.php @@ -77,6 +77,7 @@ protected function setCssJs(Request $request, \Closure $next) 'vendor/exment/jstree/jstree.min.js', 'vendor/exment/js/common_all.js', 'vendor/exment/js/common.js', + 'vendor/exment/js/file-required.js', 'vendor/exment/js/search.js', 'vendor/exment/js/calc.js', 'vendor/exment/js/notify_navbar.js', diff --git a/src/Middleware/BootstrapPublicForm.php b/src/Middleware/BootstrapPublicForm.php index f775b848f..6c3da6622 100644 --- a/src/Middleware/BootstrapPublicForm.php +++ b/src/Middleware/BootstrapPublicForm.php @@ -69,6 +69,7 @@ protected function setCssJs(Request $request, \Closure $next) 'vendor/exment/jstree/jstree.min.js', 'vendor/exment/js/common_all.js', 'vendor/exment/js/common.js', + 'vendor/exment/js/file-required.js', 'vendor/exment/js/calc.js', 'vendor/exment/js/modal.js', 'vendor/exment/js/changefield.js', From aa52191a31585eb8b897640976cef574504b6979 Mon Sep 17 00:00:00 2001 From: SuDC Date: Mon, 2 Mar 2026 16:56:42 +0700 Subject: [PATCH 10/12] fix: add error message for unique validation --- public/vendor/exment/css/common.css | 4 ++++ resources/lang_vendor/en/validation.php | 14 ++++++++++++++ resources/lang_vendor/ja/validation.php | 1 + 3 files changed, 19 insertions(+) create mode 100644 resources/lang_vendor/en/validation.php diff --git a/public/vendor/exment/css/common.css b/public/vendor/exment/css/common.css index 8ef70181c..46d4936bb 100644 --- a/public/vendor/exment/css/common.css +++ b/public/vendor/exment/css/common.css @@ -3857,6 +3857,10 @@ body .navbar-nav > .notifications-menu > .dropdown-menu, .navbar-nav > .messages color: #dd4b39; } +.has-error .help-block, .has-error .help-block .fa { + color: #737373 !important; +} + .column-__actions__ { min-width: 50px; } diff --git a/resources/lang_vendor/en/validation.php b/resources/lang_vendor/en/validation.php new file mode 100644 index 000000000..e2d02173b --- /dev/null +++ b/resources/lang_vendor/en/validation.php @@ -0,0 +1,14 @@ + unique_in_table) + 'unique_in_table' => 'The :attribute has already been taken.', +]); diff --git a/resources/lang_vendor/ja/validation.php b/resources/lang_vendor/ja/validation.php index 7e8d5b206..4a1d1ca36 100644 --- a/resources/lang_vendor/ja/validation.php +++ b/resources/lang_vendor/ja/validation.php @@ -90,6 +90,7 @@ 'string' => ':attributeには文字列を指定してください。', 'timezone' => ':attributeには正しい形式のタイムゾーンを指定してください。', 'unique' => 'その:attributeはすでに使われています。', + 'unique_in_table' => 'その:attributeがすでに存在しています。', 'uploaded' => ':attributeのアップロードに失敗しました。', 'url' => ':attributeには正しい形式のURLを指定してください。', 'summary_condition' => 'この集計タイプは数値項目以外では指定できません。', From 5e48f98f7cdd0669608205f817d116c3e63ab715 Mon Sep 17 00:00:00 2001 From: manhhh1108 Date: Wed, 4 Mar 2026 14:24:16 +0700 Subject: [PATCH 11/12] fix: refactor validation.php --- public/vendor/exment/css/common.css | 1 + resources/lang_vendor/en/validation.php | 13 ++----------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/public/vendor/exment/css/common.css b/public/vendor/exment/css/common.css index 46d4936bb..b8bcb21d0 100644 --- a/public/vendor/exment/css/common.css +++ b/public/vendor/exment/css/common.css @@ -3847,6 +3847,7 @@ body .navbar-nav > .notifications-menu > .dropdown-menu, .navbar-nav > .messages .has-error .btn-valuemodal, .has-error .text-valuemodal, .has-error .checkbox, .has-error .checkbox-inline, .has-error .control-label, .has-error .help-block, .has-error .radio, .has-error .radio-inline { color: #dd4b39 !important; + word-break: break-all; } .has-error .select2-container--default .select2-selection--single, .has-error .select2-selection .select2-selection--single { diff --git a/resources/lang_vendor/en/validation.php b/resources/lang_vendor/en/validation.php index e2d02173b..09df38c95 100644 --- a/resources/lang_vendor/en/validation.php +++ b/resources/lang_vendor/en/validation.php @@ -1,14 +1,5 @@ unique_in_table) +return [ 'unique_in_table' => 'The :attribute has already been taken.', -]); +]; \ No newline at end of file From 3520784e2d2c65d00e7ec677acad101146e58dd7 Mon Sep 17 00:00:00 2001 From: anhth+quangdv Date: Tue, 20 Jan 2026 18:15:13 +0900 Subject: [PATCH 12/12] fix: improve validation and hidden field handling in HasManyTable --- public/vendor/exment/css/common.css | 11 +- public/vendor/exment/js/file-required.js | 32 +++ .../exment/js/hasmanytable-validation.js | 255 ++++++++++++++++++ resources/lang/en/exment.php | 4 + resources/lang/ja/exment.php | 4 + resources/lang_vendor/en/validation.php | 5 + resources/lang_vendor/ja/validation.php | 1 + resources/views/form/field/display.blade.php | 2 + src/ColumnItems/CustomItem.php | 4 +- src/DataItems/Show/DefaultShow.php | 21 +- src/Form/Field/HasManyTable.php | 9 +- src/Form/Navbar/Hidden.php | 6 + src/Middleware/Bootstrap.php | 2 + src/Middleware/BootstrapPublicForm.php | 1 + src/Validator/FileRequredRule.php | 27 +- 15 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 public/vendor/exment/js/file-required.js create mode 100644 public/vendor/exment/js/hasmanytable-validation.js create mode 100644 resources/lang_vendor/en/validation.php diff --git a/public/vendor/exment/css/common.css b/public/vendor/exment/css/common.css index b81236cfe..b8bcb21d0 100644 --- a/public/vendor/exment/css/common.css +++ b/public/vendor/exment/css/common.css @@ -3847,6 +3847,7 @@ body .navbar-nav > .notifications-menu > .dropdown-menu, .navbar-nav > .messages .has-error .btn-valuemodal, .has-error .text-valuemodal, .has-error .checkbox, .has-error .checkbox-inline, .has-error .control-label, .has-error .help-block, .has-error .radio, .has-error .radio-inline { color: #dd4b39 !important; + word-break: break-all; } .has-error .select2-container--default .select2-selection--single, .has-error .select2-selection .select2-selection--single { @@ -3857,6 +3858,10 @@ body .navbar-nav > .notifications-menu > .dropdown-menu, .navbar-nav > .messages color: #dd4b39; } +.has-error .help-block, .has-error .help-block .fa { + color: #737373 !important; +} + .column-__actions__ { min-width: 50px; } @@ -4245,4 +4250,8 @@ label.radio-inline:has(input[readonly][type=radio]) { label.checkboxone-label:has(input[readonly][type=checkbox]) { pointer-events: none; -} \ No newline at end of file +} + +.btn.btn-file>input[type='file'] { + font-size: 0px !important; +} diff --git a/public/vendor/exment/js/file-required.js b/public/vendor/exment/js/file-required.js new file mode 100644 index 000000000..ea3eb1e10 --- /dev/null +++ b/public/vendor/exment/js/file-required.js @@ -0,0 +1,32 @@ +/** + * File Required Handler + * Handle adding required attribute when file is removed from Bootstrap Fileinput + */ +(function($) { + 'use strict'; + + $(document).ready(function() { + // Handle click on kv-file-remove button + $(document).on('click', '.kv-file-remove', function(event) { + var $removeBtn = $(this); + // Find the closest file input (for both file and image types) + var $fileInputContainer = $removeBtn.closest('.file-input'); + if ($fileInputContainer.length > 0) { + var $fileInput = $fileInputContainer.find('input[type="file"][data-column_type="file"], input[type="file"][data-column_type="image"]'); + if ($fileInput.length > 0) { + // Wait a bit for the file to be actually removed, then check if all files are removed + setTimeout(function() { + // Check if there are any remaining file previews + var hasFiles = $fileInputContainer.find('.file-preview-frame:not(.file-preview-initial)').length > 0; + if (!hasFiles) { + // No files left, add required attribute + $fileInput.attr('required', '1'); + $fileInput.prop('required', true); + } + }, 100); + } + } + }); + }); + +})(jQuery); diff --git a/public/vendor/exment/js/hasmanytable-validation.js b/public/vendor/exment/js/hasmanytable-validation.js new file mode 100644 index 000000000..e78b5278e --- /dev/null +++ b/public/vendor/exment/js/hasmanytable-validation.js @@ -0,0 +1,255 @@ +/** + * Has-Many Table Validation + */ +(function($) { + 'use strict'; + + function keyFromName(name) { + if (!name) return null; + var m = name.match(/\[value\]\[([^\]]+)\]|\[([^\]]+)\]$/); + return m ? (m[1] || m[2]) : null; + } + + function isHidden($el) { + if (!$el || !$el.length) return false; + if ($el.is(':hidden')) return true; + return $el.parents().addBack().filter(function() { + return $(this).css('visibility') === 'hidden'; + }).length > 0; + } + + function outerCell($field) { + var $row = $field.closest('tr.has-many-table-row'); + if ($row.length) { + var $td = $row.children('td').filter(function() { + return this.contains($field[0]); + }).first(); + if ($td.length) return $td; + } + return $field.closest('td'); + } + + function colPos($td) { + var pos = 0; + $td.prevAll('td').each(function() { + var span = parseInt($(this).attr('colspan'), 10); + pos += isNaN(span) ? 1 : span; + }); + return pos; + } + + function headerAt($table, pos) { + var $hit = $(); + var cur = 0; + $table.find('thead tr').first().children('th').each(function() { + var span = parseInt($(this).attr('colspan'), 10); + var w = isNaN(span) ? 1 : span; + if (pos >= cur && pos < cur + w) { + $hit = $(this); + return false; + } + cur += w; + }); + return $hit; + } + + function headerText($th) { + return $th.clone().find('i.fa-info-circle, i.fa, .fa').remove().end().text().trim(); + } + + function fieldLabel($field) { + var nameKey = keyFromName($field.attr('name')); + var $table = $field.closest('table.has-many-table'); + + var label = ''; + if ($table.length) { + var $td = outerCell($field); + if ($td.length) label = headerText(headerAt($table, colPos($td))); + } + + if (label && nameKey && (/^(Action|操作)$/i).test(label)) return nameKey; + if (label) return label; + + var $lbl = $field.closest('.form-group').find('label'); + if ($lbl.length) return $lbl.text().trim(); + + return $field.attr('placeholder') || nameKey || $field.attr('name') || 'Unknown field'; + } + + function tableName($table) { + var $h = $table.closest('.has-many-table-div').find('.field-header'); + if ($h.length) return $h.text().trim(); + + var cls = ($table.attr('class') || '').match(/\bhas-many-table-([^\s]+?)-table\b/); + if (cls && cls[1]) { + return cls[1].replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }); + } + return 'Table'; + } + + function isEmptyRequiredValue($f) { + if (!$f || !$f.length) return true; + if ($f.is(':disabled')) return false; + + if ($f.is('input[type="checkbox"], input[type="radio"]')) { + return !$f.is(':checked'); + } + + if ($f.is('select')) { + var v = $f.val(); + if (Array.isArray(v)) return v.length === 0; + return $.trim(v) === ''; + } + + var val = $f.val(); + return val == null || $.trim(val) === ''; + } + + function findHiddenRequired() { + var bad = []; + + $('.has-many-table').each(function() { + var $table = $(this); + var tname = tableName($table); + + $table.find('tbody tr.has-many-table-row').not('.template').each(function(i) { + var rowNo = i + 1; + + $(this).find('input[required], select[required], textarea[required]').each(function() { + var $f = $(this); + if ($f.is('input[type="hidden"]')) { + var n = $f.attr('name') || ''; + if (n.indexOf('[value][') === -1) return; + } + + // Only block when required field is hidden AND empty + if (!isEmptyRequiredValue($f)) return; + + if (isHidden($f) || isHidden($f.closest('td'))) { + bad.push({ table: tname, row: rowNo, field: fieldLabel($f), element: $f }); + } + }); + }); + }); + + return bad; + } + + function showAlert(fields) { + if (!fields.length) return; + + function escHtml(s) { + return String(s).replace(/[&<>"']/g, function(c) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; + }); + } + + function hval(id, fallback) { + var $el = $('#' + id); + return $el.length ? $el.val() : fallback; + } + + var TITLE = hval('exment_hm_validation_title', 'バリデーションエラー'); + var PLAIN_PREFIX = hval('exment_hm_validation_plain_prefix', '以下の必須項目が非表示になっています:'); + var HTML_PREFIX = hval('exment_hm_validation_html_prefix', '以下の必須項目が非表示になっており、表示する必要があります:'); + var OK_TEXT = hval('exment_hm_validation_ok', 'OK'); + var ROW_TEXT = hval('exment_common_row', '行'); + + // Group: table -> row -> [field...] + var byTableRow = {}; + fields.forEach(function(x) { + byTableRow[x.table] = byTableRow[x.table] || {}; + byTableRow[x.table][x.row] = byTableRow[x.table][x.row] || []; + byTableRow[x.table][x.row].push(x.field); + }); + + var lines = []; + Object.keys(byTableRow).forEach(function(t) { + lines.push(t + ':'); + + Object.keys(byTableRow[t]).sort(function(a, b) { + return parseInt(a, 10) - parseInt(b, 10); + }).forEach(function(r) { + var parts = byTableRow[t][r].map(function(f) { + return '• ' + ROW_TEXT + ' ' + r + ': ' + f; + }); + lines.push(parts.join(' ')); + }); + }); + + var plain = PLAIN_PREFIX + '\n\n' + lines.join('\n'); + + if (typeof toastr !== 'undefined') { + var toastHtml = escHtml(plain).replace(/\n/g, '
'); + toastr.error(toastHtml, TITLE, { + timeOut: 10000, + extendedTimeOut: 5000, + closeButton: true, + progressBar: true, + positionClass: 'toast-top-right', + escapeHtml: false + }); + return; + } + + var html = '
' + escHtml(HTML_PREFIX) + '

'; + Object.keys(byTableRow).forEach(function(t) { + html += '' + t + ':
    '; + Object.keys(byTableRow[t]).sort(function(a, b) { + return parseInt(a, 10) - parseInt(b, 10); + }).forEach(function(r) { + var parts = byTableRow[t][r].map(function(f) { + return escHtml(ROW_TEXT) + ' ' + r + ': ' + escHtml(f) + ''; + }); + html += '
  • ' + parts.join(' / ') + '
  • '; + }); + html += '
'; + }); + html += '
'; + + if (typeof swal !== 'undefined') { + swal({ title: TITLE, text: html, html: true, type: 'error', confirmButtonText: OK_TEXT }); + } else if (typeof Swal !== 'undefined') { + Swal.fire({ title: TITLE, html: html, icon: 'error', confirmButtonText: OK_TEXT }); + } else { + alert(plain); + } + } + + function init() { + var $form = $('form').has('.has-many-table'); + if (!$form.length) return; + + $form.on('submit', function(e) { + var fields = findHiddenRequired(); + if (!fields.length) return; + + e.preventDefault(); + e.stopImmediatePropagation(); + + showAlert(fields); + + if (fields[0].element) { + $('html, body').animate({ + scrollTop: fields[0].element.closest('.has-many-table-div').offset().top - 100 + }, 500); + } + + return false; + }); + + $(document).on('pjax:beforeSend', function(e) { + var $targetForm = $(e.relatedTarget).closest('form'); + if (!$targetForm.has('.has-many-table').length) return; + + var fields = findHiddenRequired(); + if (!fields.length) return; + + e.preventDefault(); + showAlert(fields); + return false; + }); + } + + $(init); +})(jQuery); diff --git a/resources/lang/en/exment.php b/resources/lang/en/exment.php index 3b959a6c3..0992248f9 100644 --- a/resources/lang/en/exment.php +++ b/resources/lang/en/exment.php @@ -226,6 +226,10 @@ ], 'validation' => [ + 'hasmany_hidden_required_title' => 'Validation Error', + 'hasmany_hidden_required_plain_prefix' => 'The following required fields are hidden:', + 'hasmany_hidden_required_html_prefix' => 'The following required fields are hidden and must be shown:', + 'hasmany_hidden_required_ok' => 'OK', 'current_password' => 'The current password is incorrect.', 'password_history' => 'The password is the same as the password registered in the past. Please enter another password.', 'complex_password' => 'The password must be at least 12 characters long and must contain three types of characters (uppercase letters, lowercase letters, numbers, symbols).', diff --git a/resources/lang/ja/exment.php b/resources/lang/ja/exment.php index 713a41358..1719ea4bd 100644 --- a/resources/lang/ja/exment.php +++ b/resources/lang/ja/exment.php @@ -226,6 +226,10 @@ ], 'validation' => [ + 'hasmany_hidden_required_title' => 'バリデーションエラー', + 'hasmany_hidden_required_plain_prefix' => '以下の必須項目が非表示になっています:', + 'hasmany_hidden_required_html_prefix' => '以下の必須項目が非表示になっており、表示する必要があります:', + 'hasmany_hidden_required_ok' => 'OK', 'current_password' => '現在のパスワードが正しくありません。', 'password_history' => '過去に登録したパスワードと同一のパスワードとなっています。他のパスワードを入力してください。', 'complex_password' => 'パスワードは12文字以上で、必ず3種類の文字種(英大文字、英小文字、数字、記号)を含む必要があります。', diff --git a/resources/lang_vendor/en/validation.php b/resources/lang_vendor/en/validation.php new file mode 100644 index 000000000..09df38c95 --- /dev/null +++ b/resources/lang_vendor/en/validation.php @@ -0,0 +1,5 @@ + 'The :attribute has already been taken.', +]; \ No newline at end of file diff --git a/resources/lang_vendor/ja/validation.php b/resources/lang_vendor/ja/validation.php index 7e8d5b206..4a1d1ca36 100644 --- a/resources/lang_vendor/ja/validation.php +++ b/resources/lang_vendor/ja/validation.php @@ -90,6 +90,7 @@ 'string' => ':attributeには文字列を指定してください。', 'timezone' => ':attributeには正しい形式のタイムゾーンを指定してください。', 'unique' => 'その:attributeはすでに使われています。', + 'unique_in_table' => 'その:attributeがすでに存在しています。', 'uploaded' => ':attributeのアップロードに失敗しました。', 'url' => ':attributeには正しい形式のURLを指定してください。', 'summary_condition' => 'この集計タイプは数値項目以外では指定できません。', diff --git a/resources/views/form/field/display.blade.php b/resources/views/form/field/display.blade.php index 24cfd7ee3..b41b531b0 100644 --- a/resources/views/form/field/display.blade.php +++ b/resources/views/form/field/display.blade.php @@ -19,6 +19,8 @@ @endif @endif + {{-- Hidden input to save value to database --}} +
diff --git a/src/ColumnItems/CustomItem.php b/src/ColumnItems/CustomItem.php index 3df8648d8..c04e2529f 100644 --- a/src/ColumnItems/CustomItem.php +++ b/src/ColumnItems/CustomItem.php @@ -452,8 +452,8 @@ protected function getCustomField($classname, $column_name_prefix = null) if (!$this->hidden()) { if ($this->initonly()) { $field->displayText($this->html())->escape(false)->default($this->value)->prepareDefault(); - } elseif ($this->viewonly() && !isset($this->value)) { - // if view only and create, set default value + } elseif ($this->viewonly() && is_null($this->id) && !isset($this->value)) { + // if view only and create (no id), set default value $this->value = $this->getDefaultValue(); $field->displayText($this->html())->escape(false); $this->value = null; diff --git a/src/DataItems/Show/DefaultShow.php b/src/DataItems/Show/DefaultShow.php index ec99bb5fa..b2601c3cc 100644 --- a/src/DataItems/Show/DefaultShow.php +++ b/src/DataItems/Show/DefaultShow.php @@ -39,6 +39,7 @@ use Exceedone\Exment\Enums\ShowPositionType; use Exceedone\Exment\Services\PartialCrudService; use Exceedone\Exment\ColumnItems\ItemInterface; +use Illuminate\Support\Str; /** @phpstan-consistent-constructor */ class DefaultShow extends ShowBase @@ -839,18 +840,30 @@ public function filedelete(Request $request, $form) $custom_item->setCustomValue($this->custom_value)->deleteFile($del_key); } - // reget custom value - $updated_value = getModelName($this->custom_table)::find($this->custom_value->id); + // Reget custom value and updated_at + $updated_at = null; + $parent_value = $this->custom_value->getParentValue(); + $referer = $request->header('referer'); + + if ($parent_value && $referer && Str::contains($referer, '/' . $parent_value->custom_table->table_name . '/')) { + $updated_value = $parent_value->custom_table->getValueQuery() + ->select(['updated_at']) + ->find($parent_value->id); + $updated_at = $updated_value->updated_at ?? null; + } else { + $updated_value = getModelName($this->custom_table)::find($this->custom_value->id); + $updated_at = $updated_value->updated_at ?? null; + } + return getAjaxResponse([ 'result' => true, 'message' => trans('admin.delete_succeeded'), 'reload' => false, 'updateValue' => [ - 'updated_at' => $updated_value->updated_at->format('Y-m-d H:i:s'), + 'updated_at' => $updated_at ? $updated_at->format('Y-m-d H:i:s') : null, ], ]); } - /** * add comment. */ diff --git a/src/Form/Field/HasManyTable.php b/src/Form/Field/HasManyTable.php index 07f7fc33a..a3661ce05 100644 --- a/src/Form/Field/HasManyTable.php +++ b/src/Form/Field/HasManyTable.php @@ -182,14 +182,15 @@ protected function setTableFieldItem(&$field, &$tableitems, &$hiddens, &$require return; } - // if hidden, set $hiddens + // if hidden, set $hiddens (do not add header metadata like required/help) if ($field instanceof Hidden) { $hiddens[] = $field; - } else { - $tableitems[] = $field; + return; } - // if required true false + $tableitems[] = $field; + + // if required true false (header only) $requires[] = is_array($field->getAttributes()) && array_has($field->getAttributes(), 'required'); // set label viewclass hidden diff --git a/src/Form/Navbar/Hidden.php b/src/Form/Navbar/Hidden.php index 270151935..79e1cdaf8 100644 --- a/src/Form/Navbar/Hidden.php +++ b/src/Form/Navbar/Hidden.php @@ -18,6 +18,12 @@ public function render() 'exment_undefined_error' => exmtrans('error.undefined_error'), 'exment_error_title' => exmtrans('common.error'), 'exment_expired_error' => exmtrans('error.expired_error'), + // has-many table validation (JS) + 'exment_hm_validation_title' => exmtrans('validation.hasmany_hidden_required_title'), + 'exment_hm_validation_plain_prefix' => exmtrans('validation.hasmany_hidden_required_plain_prefix'), + 'exment_hm_validation_html_prefix' => exmtrans('validation.hasmany_hidden_required_html_prefix'), + 'exment_hm_validation_ok' => exmtrans('validation.hasmany_hidden_required_ok'), + 'exment_common_row' => exmtrans('common.row'), ], static::getHiddenItemsCommon()); $html = ''; diff --git a/src/Middleware/Bootstrap.php b/src/Middleware/Bootstrap.php index 4c57caa8d..d41301545 100644 --- a/src/Middleware/Bootstrap.php +++ b/src/Middleware/Bootstrap.php @@ -77,6 +77,7 @@ protected function setCssJs(Request $request, \Closure $next) 'vendor/exment/jstree/jstree.min.js', 'vendor/exment/js/common_all.js', 'vendor/exment/js/common.js', + 'vendor/exment/js/file-required.js', 'vendor/exment/js/search.js', 'vendor/exment/js/calc.js', 'vendor/exment/js/notify_navbar.js', @@ -86,6 +87,7 @@ protected function setCssJs(Request $request, \Closure $next) 'vendor/exment/js/customcolumn.js', 'vendor/exment/js/customformitem.js', 'vendor/exment/js/customform.js', + 'vendor/exment/js/hasmanytable-validation.js', 'vendor/exment/js/preview.js', 'vendor/exment/js/webapi.js', 'vendor/exment/js/admin.webapi.js', diff --git a/src/Middleware/BootstrapPublicForm.php b/src/Middleware/BootstrapPublicForm.php index f775b848f..6c3da6622 100644 --- a/src/Middleware/BootstrapPublicForm.php +++ b/src/Middleware/BootstrapPublicForm.php @@ -69,6 +69,7 @@ protected function setCssJs(Request $request, \Closure $next) 'vendor/exment/jstree/jstree.min.js', 'vendor/exment/js/common_all.js', 'vendor/exment/js/common.js', + 'vendor/exment/js/file-required.js', 'vendor/exment/js/calc.js', 'vendor/exment/js/modal.js', 'vendor/exment/js/changefield.js', diff --git a/src/Validator/FileRequredRule.php b/src/Validator/FileRequredRule.php index 4bacf11c9..69ce3f380 100644 --- a/src/Validator/FileRequredRule.php +++ b/src/Validator/FileRequredRule.php @@ -38,9 +38,34 @@ public function passes($attribute, $value) // if has custom_value, checking value if (isset($this->custom_value) && $this->custom_value->exists) { $v = array_get($this->custom_value->value, $this->custom_column->column_name); - return !is_nullorempty($v); } + + // For HasMany nested forms - extract child record ID from attribute name + // Attribute format examples: + // - pivot__{hash}.{id}.value.{column_name} + // - {relation_name}.{id}.value.{column_name} + // Only numeric IDs that exist in DB are valid edit cases + if (preg_match('/\.(\d+)\.value\.([^.]+)$/', $attribute, $matches)) { + $childId = (int)$matches[1]; + $columnName = $matches[2]; + + // Verify column name matches to avoid false positives + if ($columnName !== $this->custom_column->column_name) { + return false; + } + + $customTable = $this->custom_column->custom_table; + if ($customTable && $childId > 0) { + $childRecord = $customTable->getValueModel($childId); + + // Record must exist and belong to correct table + if ($childRecord && $childRecord->exists) { + $existingValue = array_get($childRecord->value, $this->custom_column->column_name); + return !is_nullorempty($existingValue); + } + } + } return false; }