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 + ':'; + }); + 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 84d1386cc..d3eeb993f 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 d8998a146..77821888a 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 a3afdb806..159c37921 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 @@ -846,18 +847,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 e15b08037..a39d8c950 100644 --- a/src/Middleware/Bootstrap.php +++ b/src/Middleware/Bootstrap.php @@ -78,6 +78,7 @@ protected function setCssJs(Request $request, \Closure $next) 'vendor/exment/js/common_all.js', 'vendor/exment/js/common.js', 'vendor/exment/js/scroll-restore.js', + 'vendor/exment/js/file-required.js', 'vendor/exment/js/search.js', 'vendor/exment/js/calc.js', 'vendor/exment/js/notify_navbar.js', @@ -87,6 +88,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; }