From dc34f6bb3d5f6b592b972a5ed2a6e9c3612d212c Mon Sep 17 00:00:00 2001 From: grafst Date: Fri, 4 Sep 2026 15:13:06 +0200 Subject: [PATCH] Do not trigger unsaved changes alert for action modals that cannot contain unsaved changes (#20331) * Do not trigger unsaved changes alert for action modals that cannot contain unsaved changes The panel-level unsaved changes alert hooked `beforeunload` whenever any action was mounted, so read-only modals such as `ViewAction`'s warned about unsaved changes that cannot exist. Mounted actions now expose a `hasUnsavedChangesAlert` flag that the client-side guard checks, which defaults to `false` for actions with a disabled schema and can be controlled per action with `unsavedChangesAlert()`. Co-Authored-By: Claude Fable 5 * Fix unsaved action changes alert edge cases Co-authored-by: Amp * Prevent stale unsaved action alert state Co-authored-by: Amp --------- Co-authored-by: Claude Fable 5 Co-authored-by: Dan Harrin Co-authored-by: Amp --- docs/05-panel-configuration.md | 2 + packages/actions/docs/02-modals.md | 20 +++++ .../actions/src/Concerns/CanOpenModal.php | 14 ++++ .../src/Concerns/InteractsWithActions.php | 6 ++ packages/panels/dist/index.js | 2 +- .../resources/js/unsaved-changes-alert.js | 4 +- ...saved-changes-alert-browser-test.blade.php | 1 + tests/src/Actions/ActionTest.php | 56 ++++++++++++++ tests/src/Actions/ModalTest.php | 73 +++++++++++++++++++ tests/src/Actions/ViewActionTest.php | 9 +++ tests/src/Fixtures/Pages/Actions.php | 46 ++++++++++++ .../Pages/UnsavedChangesAlertBrowserTest.php | 57 +++++++++++++++ .../Fixtures/Providers/AdminPanelProvider.php | 3 + 13 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 tests/resources/views/pages/unsaved-changes-alert-browser-test.blade.php create mode 100644 tests/src/Fixtures/Pages/UnsavedChangesAlertBrowserTest.php diff --git a/docs/05-panel-configuration.md b/docs/05-panel-configuration.md index d1dfc62e19..211ff09539 100644 --- a/docs/05-panel-configuration.md +++ b/docs/05-panel-configuration.md @@ -265,6 +265,8 @@ public function panel(Panel $panel): Panel } ``` +Action modals with a disabled schema, such as the read-only `ViewAction` modal, do not trigger the alert. You may also [disable the alert for another action that cannot contain unsaved changes](actions/modals#disabling-the-unsaved-changes-alert) using the `unsavedChangesAlert(false)` method. + ## Enabling database transactions By default, Filament does not wrap operations in database transactions, and allows the user to enable this themselves when they have tested to ensure that their operations are safe to be wrapped in a transaction. However, you can enable database transactions at once for all operations by using the `databaseTransactions()` method: diff --git a/packages/actions/docs/02-modals.md b/packages/actions/docs/02-modals.md index a0bcc05e30..ad47f7b8ac 100644 --- a/packages/actions/docs/02-modals.md +++ b/packages/actions/docs/02-modals.md @@ -747,6 +747,26 @@ use Filament\Support\View\Components\ModalComponent; ModalComponent::closedByEscaping(false); ``` +### Disabling the unsaved changes alert + +When [unsaved changes alerts](../panel-configuration#unsaved-changes-alerts) are enabled for a panel, users are warned before leaving the page while an action modal is open. If a specific action's modal cannot contain unsaved changes, you can disable the warning for it using the `unsavedChangesAlert(false)` method: + +```php +use Filament\Actions\Action; +use Filament\Infolists\Components\TextEntry; + +Action::make('viewAuthor') + ->schema([ + TextEntry::make('name'), + TextEntry::make('email'), + ]) + ->unsavedChangesAlert(false) +``` + +The `unsavedChangesAlert()` method also accepts a function to dynamically calculate the value. You can inject various utilities into the function as parameters. + +By default, actions with a [disabled schema](#disabling-all-form-fields), such as `ViewAction`, do not trigger the alert, since their modals do not accept user input. + ### Hiding the modal close button By default, modals have a close button in the top right corner. If you wish to hide the close button, you can use the `modalCloseButton(false)` method: diff --git a/packages/actions/src/Concerns/CanOpenModal.php b/packages/actions/src/Concerns/CanOpenModal.php index 4e2da349e4..ddda07d4e0 100644 --- a/packages/actions/src/Concerns/CanOpenModal.php +++ b/packages/actions/src/Concerns/CanOpenModal.php @@ -93,6 +93,8 @@ trait CanOpenModal protected bool | Closure | null $isModalAutofocused = null; + protected bool | Closure | null $hasUnsavedChangesAlert = null; + protected string | BackedEnum | Htmlable | Closure | null $modalIcon = null; /** @@ -121,6 +123,13 @@ trait CanOpenModal return $this; } + public function unsavedChangesAlert(bool | Closure | null $condition = true): static + { + $this->hasUnsavedChangesAlert = $condition; + + return $this; + } + /** * @deprecated Use `modalAlignment(Alignment::Center)` instead. */ @@ -730,6 +739,11 @@ trait CanOpenModal return $this->evaluate($this->isModalAutofocused) ?? ModalComponent::$isAutofocused; } + public function hasUnsavedChangesAlert(): bool + { + return (bool) ($this->evaluate($this->hasUnsavedChangesAlert) ?? (! $this->isSchemaDisabled())); + } + /** * @deprecated Use `makeModalSubmitAction()` instead. * diff --git a/packages/actions/src/Concerns/InteractsWithActions.php b/packages/actions/src/Concerns/InteractsWithActions.php index d5e9849fec..680165a475 100644 --- a/packages/actions/src/Concerns/InteractsWithActions.php +++ b/packages/actions/src/Concerns/InteractsWithActions.php @@ -172,6 +172,9 @@ trait InteractsWithActions /** @phpstan-ignore trait.unused */ } } + // Keep a reference to this entry since the action's mount lifecycle may replace it. + $mountedActionState = &$this->mountedActions[$action->getNestingIndex()]; + try { if ( $action->hasAuthorizationNotification() && @@ -212,6 +215,9 @@ trait InteractsWithActions /** @phpstan-ignore trait.unused */ throw $exception; } + $mountedActionState['hasUnsavedChangesAlert'] = $action->hasUnsavedChangesAlert(); + unset($mountedActionState); + if (! $this->mountedActionShouldOpenModal(mountedAction: $action)) { if ($context['mountedFromUrl'] ?? false) { // A modal-less action mounted from the URL has nothing to show the user, so diff --git a/packages/panels/dist/index.js b/packages/panels/dist/index.js index f0aad1b120..5983d50029 100644 --- a/packages/panels/dist/index.js +++ b/packages/panels/dist/index.js @@ -1 +1 @@ -(()=>{var re=Object.create,N=Object.defineProperty,ne=Object.getPrototypeOf,ie=Object.prototype.hasOwnProperty,oe=Object.getOwnPropertyNames,se=Object.getOwnPropertyDescriptor,ae=t=>N(t,"__esModule",{value:!0}),le=(t,n)=>()=>(n||(n={exports:{}},t(n.exports,n)),n.exports),fe=(t,n,i)=>{if(n&&typeof n=="object"||typeof n=="function")for(let s of oe(n))!ie.call(t,s)&&s!=="default"&&N(t,s,{get:()=>n[s],enumerable:!(i=se(n,s))||i.enumerable});return t},ue=t=>fe(ae(N(t!=null?re(ne(t)):{},"default",t&&t.__esModule&&"default"in t?{get:()=>t.default,enumerable:!0}:{value:t,enumerable:!0})),t),ce=le((t,n)=>{(function(i,s,w){if(!i)return;for(var u={8:"backspace",9:"tab",13:"enter",16:"shift",17:"ctrl",18:"alt",20:"capslock",27:"esc",32:"space",33:"pageup",34:"pagedown",35:"end",36:"home",37:"left",38:"up",39:"right",40:"down",45:"ins",46:"del",91:"meta",93:"meta",224:"meta"},y={106:"*",107:"+",109:"-",110:".",111:"/",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"},v={"~":"`","!":"1","@":"2","#":"3",$:"4","%":"5","^":"6","&":"7","*":"8","(":"9",")":"0",_:"-","+":"=",":":";",'"':"'","<":",",">":".","?":"/","|":"\\"},k={option:"alt",command:"meta",return:"enter",escape:"esc",plus:"+",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},C,m=1;m<20;++m)u[111+m]="f"+m;for(m=0;m<=9;++m)u[m+96]=m.toString();function P(e,r,a){if(e.addEventListener){e.addEventListener(r,a,!1);return}e.attachEvent("on"+r,a)}function U(e){if(e.type=="keypress"){var r=String.fromCharCode(e.which);return e.shiftKey||(r=r.toLowerCase()),r}return u[e.which]?u[e.which]:y[e.which]?y[e.which]:String.fromCharCode(e.which).toLowerCase()}function F(e,r){return e.sort().join(",")===r.sort().join(",")}function V(e){var r=[];return e.shiftKey&&r.push("shift"),e.altKey&&r.push("alt"),e.ctrlKey&&r.push("ctrl"),e.metaKey&&r.push("meta"),r}function H(e){if(e.preventDefault){e.preventDefault();return}e.returnValue=!1}function B(e){if(e.stopPropagation){e.stopPropagation();return}e.cancelBubble=!0}function E(e){return e=="shift"||e=="ctrl"||e=="alt"||e=="meta"}function X(){if(!C){C={};for(var e in u)e>95&&e<112||u.hasOwnProperty(e)&&(C[u[e]]=e)}return C}function Y(e,r,a){return a||(a=X()[e]?"keydown":"keypress"),a=="keypress"&&r.length&&(a="keydown"),a}function Q(e){return e==="+"?["+"]:(e=e.replace(/\+{2}/g,"+plus"),e.split("+"))}function G(e,r){var a,h,O,A=[];for(a=Q(e),O=0;O1){$(o,b,f,p);return}c=G(o,p),r._callbacks[c.key]=r._callbacks[c.key]||[],I(c.key,c.modifiers,{type:c.action},l,o,d),r._callbacks[c.key][l?"unshift":"push"]({callback:f,modifiers:c.modifiers,action:c.action,seq:l,level:d,combo:o})}r._bindMultiple=function(o,f,p){for(var l=0;l-1||R(r,a.target))return!1;if("composedPath"in e&&typeof e.composedPath=="function"){var h=e.composedPath()[0];h!==e.target&&(r=h)}return r.tagName=="INPUT"||r.tagName=="SELECT"||r.tagName=="TEXTAREA"||r.isContentEditable},g.prototype.handleKey=function(){var e=this;return e._handleKey.apply(e,arguments)},g.addKeycodes=function(e){for(var r in e)e.hasOwnProperty(r)&&(u[r]=e[r]);C=null},g.init=function(){var e=g(s);for(var r in e)r.charAt(0)!=="_"&&(g[r]=(function(a){return function(){return e[a].apply(e,arguments)}})(r))},g.init(),i.Mousetrap=g,typeof n<"u"&&n.exports&&(n.exports=g),typeof define=="function"&&define.amd&&define(function(){return g})})(typeof window<"u"?window:null,typeof window<"u"?document:null)}),K=ue(ce());(function(t){if(t){var n={},i=t.prototype.stopCallback;t.prototype.stopCallback=function(s,w,u,y){var v=this;return v.paused?!0:n[u]||n[y]?!1:i.call(v,s,w,u)},t.prototype.bindGlobal=function(s,w,u){var y=this;if(y.bind(s,w,u),s instanceof Array){for(var v=0;vArray.from(document.querySelectorAll('[aria-modal="true"]')).find(t=>window.getComputedStyle(t).display!=="none"),pe=t=>{t.directive("mousetrap",(n,{modifiers:i,expression:s},{cleanup:w,evaluate:u})=>{let y=()=>s?u(s):n.click();i=i.map(v=>v.replace(/--/g," ").replace(/-/g,"+").replace(/\bslash\b/g,"/")),i.includes("global")&&(i=i.filter(v=>v!=="global"),K.default.bindGlobal(i,v=>{let k=J();k&&!k.contains(n)||(v.preventDefault(),y())})),K.default.bind(i,v=>{let k=J();k&&!k.contains(n)||(v.preventDefault(),y())}),w(()=>{K.default.unbind(i)})})},W=pe;var j=()=>({isOpen:window.Alpine.$persist(!0).as("isOpen"),isOpenDesktop:window.Alpine.$persist(!0).as("isOpenDesktop"),collapsedGroups:window.Alpine.$persist(null).as("collapsedGroups"),scrollTop:0,init(){this.resizeObserver=null,this.setUpResizeObserver(),document.addEventListener("livewire:navigate",()=>{let t=document.querySelector(".fi-main-sidebar .fi-sidebar-nav");t&&(this.scrollTop=t.scrollTop)}),document.addEventListener("livewire:navigated",()=>{this.setUpResizeObserver(),requestAnimationFrame(()=>{let t=document.querySelector(".fi-main-sidebar .fi-sidebar-nav");t&&this.scrollTop&&(t.scrollTop=this.scrollTop)})})},setUpResizeObserver(){this.resizeObserver&&this.resizeObserver.disconnect();let t=window.innerWidth;this.resizeObserver=new ResizeObserver(()=>{let n=window.innerWidth,i=t>=1024,s=n<1024,w=n>=1024;i&&s?(this.isOpenDesktop=this.isOpen,this.isOpen&&this.close()):!i&&w&&(this.isOpen=this.isOpenDesktop),t=n}),this.resizeObserver.observe(document.body),window.innerWidth<1024?this.isOpen&&(this.isOpenDesktop=!0,this.close()):this.isOpen=this.isOpenDesktop},groupIsCollapsed(t){return this.collapsedGroups.includes(t)},collapseGroup(t){this.collapsedGroups.includes(t)||(this.collapsedGroups=this.collapsedGroups.concat(t))},toggleCollapsedGroup(t){this.collapsedGroups=this.collapsedGroups.includes(t)?this.collapsedGroups.filter(n=>n!==t):this.collapsedGroups.concat(t)},close(){this.isOpen=!1,window.innerWidth>=1024&&(this.isOpenDesktop=!1)},open(){this.isOpen=!0,window.innerWidth>=1024&&(this.isOpenDesktop=!0)}});document.addEventListener("alpine:init",()=>{let t=localStorage.getItem("theme")??getComputedStyle(document.documentElement).getPropertyValue("--default-theme-mode");window.Alpine.store("theme",t==="dark"||t==="system"&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),window.addEventListener("theme-changed",n=>{let i=n.detail;localStorage.setItem("theme",i),i==="system"&&(i=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),window.Alpine.store("theme",i)}),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",n=>{localStorage.getItem("theme")==="system"&&window.Alpine.store("theme",n.matches?"dark":"light")}),window.Alpine.effect(()=>{window.Alpine.store("theme")==="dark"?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark")})});document.addEventListener("livewire:init",()=>{Livewire.hook("request",({payload:t,fail:n})=>{n(({status:i,preventDefault:s})=>{let w=window.filamentErrorNotifications;if(!w)return;if(JSON.parse(t).components.length===1){for(let y of JSON.parse(t).components)if(JSON.parse(y.snapshot).data.isFilamentNotificationsComponent)return}let u=w[i]??w[""];u.isDisabled!==!0&&(s(),u.isHidden!==!0&&new FilamentNotification().title(u.title).body(u.body).danger().send())})})});var x=window.history.replaceState,de=window.history.pushState;window.history.replaceState=function(t,n,i){t?.url instanceof URL&&(t.url=t.url.toString());let s=i||t?.url||window.location.href,w=window.location.href;if(s!==w){x.call(window.history,t,n,i);return}try{let u=window.history.state;JSON.stringify(t)!==JSON.stringify(u)&&x.call(window.history,t,n,i)}catch{x.call(window.history,t,n,i)}};window.history.pushState=function(t,n,i){t?.url instanceof URL&&(t.url=t.url.toString()),de.call(window.history,t,n,i)};document.addEventListener("livewire:init",()=>{window.Livewire.hook("component.init",({component:t})=>{t.el?.classList?.contains("fi-resource-create-record-page")&&t.snapshot?.data?.isCreating===!0&&(t.$wire.isCreating=!1)})});document.addEventListener("DOMContentLoaded",()=>{setTimeout(()=>{let t=document.querySelector(".fi-main-sidebar .fi-sidebar-item.fi-active");if((!t||t.offsetParent===null)&&(t=document.querySelector(".fi-main-sidebar .fi-sidebar-group.fi-active")),!t||t.offsetParent===null)return;let n=document.querySelector(".fi-main-sidebar .fi-sidebar-nav");n&&n.scrollTo(0,t.offsetTop-window.innerHeight/2)},10)});window.setUpUnsavedDataChangesAlert=({body:t,livewireComponent:n,$wire:i})=>{window.addEventListener("beforeunload",s=>{window.jsMd5(JSON.stringify(i.data).replace(/\\/g,""))===i.savedDataHash||i?.__instance?.effects?.redirect||(s.preventDefault(),s.returnValue=!0)})};window.setUpSpaModeUnsavedDataChangesAlert=({body:t,resolveLivewireComponentUsing:n,$wire:i})=>{let s=()=>i?.__instance?.effects?.redirect?!1:window.jsMd5(JSON.stringify(i.data).replace(/\\/g,""))!==i.savedDataHash,w=()=>confirm(t);document.addEventListener("livewire:navigate",u=>{if(typeof n()<"u"){if(!s()||w())return;u.preventDefault()}}),window.addEventListener("beforeunload",u=>{s()&&(u.preventDefault(),u.returnValue=!0)})};window.setUpUnsavedActionChangesAlert=({resolveLivewireComponentUsing:t,$wire:n})=>{window.addEventListener("beforeunload",i=>{if(!(typeof t()>"u")&&(n.mountedActions?.length??0)&&!n?.__instance?.effects?.redirect){i.preventDefault(),i.returnValue=!0;return}})};document.addEventListener("alpine:init",()=>{window.Alpine.plugin(W),window.Alpine.store("sidebar",j())});})(); +(()=>{var re=Object.create,N=Object.defineProperty,ne=Object.getPrototypeOf,ie=Object.prototype.hasOwnProperty,oe=Object.getOwnPropertyNames,se=Object.getOwnPropertyDescriptor,ae=t=>N(t,"__esModule",{value:!0}),le=(t,n)=>()=>(n||(n={exports:{}},t(n.exports,n)),n.exports),fe=(t,n,i)=>{if(n&&typeof n=="object"||typeof n=="function")for(let s of oe(n))!ie.call(t,s)&&s!=="default"&&N(t,s,{get:()=>n[s],enumerable:!(i=se(n,s))||i.enumerable});return t},ue=t=>fe(ae(N(t!=null?re(ne(t)):{},"default",t&&t.__esModule&&"default"in t?{get:()=>t.default,enumerable:!0}:{value:t,enumerable:!0})),t),ce=le((t,n)=>{(function(i,s,w){if(!i)return;for(var u={8:"backspace",9:"tab",13:"enter",16:"shift",17:"ctrl",18:"alt",20:"capslock",27:"esc",32:"space",33:"pageup",34:"pagedown",35:"end",36:"home",37:"left",38:"up",39:"right",40:"down",45:"ins",46:"del",91:"meta",93:"meta",224:"meta"},m={106:"*",107:"+",109:"-",110:".",111:"/",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"},v={"~":"`","!":"1","@":"2","#":"3",$:"4","%":"5","^":"6","&":"7","*":"8","(":"9",")":"0",_:"-","+":"=",":":";",'"':"'","<":",",">":".","?":"/","|":"\\"},k={option:"alt",command:"meta",return:"enter",escape:"esc",plus:"+",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},M,y=1;y<20;++y)u[111+y]="f"+y;for(y=0;y<=9;++y)u[y+96]=y.toString();function P(e,r,a){if(e.addEventListener){e.addEventListener(r,a,!1);return}e.attachEvent("on"+r,a)}function x(e){if(e.type=="keypress"){var r=String.fromCharCode(e.which);return e.shiftKey||(r=r.toLowerCase()),r}return u[e.which]?u[e.which]:m[e.which]?m[e.which]:String.fromCharCode(e.which).toLowerCase()}function F(e,r){return e.sort().join(",")===r.sort().join(",")}function V(e){var r=[];return e.shiftKey&&r.push("shift"),e.altKey&&r.push("alt"),e.ctrlKey&&r.push("ctrl"),e.metaKey&&r.push("meta"),r}function H(e){if(e.preventDefault){e.preventDefault();return}e.returnValue=!1}function B(e){if(e.stopPropagation){e.stopPropagation();return}e.cancelBubble=!0}function E(e){return e=="shift"||e=="ctrl"||e=="alt"||e=="meta"}function X(){if(!M){M={};for(var e in u)e>95&&e<112||u.hasOwnProperty(e)&&(M[u[e]]=e)}return M}function Y(e,r,a){return a||(a=X()[e]?"keydown":"keypress"),a=="keypress"&&r.length&&(a="keydown"),a}function Q(e){return e==="+"?["+"]:(e=e.replace(/\+{2}/g,"+plus"),e.split("+"))}function G(e,r){var a,h,O,A=[];for(a=Q(e),O=0;O1){$(o,b,f,p);return}c=G(o,p),r._callbacks[c.key]=r._callbacks[c.key]||[],I(c.key,c.modifiers,{type:c.action},l,o,d),r._callbacks[c.key][l?"unshift":"push"]({callback:f,modifiers:c.modifiers,action:c.action,seq:l,level:d,combo:o})}r._bindMultiple=function(o,f,p){for(var l=0;l-1||R(r,a.target))return!1;if("composedPath"in e&&typeof e.composedPath=="function"){var h=e.composedPath()[0];h!==e.target&&(r=h)}return r.tagName=="INPUT"||r.tagName=="SELECT"||r.tagName=="TEXTAREA"||r.isContentEditable},g.prototype.handleKey=function(){var e=this;return e._handleKey.apply(e,arguments)},g.addKeycodes=function(e){for(var r in e)e.hasOwnProperty(r)&&(u[r]=e[r]);M=null},g.init=function(){var e=g(s);for(var r in e)r.charAt(0)!=="_"&&(g[r]=(function(a){return function(){return e[a].apply(e,arguments)}})(r))},g.init(),i.Mousetrap=g,typeof n<"u"&&n.exports&&(n.exports=g),typeof define=="function"&&define.amd&&define(function(){return g})})(typeof window<"u"?window:null,typeof window<"u"?document:null)}),K=ue(ce());(function(t){if(t){var n={},i=t.prototype.stopCallback;t.prototype.stopCallback=function(s,w,u,m){var v=this;return v.paused?!0:n[u]||n[m]?!1:i.call(v,s,w,u)},t.prototype.bindGlobal=function(s,w,u){var m=this;if(m.bind(s,w,u),s instanceof Array){for(var v=0;vArray.from(document.querySelectorAll('[aria-modal="true"]')).find(t=>window.getComputedStyle(t).display!=="none"),pe=t=>{t.directive("mousetrap",(n,{modifiers:i,expression:s},{cleanup:w,evaluate:u})=>{let m=()=>s?u(s):n.click();i=i.map(v=>v.replace(/--/g," ").replace(/-/g,"+").replace(/\bslash\b/g,"/")),i.includes("global")&&(i=i.filter(v=>v!=="global"),K.default.bindGlobal(i,v=>{let k=J();k&&!k.contains(n)||(v.preventDefault(),m())})),K.default.bind(i,v=>{let k=J();k&&!k.contains(n)||(v.preventDefault(),m())}),w(()=>{K.default.unbind(i)})})},W=pe;var j=()=>({isOpen:window.Alpine.$persist(!0).as("isOpen"),isOpenDesktop:window.Alpine.$persist(!0).as("isOpenDesktop"),collapsedGroups:window.Alpine.$persist(null).as("collapsedGroups"),scrollTop:0,init(){this.resizeObserver=null,this.setUpResizeObserver(),document.addEventListener("livewire:navigate",()=>{let t=document.querySelector(".fi-main-sidebar .fi-sidebar-nav");t&&(this.scrollTop=t.scrollTop)}),document.addEventListener("livewire:navigated",()=>{this.setUpResizeObserver(),requestAnimationFrame(()=>{let t=document.querySelector(".fi-main-sidebar .fi-sidebar-nav");t&&this.scrollTop&&(t.scrollTop=this.scrollTop)})})},setUpResizeObserver(){this.resizeObserver&&this.resizeObserver.disconnect();let t=window.innerWidth;this.resizeObserver=new ResizeObserver(()=>{let n=window.innerWidth,i=t>=1024,s=n<1024,w=n>=1024;i&&s?(this.isOpenDesktop=this.isOpen,this.isOpen&&this.close()):!i&&w&&(this.isOpen=this.isOpenDesktop),t=n}),this.resizeObserver.observe(document.body),window.innerWidth<1024?this.isOpen&&(this.isOpenDesktop=!0,this.close()):this.isOpen=this.isOpenDesktop},groupIsCollapsed(t){return this.collapsedGroups.includes(t)},collapseGroup(t){this.collapsedGroups.includes(t)||(this.collapsedGroups=this.collapsedGroups.concat(t))},toggleCollapsedGroup(t){this.collapsedGroups=this.collapsedGroups.includes(t)?this.collapsedGroups.filter(n=>n!==t):this.collapsedGroups.concat(t)},close(){this.isOpen=!1,window.innerWidth>=1024&&(this.isOpenDesktop=!1)},open(){this.isOpen=!0,window.innerWidth>=1024&&(this.isOpenDesktop=!0)}});document.addEventListener("alpine:init",()=>{let t=localStorage.getItem("theme")??getComputedStyle(document.documentElement).getPropertyValue("--default-theme-mode");window.Alpine.store("theme",t==="dark"||t==="system"&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),window.addEventListener("theme-changed",n=>{let i=n.detail;localStorage.setItem("theme",i),i==="system"&&(i=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),window.Alpine.store("theme",i)}),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",n=>{localStorage.getItem("theme")==="system"&&window.Alpine.store("theme",n.matches?"dark":"light")}),window.Alpine.effect(()=>{window.Alpine.store("theme")==="dark"?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark")})});document.addEventListener("livewire:init",()=>{Livewire.hook("request",({payload:t,fail:n})=>{n(({status:i,preventDefault:s})=>{let w=window.filamentErrorNotifications;if(!w)return;if(JSON.parse(t).components.length===1){for(let m of JSON.parse(t).components)if(JSON.parse(m.snapshot).data.isFilamentNotificationsComponent)return}let u=w[i]??w[""];u.isDisabled!==!0&&(s(),u.isHidden!==!0&&new FilamentNotification().title(u.title).body(u.body).danger().send())})})});var U=window.history.replaceState,de=window.history.pushState;window.history.replaceState=function(t,n,i){t?.url instanceof URL&&(t.url=t.url.toString());let s=i||t?.url||window.location.href,w=window.location.href;if(s!==w){U.call(window.history,t,n,i);return}try{let u=window.history.state;JSON.stringify(t)!==JSON.stringify(u)&&U.call(window.history,t,n,i)}catch{U.call(window.history,t,n,i)}};window.history.pushState=function(t,n,i){t?.url instanceof URL&&(t.url=t.url.toString()),de.call(window.history,t,n,i)};document.addEventListener("livewire:init",()=>{window.Livewire.hook("component.init",({component:t})=>{t.el?.classList?.contains("fi-resource-create-record-page")&&t.snapshot?.data?.isCreating===!0&&(t.$wire.isCreating=!1)})});document.addEventListener("DOMContentLoaded",()=>{setTimeout(()=>{let t=document.querySelector(".fi-main-sidebar .fi-sidebar-item.fi-active");if((!t||t.offsetParent===null)&&(t=document.querySelector(".fi-main-sidebar .fi-sidebar-group.fi-active")),!t||t.offsetParent===null)return;let n=document.querySelector(".fi-main-sidebar .fi-sidebar-nav");n&&n.scrollTo(0,t.offsetTop-window.innerHeight/2)},10)});window.setUpUnsavedDataChangesAlert=({body:t,livewireComponent:n,$wire:i})=>{window.addEventListener("beforeunload",s=>{window.jsMd5(JSON.stringify(i.data).replace(/\\/g,""))===i.savedDataHash||i?.__instance?.effects?.redirect||(s.preventDefault(),s.returnValue=!0)})};window.setUpSpaModeUnsavedDataChangesAlert=({body:t,resolveLivewireComponentUsing:n,$wire:i})=>{let s=()=>i?.__instance?.effects?.redirect?!1:window.jsMd5(JSON.stringify(i.data).replace(/\\/g,""))!==i.savedDataHash,w=()=>confirm(t);document.addEventListener("livewire:navigate",u=>{if(typeof n()<"u"){if(!s()||w())return;u.preventDefault()}}),window.addEventListener("beforeunload",u=>{s()&&(u.preventDefault(),u.returnValue=!0)})};window.setUpUnsavedActionChangesAlert=({resolveLivewireComponentUsing:t,$wire:n})=>{window.addEventListener("beforeunload",i=>{if(!(typeof t()>"u")&&(n.mountedActions??[]).some(s=>s.hasUnsavedChangesAlert??!0)&&!n?.__instance?.effects?.redirect){i.preventDefault(),i.returnValue=!0;return}})};document.addEventListener("alpine:init",()=>{window.Alpine.plugin(W),window.Alpine.store("sidebar",j())});})(); diff --git a/packages/panels/resources/js/unsaved-changes-alert.js b/packages/panels/resources/js/unsaved-changes-alert.js index 4af36be1a8..ead572ee25 100644 --- a/packages/panels/resources/js/unsaved-changes-alert.js +++ b/packages/panels/resources/js/unsaved-changes-alert.js @@ -67,7 +67,9 @@ window.setUpUnsavedActionChangesAlert = ({ } if ( - ($wire.mountedActions?.length ?? 0) && + ($wire.mountedActions ?? []).some( + (mountedAction) => mountedAction.hasUnsavedChangesAlert ?? true, + ) && !$wire?.__instance?.effects?.redirect ) { event.preventDefault() diff --git a/tests/resources/views/pages/unsaved-changes-alert-browser-test.blade.php b/tests/resources/views/pages/unsaved-changes-alert-browser-test.blade.php new file mode 100644 index 0000000000..3c86e5c74d --- /dev/null +++ b/tests/resources/views/pages/unsaved-changes-alert-browser-test.blade.php @@ -0,0 +1 @@ + diff --git a/tests/src/Actions/ActionTest.php b/tests/src/Actions/ActionTest.php index 9eb24cc87a..5ed0fab03a 100644 --- a/tests/src/Actions/ActionTest.php +++ b/tests/src/Actions/ActionTest.php @@ -234,6 +234,62 @@ describe('validation', function (): void { }); }); +describe('unsaved changes alerts', function (): void { + it('flags a mounted action with an editable schema for the unsaved changes alert', function (): void { + livewire(Actions::class) + ->mountAction('data') + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', true, strict: true); + }); + + it('does not flag a mounted action with a disabled schema for the unsaved changes alert', function (): void { + livewire(Actions::class) + ->mountAction('disabledSchema') + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', false, strict: true); + }); + + it('does not flag a mounted action that opts out of the unsaved changes alert', function (): void { + livewire(Actions::class) + ->mountAction('withoutUnsavedChangesAlert') + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', false, strict: true); + }); + + it('returns `true` from `hasUnsavedChangesAlert()` by default', function (): void { + expect(Action::make('test')->hasUnsavedChangesAlert()) + ->toBeTrue(); + }); + + it('can use `unsavedChangesAlert()` to opt an action with a disabled schema back in to the unsaved changes alert', function (): void { + expect(Action::make('test')->disabledSchema()->unsavedChangesAlert()->hasUnsavedChangesAlert()) + ->toBeTrue(); + }); + + it('can use `unsavedChangesAlert()` with a `Closure`', function (): void { + expect(Action::make('test')->unsavedChangesAlert(static fn (): bool => false)->hasUnsavedChangesAlert()) + ->toBeFalse(); + }); + + it('preserves each `unsavedChangesAlert()` configuration when an action mounts a child during its mount lifecycle', function (): void { + livewire(Actions::class) + ->mountAction('mountsChildDuringMount') + ->assertCount('mountedActions', 2) + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', false, strict: true) + ->assertSet('mountedActions.1.hasUnsavedChangesAlert', true, strict: true); + }); + + it('does not restore an action that unmounts itself during `mountUsing()`', function (): void { + livewire(Actions::class) + ->mountAction('unmountsDuringMount') + ->assertSet('mountedActions', [], strict: true); + }); + + it('does not overwrite the unsaved changes alert configuration of an action replaced during `mountUsing()`', function (): void { + livewire(Actions::class) + ->mountAction('replacesDuringMount') + ->assertSet('mountedActions.0.name', 'replacementMountedDuringMount', strict: true) + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', true, strict: true); + }); +}); + describe('arguments', function (): void { it('can mount an action with arguments', function (): void { livewire(Actions::class) diff --git a/tests/src/Actions/ModalTest.php b/tests/src/Actions/ModalTest.php index 6bd7c6c9b3..55e05b5675 100644 --- a/tests/src/Actions/ModalTest.php +++ b/tests/src/Actions/ModalTest.php @@ -11,6 +11,79 @@ beforeEach(function (): void { }); describe('browser interactions', function (): void { + it('prevents `beforeunload` only while actions that can contain unsaved changes are mounted', function (bool $isDarkMode): void { + $this->actingAs(User::factory()->create()); + + $browser = visit('/unsaved-changes-alert-browser-test'); + + if ($isDarkMode) { + $browser->inDarkMode(); + } + + $dispatchBeforeUnloadEvent = <<<'JS' + (() => { + const event = new Event('beforeunload', { cancelable: true }) + + window.dispatchEvent(event) + + return event.defaultPrevented + })() + JS; + + $browser + ->assertScript($dispatchBeforeUnloadEvent, false) + ->click('[data-testid="read-only-unsaved-changes-alert-trigger"]') + ->assertVisible('[data-testid="read-only-unsaved-changes-alert-modal"]') + ->assertScript($dispatchBeforeUnloadEvent, false) + ->assertNoAccessibilityIssues() + ->click('[data-testid="read-only-unsaved-changes-alert-modal"] .fi-modal-footer-actions button >> text=Cancel') + ->assertMissing('[data-testid="read-only-unsaved-changes-alert-modal"]') + ->click('[data-testid="editable-unsaved-changes-alert-trigger"]') + ->assertVisible('[data-testid="editable-unsaved-changes-alert-modal"]') + ->assertScript($dispatchBeforeUnloadEvent, true) + ->click('[data-testid="editable-unsaved-changes-alert-modal"] .fi-modal-footer-actions button >> text=Cancel') + ->assertMissing('[data-testid="editable-unsaved-changes-alert-modal"]') + ->click('[data-testid="nested-unsaved-changes-alert-trigger"]') + ->assertVisible('[data-testid="nested-unsaved-changes-alert-modal"]') + ->assertScript($dispatchBeforeUnloadEvent, false) + ->click('[data-testid="nested-unsaved-changes-alert-modal"] .fi-modal-footer-actions button >> text=Open editable nested action') + ->assertVisible('[data-testid="editable-nested-unsaved-changes-alert-modal"]') + ->assertScript($dispatchBeforeUnloadEvent, true) + ->wait(0.5) + ->assertNoAccessibilityIssues() + ->click('[data-testid="editable-nested-unsaved-changes-alert-modal"] .fi-modal-footer-actions button >> text=Cancel') + ->assertVisible('[data-testid="nested-unsaved-changes-alert-modal"]') + ->assertScript($dispatchBeforeUnloadEvent, false) + ->assertNoSmoke(); + })->with([ + 'light mode' => false, + 'dark mode' => true, + ]); + + it('preserves the `beforeunload` alert for mounted action state without a `hasUnsavedChangesAlert` key', function (): void { + $this->actingAs(User::factory()->create()); + + visit('/unsaved-changes-alert-browser-test') + ->assertScript(<<<'JS' + (() => { + setUpUnsavedActionChangesAlert({ + resolveLivewireComponentUsing: () => ({}), + $wire: { + mountedActions: [{}], + __instance: { effects: {} }, + }, + }) + + const event = new Event('beforeunload', { cancelable: true }) + + window.dispatchEvent(event) + + return event.defaultPrevented + })() + JS, true) + ->assertNoSmoke(); + }); + it('can run another action after closing a child opened by an action without a modal', function (bool $isDarkMode): void { $this->actingAs(User::factory()->create()); diff --git a/tests/src/Actions/ViewActionTest.php b/tests/src/Actions/ViewActionTest.php index bb1ec24379..821bbe7189 100644 --- a/tests/src/Actions/ViewActionTest.php +++ b/tests/src/Actions/ViewActionTest.php @@ -64,6 +64,15 @@ it('can view multiple records sequentially', function (): void { ->assertSchemaStateSet(['name' => 'Department 2']); }); +it('does not flag a mounted `ViewAction` for the unsaved changes alert', function (): void { + $ticket = Ticket::factory()->create(); + $department = Department::factory()->hasAttached($ticket)->create(); + + livewire(DepartmentsRelationManager::class, ['ownerRecord' => $ticket, 'pageClass' => EditTicket::class]) + ->mountAction(TestAction::make(ViewAction::class)->table($department)) + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', false, strict: true); +}); + it('returns `view` from `getDefaultName()`', function (): void { expect(ViewAction::getDefaultName())->toBe('view'); }); diff --git a/tests/src/Fixtures/Pages/Actions.php b/tests/src/Fixtures/Pages/Actions.php index c1cd639839..fc87f7fc47 100644 --- a/tests/src/Fixtures/Pages/Actions.php +++ b/tests/src/Fixtures/Pages/Actions.php @@ -363,6 +363,52 @@ class Actions extends Page ->action(function (): void { $this->dispatch('enforcement-authorized-called'); }), + Action::make('disabledSchema') + ->schema([ + TextInput::make('payload'), + ]) + ->disabledSchema() + ->action(function (): void { + $this->dispatch('disabled-schema-called'); + }), + Action::make('withoutUnsavedChangesAlert') + ->schema([ + TextInput::make('payload'), + ]) + ->unsavedChangesAlert(false) + ->action(function (): void { + $this->dispatch('without-unsaved-changes-alert-called'); + }), + Action::make('mountsChildDuringMount') + ->requiresConfirmation() + ->unsavedChangesAlert(false) + ->registerModalActions([ + Action::make('childMountedDuringParentMount') + ->requiresConfirmation() + ->unsavedChangesAlert() + ->action(static fn () => null), + ]) + ->mountUsing(function (Action $action): void { + $action->getLivewire()->mountAction('childMountedDuringParentMount'); + }) + ->action(static fn () => null), + Action::make('unmountsDuringMount') + ->requiresConfirmation() + ->mountUsing(function (Action $action): void { + $action->getLivewire()->unmountAction(); + }) + ->action(static fn () => null), + Action::make('replacesDuringMount') + ->requiresConfirmation() + ->unsavedChangesAlert(false) + ->mountUsing(function (Action $action): void { + $action->getLivewire()->replaceMountedAction('replacementMountedDuringMount'); + }) + ->action(static fn () => null), + Action::make('replacementMountedDuringMount') + ->requiresConfirmation() + ->unsavedChangesAlert() + ->action(static fn () => null), ]; } diff --git a/tests/src/Fixtures/Pages/UnsavedChangesAlertBrowserTest.php b/tests/src/Fixtures/Pages/UnsavedChangesAlertBrowserTest.php new file mode 100644 index 0000000000..0cf53654af --- /dev/null +++ b/tests/src/Fixtures/Pages/UnsavedChangesAlertBrowserTest.php @@ -0,0 +1,57 @@ +label('Read-only unsaved changes alert') + ->schema([ + TextInput::make('name'), + ]) + ->disabledSchema() + ->modalSubmitAction(false) + ->extraAttributes(['data-testid' => 'read-only-unsaved-changes-alert-trigger']) + ->extraModalWindowAttributes(['data-testid' => 'read-only-unsaved-changes-alert-modal']), + Action::make('editableUnsavedChangesAlert') + ->label('Editable unsaved changes alert') + ->schema([ + TextInput::make('name'), + ]) + ->modalSubmitAction(false) + ->extraAttributes(['data-testid' => 'editable-unsaved-changes-alert-trigger']) + ->extraModalWindowAttributes(['data-testid' => 'editable-unsaved-changes-alert-modal']), + Action::make('nestedUnsavedChangesAlert') + ->label('Nested unsaved changes alert') + ->modalHeading('Nested unsaved changes alert') + ->modalSubmitAction(false) + ->unsavedChangesAlert(false) + ->extraAttributes(['data-testid' => 'nested-unsaved-changes-alert-trigger']) + ->extraModalWindowAttributes(['data-testid' => 'nested-unsaved-changes-alert-modal']) + ->extraModalFooterActions([ + Action::make('editableNestedUnsavedChangesAlert') + ->label('Open editable nested action') + ->schema([ + TextInput::make('name'), + ]) + ->modalSubmitAction(false) + ->extraModalWindowAttributes(['data-testid' => 'editable-nested-unsaved-changes-alert-modal']), + ]), + ]; + } +} diff --git a/tests/src/Fixtures/Providers/AdminPanelProvider.php b/tests/src/Fixtures/Providers/AdminPanelProvider.php index d641b591ce..50d601c7f0 100644 --- a/tests/src/Fixtures/Providers/AdminPanelProvider.php +++ b/tests/src/Fixtures/Providers/AdminPanelProvider.php @@ -62,6 +62,7 @@ use Filament\Tests\Fixtures\Pages\TextInputTest; use Filament\Tests\Fixtures\Pages\TimePickerBrowserTest; use Filament\Tests\Fixtures\Pages\ToggleButtonsTest; use Filament\Tests\Fixtures\Pages\ToggleTest; +use Filament\Tests\Fixtures\Pages\UnsavedChangesAlertBrowserTest; use Filament\Tests\Fixtures\Pages\WizardBrowserTest; use Filament\Tests\Fixtures\Resources\Companies\CompanyResource; use Filament\Tests\Fixtures\Resources\Companies\Resources\CompanyTeamResource; @@ -94,6 +95,7 @@ class AdminPanelProvider extends PanelProvider ->emailChangeVerification() ->emailVerification() ->profile() + ->unsavedChangesAlerts(static fn (): bool => request()->routeIs('filament.admin.pages.unsaved-changes-alert-browser-test')) ->resources([ CompanyResource::class, CompanyTeamResource::class, @@ -155,6 +157,7 @@ class AdminPanelProvider extends PanelProvider TimePickerBrowserTest::class, ToggleButtonsTest::class, ToggleTest::class, + UnsavedChangesAlertBrowserTest::class, WizardBrowserTest::class, UserManagement::class, ManageAdmins::class,