diff --git a/public/vendor/exment/js/scroll-restore.js b/public/vendor/exment/js/scroll-restore.js new file mode 100644 index 000000000..368ca822b --- /dev/null +++ b/public/vendor/exment/js/scroll-restore.js @@ -0,0 +1,128 @@ +/** + * Scroll position restore for custom table list view + * Handles scroll restoration on browser back button and pjax navigation + */ +var Exment; +(function (Exment) { + 'use strict'; + + const CLASSNAME_CUSTOM_VALUE_GRID = '.block_custom_value_grid'; + + // Save current scroll position + function saveCurrentScrollPosition() { + const scrollTop = $(window).scrollTop() || document.documentElement.scrollTop || document.body.scrollTop || 0; + + // Store in history state if available + if (window.history && window.history.replaceState) { + const currentState = window.history.state || {}; + currentState.scrollTop = scrollTop; + try { + window.history.replaceState(currentState, document.title); + } catch (e) { + console.warn('Failed to update history state:', e); + } + } + } + + // Restore scroll position from history state + function restoreScrollPosition() { + let scrollTop = 0; + + // Only restore from history state (for back button) + if (window.history && window.history.state && typeof window.history.state.scrollTop === 'number') { + scrollTop = window.history.state.scrollTop; + } + + if (scrollTop > 0) { + // Use setTimeout to ensure DOM is ready + setTimeout(function() { + window.scrollTo(0, scrollTop); + // Verify and retry if needed + setTimeout(function() { + const currentScroll = window.pageYOffset || document.documentElement.scrollTop; + if (Math.abs(currentScroll - scrollTop) > 50) { + window.scrollTo(0, scrollTop); + } + }, 100); + }, 0); + } + } + + // ScrollRestore module + Exment.ScrollRestore = { + init: function() { + // Prevent multiple initialization + if (window.exmentScrollRestoreInitialized) { + return; + } + window.exmentScrollRestoreInitialized = true; + + // Disable browser's default scroll restoration + if ('scrollRestoration' in history) { + history.scrollRestoration = 'manual'; + } + + restoreScrollPosition(); + + // Save scroll position periodically while scrolling + let scrollTimer; + $(window).on('scroll', function() { + clearTimeout(scrollTimer); + scrollTimer = setTimeout(function() { + // Only save if we're on a list page (grid view) + if ($(CLASSNAME_CUSTOM_VALUE_GRID).length > 0) { + saveCurrentScrollPosition(); + } + }, 150); + }); + + // Save scroll position before leaving page + $(window).on('beforeunload', function() { + if ($(CLASSNAME_CUSTOM_VALUE_GRID).length > 0) { + saveCurrentScrollPosition(); + } + }); + + // Handle pjax events + if ($.pjax) { + let isPjaxPopstate = false; + + $(document).on('pjax:popstate', function() { + isPjaxPopstate = true; + }); + + // Save scroll position before pjax request + $(document).on('pjax:send', function(event, xhr, options) { + if ($(CLASSNAME_CUSTOM_VALUE_GRID).length > 0) { + saveCurrentScrollPosition(); + } + }); + + // Handle scroll position after pjax complete + $(document).on('pjax:end', function(event, xhr, options) { + if ($(CLASSNAME_CUSTOM_VALUE_GRID).length > 0) { + if (isPjaxPopstate) { + // If it's a back/forward navigation, restore scroll + restoreScrollPosition(); + isPjaxPopstate = false; + } else { + // If it's a new navigation (pagination, sort, etc), scroll to top + window.scrollTo(0, 0); + } + } + }); + } + + // Save position when clicking on links in grid + $(CLASSNAME_CUSTOM_VALUE_GRID).on('click', 'a', function() { + saveCurrentScrollPosition(); + }); + } + }; + + // Auto-initialize on document ready + $(function() { + Exment.ScrollRestore.init(); + }); + +})(Exment || (Exment = {})); diff --git a/resources/lang/en/exment.php b/resources/lang/en/exment.php index 3b959a6c3..84d1386cc 100644 --- a/resources/lang/en/exment.php +++ b/resources/lang/en/exment.php @@ -1196,6 +1196,7 @@ 'all_user_viewable_flg' => 'All Users Can View', 'all_user_accessable_flg' => 'All Users Can Access', 'inherit_parent_permission' => 'Viewable with Permissions of Parent data', + 'editable_with_parent' => 'Need Edit Permission for Parend data', 'add_parent_menu_flg' => 'Add Menu', 'add_notify_flg' => 'Add To Notification', 'add_parent_menu' => 'Target Parent Menu', @@ -1278,6 +1279,7 @@ 'all_user_viewable_flg' => 'If set to YES, all users will be able to view all the data in this table.', 'all_user_accessable_flg' => 'If set to YES, all users will be able to see all the data in this table.
*It is not displayed on the menu or list page, it can be displayed only with internal data or reference from another table.', 'inherit_parent_permission' => 'If set to YES, the data can be viewed based on the the parent data(1:N) permissions. However, you must have viewing or similar permissions for this table itself.', + 'editable_with_parent' => 'If set to YES, you need edit permissions for the parent data(1:N) to edit the data in this table.', 'add_parent_menu_flg' => 'After creating custom table, you can add it to the menu. To add it, please set it to YES. *It will be displayed after updating the browser.
*It can be set only when new table is created. When updating please set it from "Menu" page.', 'add_parent_menu' => 'Please select the menu name to be parent.', 'add_notify_flg' => 'You can add settings for performing in-system notification to authorized users when creating/updating/sharing/commenting data, after creating a new table. Please add YES if you want to add.
* It can be set only when creating a new table. Please set from the "notification" page when updating.', diff --git a/resources/lang/ja/exment.php b/resources/lang/ja/exment.php index 713a41358..d8998a146 100644 --- a/resources/lang/ja/exment.php +++ b/resources/lang/ja/exment.php @@ -1197,6 +1197,7 @@ 'all_user_viewable_flg' => '全ユーザーが閲覧可能', 'all_user_accessable_flg' => '全ユーザーが参照可能', 'inherit_parent_permission' => '親データの権限で閲覧可能', + 'editable_with_parent' => '親データの編集権限が必要', 'add_parent_menu_flg' => 'メニューに追加する', 'add_notify_flg' => '通知に追加する', 'add_parent_menu' => '追加先の親メニュー', @@ -1279,6 +1280,7 @@ 'all_user_viewable_flg' => 'YESにした場合、すべてのユーザーが、このテーブルのすべてのデータを閲覧可能になります。', 'all_user_accessable_flg' => 'YESにした場合、すべてのユーザーが、このテーブルのすべてのデータを参照可能になります。
※メニューや一覧画面では表示されず、内部データや、他のテーブルからの参照でのみ表示できます。', 'inherit_parent_permission' => 'YESにした場合、親データ(1対多)の権限でデータを閲覧できます。ただし、このテーブル自体に担当者の閲覧等の権限を持っている必要があります。', + 'editable_with_parent' => 'YESにした場合、このテーブルのデータを編集するには親データ(1対多)の編集権限が必要になります。', 'add_parent_menu_flg' => '新規作成後、メニューに追加することができます。追加する場合はYESにしてください。
※ブラウザ更新後に表示されます。
※テーブルの新規作成時のみ設定できます。更新時は「メニュー」画面より設定してください。', 'add_parent_menu' => '親にするメニュー名を選択してください。', 'add_notify_flg' => 'データの新規作成・更新・共有、コメント時に、権限のあるユーザーに、システム内通知を行う設定を、テーブルの新規作成後に追加することができます。追加する場合はYESにしてください。
※テーブルの新規作成時のみ設定できます。更新時は「通知」画面より設定してください。', diff --git a/src/Controllers/ApiDataTrait.php b/src/Controllers/ApiDataTrait.php index 76aa611ee..dbdf0f140 100644 --- a/src/Controllers/ApiDataTrait.php +++ b/src/Controllers/ApiDataTrait.php @@ -16,6 +16,7 @@ use Exceedone\Exment\Enums\SearchType; use Exceedone\Exment\Enums\ValueType; use Exceedone\Exment\Enums\ErrorCode; +use Exceedone\Exment\Enums\RelationType; use Validator; /** @@ -202,6 +203,7 @@ protected function modifyAfterGetValue(Request $request, $target, $options = []) 'id', 'target_view_id', 'children', + 'parents', ]), $options['appends'] ); @@ -267,6 +269,11 @@ protected function modifyCustomValue(Request $request, $custom_value, $recursive $custom_value = $this->modifyChildrenValue($request, $custom_value); } + // Change relation key name + if (!$recursive && $request->has('parents') && boolval($request->get('parents'))) { + $custom_value = $this->modifyParentsValue($request, $custom_value); + } + // convert to custom values $valuetype = $request->get('valuetype'); if ($request->has('valuetype') && ValueType::isRegetApiCustomValue($valuetype)) { @@ -386,10 +393,22 @@ protected function setQueryInfo($query) $query->with($relation->getRelationName()); } } + if ($request->has('parents') && boolval($request->get('parents'))) { + $relations = CustomRelation::getRelationsByChild($this->custom_table); + foreach ($relations as $relation) { + $query->with($relation->getRelationName()); + } + } return $query; } + /** + * Modify children values (exclude virtual columns from children values) + * + * @param Request $request + * @return CustomValue $custom_value + */ protected function modifyChildrenValue(Request $request, $custom_value) { $relations = CustomRelation::getRelationsByParent($this->custom_table); @@ -417,4 +436,46 @@ protected function modifyChildrenValue(Request $request, $custom_value) $custom_value['children'] = $results; return $custom_value; } + + /** + * Modify parents values (exclude virtual columns from parents values) + * + * @param Request $request + * @return CustomValue $custom_value + */ + protected function modifyParentsValue(Request $request, $custom_value) + { + $relations = CustomRelation::getRelationsByChild($this->custom_table); + + $results = []; + foreach ($relations as $relation) { + // If getted relation name, change key name + $reltionName = $relation->getRelationName(); + if (array_has($custom_value, $reltionName)) { + $relationValues = $custom_value[$reltionName]; + $makeHiddenArray = $relation->parent_custom_table_cache->getMakeHiddenArray(); + + if ($relation->relation_type == RelationType::ONE_TO_MANY) { + // Call makehidden + $relationValues = $relationValues->makeHidden($makeHiddenArray); + // Call modify custom value + $relationValues = $this->modifyCustomValue($request, $relationValues, true); + } else { + $relationValues = $relationValues->map(function ($relationValue) use ($makeHiddenArray, $request) { + // Call makehidden + $relationValue = $relationValue->makeHidden($makeHiddenArray); + // Call modify custom value + $relationValue = $this->modifyCustomValue($request, $relationValue, true); + return $relationValue; + }); + } + // Set key name + $results[$relation->parent_custom_table_cache->table_name] = $relationValues; + unset($custom_value[$reltionName]); + } + } + + $custom_value['parents'] = $results; + return $custom_value; + } } diff --git a/src/Controllers/CustomTableController.php b/src/Controllers/CustomTableController.php index ceadd0fdf..339bceede 100644 --- a/src/Controllers/CustomTableController.php +++ b/src/Controllers/CustomTableController.php @@ -318,6 +318,9 @@ protected function form($id = null) if ($has_parent) { $form->switchbool('inherit_parent_permission', exmtrans("custom_table.inherit_parent_permission"))->help(exmtrans("custom_table.help.inherit_parent_permission")) ->default("0"); + + $form->switchbool('editable_with_parent', exmtrans("custom_table.editable_with_parent"))->help(exmtrans("custom_table.help.editable_with_parent")) + ->default("1"); } })->disableHeader(); diff --git a/src/DataItems/Grid/DefaultGrid.php b/src/DataItems/Grid/DefaultGrid.php index 140f7584f..087ca1141 100644 --- a/src/DataItems/Grid/DefaultGrid.php +++ b/src/DataItems/Grid/DefaultGrid.php @@ -558,10 +558,18 @@ protected function manageRowAction($grid) $enableCreate = false; } - if (!is_null($parent_value = $actions->row->getParentValue(null, true)) && $parent_value->enableEdit(true) !== true) { - $enableCreate = false; - $enableEdit = false; - $enableDelete = false; + if (!is_null($parent_value = $actions->row->getParentValue(null, true))) { + if (boolval($custom_table->getOption('editable_with_parent')??1)) { + if ($parent_value->enableEdit(true) !== true) { + $enableCreate = false; + $enableEdit = false; + $enableDelete = false; + } + } elseif ($parent_value->enableAccess() !== true) { + $enableCreate = false; + $enableEdit = false; + $enableDelete = false; + } } if (!$enableEdit) { diff --git a/src/DataItems/Show/DefaultShow.php b/src/DataItems/Show/DefaultShow.php index ec99bb5fa..a3afdb806 100644 --- a/src/DataItems/Show/DefaultShow.php +++ b/src/DataItems/Show/DefaultShow.php @@ -170,9 +170,16 @@ public function createShowForm() $tools->disableList(); } - if (!is_null($parent_value = $this->custom_value->getParentValue(null, true)) && $parent_value->enableEdit(true) !== true) { - $tools->disableEdit(); - $tools->disableDelete(); + if (!is_null($parent_value = $this->custom_value->getParentValue(null, true))) { + if (boolval($this->custom_table->getOption('editable_with_parent')??1)) { + if ($parent_value->enableEdit(true) !== true) { + $tools->disableEdit(); + $tools->disableDelete(); + } + } elseif ($parent_value->enableAccess() !== true) { + $tools->disableEdit(); + $tools->disableDelete(); + } } if ($this->modal) { diff --git a/src/Jobs/MailSendJob.php b/src/Jobs/MailSendJob.php index feabdeea3..1d9dc0d33 100644 --- a/src/Jobs/MailSendJob.php +++ b/src/Jobs/MailSendJob.php @@ -28,13 +28,13 @@ class MailSendJob extends Notification implements ShouldQueue * @var MailHistory */ protected $mailHistory; - protected $user; + protected $userId; protected $finalUser; - public function __construct($user = null, $finalUser = false) + public function __construct($userId = null, $finalUser = false) { - $this->user = $user; + $this->userId = $userId; $this->finalUser = $finalUser; } @@ -104,7 +104,7 @@ public function failed($exception) $mail_template->getValue('mail_subject'), $mail_template->getValue('mail_body'), $this->notify_id ?? -1, - $this->user->id, + $this->userId, \Exment::getUserId() ?? null, $this->mailHistory->getParentId(), $this->mailHistory->getParentType() diff --git a/src/Middleware/Bootstrap.php b/src/Middleware/Bootstrap.php index 4c57caa8d..e15b08037 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/scroll-restore.js', 'vendor/exment/js/search.js', 'vendor/exment/js/calc.js', 'vendor/exment/js/notify_navbar.js', diff --git a/src/Model/CustomValue.php b/src/Model/CustomValue.php index 8b6718241..27c02193b 100644 --- a/src/Model/CustomValue.php +++ b/src/Model/CustomValue.php @@ -1794,8 +1794,17 @@ public function enableEdit($checkFormAction = false) return ErrorCode::WORKFLOW_LOCK(); } - if (!is_null($parent_value = $this->getParentValue()) && ($code = $parent_value->enableEdit($checkFormAction)) !== true) { - return $code; + // check parent permission + if (!is_null($parent_value = $this->getParentValue())) { + if (boolval($this->custom_table->getOption('editable_with_parent')??1)) { + if (($code = $parent_value->enableEdit($checkFormAction)) !== true) { + return $code; + } + } else { + if (($code = $parent_value->enableAccess()) !== true) { + return $code; + } + } } if ($this->trashed()) { @@ -1834,8 +1843,17 @@ public function enableDelete($checkFormAction = false) return ErrorCode::DELETE_DISABLED(); } - if (!is_null($parent_value = $this->getParentValue()) && ($code = $parent_value->enableDelete($checkFormAction)) !== true) { - return $code; + // check parent permission + if (!is_null($parent_value = $this->getParentValue())) { + if (boolval($this->custom_table->getOption('editable_with_parent')??1)) { + if (($code = $parent_value->enableDelete($checkFormAction)) !== true) { + return $code; + } + } else { + if (($code = $parent_value->enableAccess()) !== true) { + return $code; + } + } } return true; diff --git a/src/Notifications/MailSender.php b/src/Notifications/MailSender.php index 2acdc66e7..2973c3dc8 100644 --- a/src/Notifications/MailSender.php +++ b/src/Notifications/MailSender.php @@ -241,7 +241,7 @@ protected function sendMail() ->setFromName($fromName) ->setBodyType($bodyType); - $job = new MailSendJob(\Exment::user(), $this->final_user); + $job = new MailSendJob(\Exment::getUserId(), $this->final_user); $job->setMailInfo($this->mailInfo) ->setMailHistory($this->mailHistory); $this->notify($job); @@ -281,7 +281,7 @@ protected function sendPasswordMail() ->setMailTemplate($mail_template) ->setHistory(false); - $job = new MailSendJob(\Exment::user(), $this->final_user); + $job = new MailSendJob(\Exment::getUserId(), $this->final_user); $job->setMailInfo($mailInfo) ->setMailHistory($mailHistory); diff --git a/src/Services/Search/SearchService.php b/src/Services/Search/SearchService.php index 26dc4ada6..ac9bfc676 100644 --- a/src/Services/Search/SearchService.php +++ b/src/Services/Search/SearchService.php @@ -466,7 +466,7 @@ protected function setSummaryOrderBy($column, $wrap_column) { $sort_order = array_get($column->options, 'sort_order'); if (is_nullorempty($sort_order)) { - return $this; + $sort_order = 1; } $sort_type = isMatchString(array_get($column->options, 'sort_type'), '-1') ? 'desc' : 'asc'; diff --git a/tests/Feature/Api2Test.php b/tests/Feature/Api2Test.php index 2618fff15..cfc4315d4 100644 --- a/tests/Feature/Api2Test.php +++ b/tests/Feature/Api2Test.php @@ -662,6 +662,56 @@ public function testGetValuesWithChildren2() } } + /** + * Getting values parents(1:N), and match ids + */ + /** + * @return void + */ + public function testGetValuesWithParents() + { + $token = $this->getAdminAccessToken([ApiScope::VALUE_READ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer $token", + ])->get(admin_urls('api', 'data', TestDefine::TESTDATA_TABLE_NAME_CHILD_TABLE).'?parents=1&count=5') + ->assertStatus(200) + ->assertJsonCount(5, 'data'); + + // check children + $json = json_decode_ex($response->baseResponse->getContent(), true); + $data = array_get($json, 'data'); + + foreach ($data as $d) { + $this->_testParentsValues($d, TestDefine::TESTDATA_TABLE_NAME_CHILD_TABLE, array_get($d, 'id')); + } + } + + /** + * Getting values parents(N:N), and match ids + */ + /** + * @return void + */ + public function testGetValuesWithParents2() + { + $token = $this->getAdminAccessToken([ApiScope::VALUE_READ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer $token", + ])->get(admin_urls('api', 'data', TestDefine::TESTDATA_TABLE_NAME_CHILD_TABLE_MANY_TO_MANY).'?parents=1&count=5&page=2') + ->assertStatus(200) + ->assertJsonCount(5, 'data'); + + // check children + $json = json_decode_ex($response->baseResponse->getContent(), true); + $data = array_get($json, 'data'); + + foreach ($data as $d) { + $this->_testParentsValues($d, TestDefine::TESTDATA_TABLE_NAME_CHILD_TABLE_MANY_TO_MANY, array_get($d, 'id')); + } + } + /** * @return void */ @@ -808,6 +858,52 @@ public function testGetValueWithChildren2() $this->_testChildrenValues($d, TestDefine::TESTDATA_TABLE_NAME_PARENT_TABLE_MANY_TO_MANY, array_get($d, 'id')); } + /** + * Getting value with parents, and match ids + */ + /** + * @return void + */ + public function testGetValueWithParents() + { + $token = $this->getAdminAccessToken([ApiScope::VALUE_READ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer $token", + ])->get(admin_urls('api', 'data', TestDefine::TESTDATA_TABLE_NAME_CHILD_TABLE, '1?parents=1')) + ->assertStatus(200) + ->assertJsonFragment([ + 'id' => 1 + ]); + + // check children + $d = json_decode_ex($response->baseResponse->getContent(), true); + $this->_testParentsValues($d, TestDefine::TESTDATA_TABLE_NAME_CHILD_TABLE, array_get($d, 'id')); + } + + /** + * Getting value with parents, and match ids + */ + /** + * @return void + */ + public function testGetValueWithParents2() + { + $token = $this->getAdminAccessToken([ApiScope::VALUE_READ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer $token", + ])->get(admin_urls('api', 'data', TestDefine::TESTDATA_TABLE_NAME_CHILD_TABLE_MANY_TO_MANY, '2?parents=1')) + ->assertStatus(200) + ->assertJsonFragment([ + 'id' => 2 + ]); + + // check children + $d = json_decode_ex($response->baseResponse->getContent(), true); + $this->_testParentsValues($d, TestDefine::TESTDATA_TABLE_NAME_CHILD_TABLE_MANY_TO_MANY, array_get($d, 'id')); + } + /** * @return void */ @@ -3038,4 +3134,55 @@ protected function _testChildrenValues($data, $table_name, $id) } } } + + /** + * @param mixed $data + * @param string $table_name + * @param mixed $id + * @return void + */ + protected function _testParentsValues($data, $table_name, $id) + { + $relations = CustomRelation::getRelationsByChild($table_name); + + // Whether has children + $this->assertTrue(array_has($data, 'parents')); + foreach ($relations as $relation) { + $this->assertTrue(array_has($data, 'parents.' . $relation->parent_custom_table_cache->table_name)); + $parents = array_get($data, 'parents.' . $relation->parent_custom_table_cache->table_name); + + if ($relation->relation_type == RelationType::ONE_TO_MANY) { + // Get value directly with parent_id + $value = $relation->child_custom_table_cache->getValueQuery()->find($id); + $parentId = array_get($parents, 'id'); + $this->assertTrue($parentId == $value->parent_id, "parent_id expects equals {$value->parent_id}', but {$parentId}."); + } + ////// Check as n:n + else { + // get parents id + $parents_ids = collect($parents)->map(function ($parent) { + return array_get($parent, 'id'); + })->toArray(); + + // Get value using pivot table + $parentIds = \DB::table($relation->getRelationName()) + ->where('child_id', $id) + ->select('parent_id') + ->distinct() + ->pluck('parent_id') + ->toArray(); + + if (count($parentIds) == 0) { + $this->assertTrue(count($parents_ids) == 0, "{$id}' parents count expects 0, but real count is " . count($parents_ids)); + } else { + foreach ($parentIds as $parentId) { + $this->assertTrue(in_array($parentId, $parents_ids), "{$parentId} expects containing {$id}' parents, but not has."); + } + foreach ($parents_ids as $parentId) { + $this->assertTrue(in_array($parentId, $parentIds), "{$parentId} expects containing {$id}' parents, but not has."); + } + } + } + } + } }