Untitled
unknown
plain_text
2 years ago
172 kB
57
Indexable
Skip to content Product Solutions Open Source Pricing Search or jump to... Sign in Sign up livewire / livewire Public Code Issues Pull requests 44 Discussions Actions Projects 2 Security Insights Comparing changes Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons. ... 33 contributors Commits 51 Files changed 89 Showing with 2,426 additions and 450 deletions. 6 changes: 3 additions & 3 deletions6 composer.json @@ -10,7 +10,7 @@ ], "require": { "php": "^8.1", "symfony/http-kernel": "^5.0|^6.0", "symfony/http-kernel": "^6.2", "illuminate/support": "^10.0", "illuminate/database": "^10.0", "illuminate/validation": "^10.0", @@ -21,8 +21,8 @@ "mockery/mockery": "^1.3.1", "phpunit/phpunit": "^9.0", "laravel/framework": "^10.0", "orchestra/testbench": "^7.0|^8.0", "orchestra/testbench-dusk": "^7.0|^8.0", "orchestra/testbench": "^8.0", "orchestra/testbench-dusk": "^8.0", "calebporzio/sushi": "^2.1", "laravel/prompts": "^0.1.6" }, 1 change: 1 addition & 0 deletions1 config/livewire.php @@ -128,6 +128,7 @@ 'navigate' => [ 'show_progress_bar' => true, 'progress_bar_color' => '#2299dd', ], /* 81 changes: 42 additions & 39 deletions81 dist/livewire.esm.js @@ -1837,7 +1837,11 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); let rightSideSafeExpression = /^[\n\s]*if.*\(.*\)/.test(expression.trim()) || /^(let|const)\s/.test(expression.trim()) ? `(async()=>{ ${expression} })()` : expression; const safeAsyncFunction = () => { try { return new AsyncFunction(["__self", "scope"], `with (scope) { __self.result = ${rightSideSafeExpression} }; __self.finished = true; return __self.result;`); let func2 = new AsyncFunction(["__self", "scope"], `with (scope) { __self.result = ${rightSideSafeExpression} }; __self.finished = true; return __self.result;`); Object.defineProperty(func2, "name", { value: `[Alpine] ${expression}` }); return func2; } catch (error2) { handleError(error2, el, expression); return Promise.resolve(); @@ -2808,7 +2812,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); get raw() { return raw; }, version: "3.13.0", version: "3.13.1", flushAndStopDeferringMutations, dontAutoEvaluateFunctions, disableEffectScheduling, @@ -3003,6 +3007,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); placeInDom(el._x_teleport, target2, modifiers); }); }; cleanup2(() => clone2.remove()); }); var teleportContainerDuringClone = document.createElement("div"); function getTarget(expression) { @@ -4895,7 +4900,7 @@ var require_module_cjs5 = __commonJS({ let evaluate = evaluateLater(expression); let options = { rootMargin: getRootMargin(modifiers), threshold: getThreshhold(modifiers) threshold: getThreshold(modifiers) }; let observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { @@ -4911,7 +4916,7 @@ var require_module_cjs5 = __commonJS({ }); }); } function getThreshhold(modifiers) { function getThreshold(modifiers) { if (modifiers.includes("full")) return 0.99; if (modifiers.includes("half")) @@ -5355,8 +5360,8 @@ var require_module_cjs6 = __commonJS({ continue; } } let isIf = (node) => node && node.nodeType === 8 && node.textContent === " __BLOCK__ "; let isEnd = (node) => node && node.nodeType === 8 && node.textContent === " __ENDBLOCK__ "; let isIf = (node) => node && node.nodeType === 8 && node.textContent === "[if BLOCK]><![endif]"; let isEnd = (node) => node && node.nodeType === 8 && node.textContent === "[if ENDBLOCK]><![endif]"; if (isIf(currentTo) && isIf(currentFrom)) { let nestedIfCount = 0; let fromBlockStart = currentFrom; @@ -5863,6 +5868,9 @@ function isSynthetic(subject) { return Array.isArray(subject) && subject.length === 2 && typeof subject[1] === "object" && Object.keys(subject[1]).includes("s"); } function getCsrfToken() { if (document.querySelector('meta[name="csrf-token"]')) { return document.querySelector('meta[name="csrf-token"]').getAttribute("content"); } if (document.querySelector("[data-csrf]")) { return document.querySelector("[data-csrf]").getAttribute("data-csrf"); } @@ -6205,7 +6213,7 @@ function generateEntangleFunction(component, cleanup2) { if (!cleanup2) cleanup2 = () => { }; return (name, live) => { return (name, live = false) => { let isLive = live; let livewireProperty = name; let livewireComponent = component.$wire; @@ -6215,24 +6223,22 @@ function generateEntangleFunction(component, cleanup2) { console.error(`Livewire Entangle Error: Livewire property ['${livewireProperty}'] cannot be found on component: ['${component.name}']`); return; } queueMicrotask(() => { let release = import_alpinejs.default.entangle({ get() { return livewireComponent.get(name); }, set(value) { livewireComponent.set(name, value, isLive); } }, { get() { return getter(); }, set(value) { setter(value); } }); cleanup2(() => release()); let release = import_alpinejs.default.entangle({ get() { return livewireComponent.get(name); }, set(value) { livewireComponent.set(name, value, isLive); } }, { get() { return getter(); }, set(value) { setter(value); } }); cleanup2(() => release()); return livewireComponent.get(name); }, (obj) => { Object.defineProperty(obj, "live", { @@ -7146,13 +7152,13 @@ function finishAndHideProgressBar() { function injectStyles() { let style = document.createElement("style"); style.innerHTML = `/* Make clicks pass-through */ #nprogress { pointer-events: none; } #nprogress .bar { // background: #FC70A9; background: #29d; background: var(--livewire-progress-bar-color, #29d); position: fixed; z-index: 1031; @@ -7193,8 +7199,8 @@ function injectStyles() { box-sizing: border-box; border: solid 2px transparent; border-top-color: #29d; border-left-color: #29d; border-top-color: var(--livewire-progress-bar-color, #29d); border-left-color: var(--livewire-progress-bar-color, #29d); border-radius: 50%; -webkit-animation: nprogress-spinner 400ms linear infinite; @@ -7434,7 +7440,7 @@ function navigate_default(Alpine21) { }); }); setTimeout(() => { fireEventForOtherLibariesToHookInto("alpine:navigated", true); fireEventForOtherLibariesToHookInto("alpine:navigated"); }); } function fetchHtmlOrUsePrefetchedHtml(fromDestination, callback) { @@ -7451,8 +7457,8 @@ function preventAlpineFromPickingUpDomChanges(Alpine21, callback) { }); }); } function fireEventForOtherLibariesToHookInto(eventName, init = false) { document.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: { init } })); function fireEventForOtherLibariesToHookInto(eventName) { document.dispatchEvent(new CustomEvent(eventName, { bubbles: true })); } function nowInitializeAlpineOnTheNewPage(Alpine21) { Alpine21.initTree(document.body, void 0, (el, skip) => { @@ -7915,20 +7921,16 @@ on("effects", (component, effects) => { }); // js/features/supportNavigate.js var isNavigating = false; shouldHideProgressBar() && Alpine.navigate.disableProgressBar(); document.addEventListener("alpine:navigated", (e) => { if (e.detail && e.detail.init) return; isNavigating = true; document.dispatchEvent(new CustomEvent("livewire:navigated", { bubbles: true })); }); document.addEventListener("alpine:navigating", (e) => { document.dispatchEvent(new CustomEvent("livewire:navigating", { bubbles: true })); }); function shouldRedirectUsingNavigateOr(effects, url, or) { let forceNavigate = effects.redirectUsingNavigate; if (forceNavigate || isNavigating) { if (forceNavigate) { Alpine.navigate(url); } else { or(); @@ -8064,7 +8066,7 @@ on("morph.added", ({ el }) => { el.__addedByMorph = true; }); directive("transition", ({ el, directive: directive2, component, cleanup: cleanup2 }) => { let visibility = import_alpinejs13.default.reactive({ state: false }); let visibility = import_alpinejs13.default.reactive({ state: el.__addedByMorph ? false : true }); import_alpinejs13.default.bind(el, { [directive2.rawName.replace("wire:", "x-")]: "", "x-show"() { @@ -8190,13 +8192,14 @@ directive("loading", ({ el, directive: directive2, component }) => { ]); }); function applyDelay(directive2) { if (!directive2.modifiers.includes("delay")) if (!directive2.modifiers.includes("delay") || directive2.modifiers.includes("none")) return [(i) => i(), (i) => i()]; let duration = 200; let delayModifiers = { "shortest": 50, "shorter": 100, "short": 150, "default": 200, "long": 300, "longer": 500, "longest": 1e3 @@ -8452,7 +8455,7 @@ directive("model", ({ el, directive: directive2, component, cleanup: cleanup2 }) return handleFileUpload(el, expression, component, cleanup2); } let isLive = modifiers.includes("live"); let isLazy = modifiers.includes("lazy"); let isLazy = modifiers.includes("lazy") || modifiers.includes("change"); let onBlur = modifiers.includes("blur"); let isDebounced = modifiers.includes("debounce"); let update = () => component.$wire.$commit(); 81 changes: 42 additions & 39 deletions81 dist/livewire.js @@ -394,6 +394,9 @@ return Array.isArray(subject) && subject.length === 2 && typeof subject[1] === "object" && Object.keys(subject[1]).includes("s"); } function getCsrfToken() { if (document.querySelector('meta[name="csrf-token"]')) { return document.querySelector('meta[name="csrf-token"]').getAttribute("content"); } if (document.querySelector("[data-csrf]")) { return document.querySelector("[data-csrf]").getAttribute("data-csrf"); } @@ -1275,7 +1278,11 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); let rightSideSafeExpression = /^[\n\s]*if.*\(.*\)/.test(expression.trim()) || /^(let|const)\s/.test(expression.trim()) ? `(async()=>{ ${expression} })()` : expression; const safeAsyncFunction = () => { try { return new AsyncFunction(["__self", "scope"], `with (scope) { __self.result = ${rightSideSafeExpression} }; __self.finished = true; return __self.result;`); let func2 = new AsyncFunction(["__self", "scope"], `with (scope) { __self.result = ${rightSideSafeExpression} }; __self.finished = true; return __self.result;`); Object.defineProperty(func2, "name", { value: `[Alpine] ${expression}` }); return func2; } catch (error2) { handleError(error2, el, expression); return Promise.resolve(); @@ -2246,7 +2253,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); get raw() { return raw; }, version: "3.13.0", version: "3.13.1", flushAndStopDeferringMutations, dontAutoEvaluateFunctions, disableEffectScheduling, @@ -3088,6 +3095,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); placeInDom(el._x_teleport, target2, modifiers); }); }; cleanup22(() => clone2.remove()); }); var teleportContainerDuringClone = document.createElement("div"); function getTarget(expression) { @@ -3762,7 +3770,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); if (!cleanup3) cleanup3 = () => { }; return (name, live) => { return (name, live = false) => { let isLive = live; let livewireProperty = name; let livewireComponent = component.$wire; @@ -3772,24 +3780,22 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); console.error(`Livewire Entangle Error: Livewire property ['${livewireProperty}'] cannot be found on component: ['${component.name}']`); return; } queueMicrotask(() => { let release2 = module_default.entangle({ get() { return livewireComponent.get(name); }, set(value) { livewireComponent.set(name, value, isLive); } }, { get() { return getter(); }, set(value) { setter(value); } }); cleanup3(() => release2()); let release2 = module_default.entangle({ get() { return livewireComponent.get(name); }, set(value) { livewireComponent.set(name, value, isLive); } }, { get() { return getter(); }, set(value) { setter(value); } }); cleanup3(() => release2()); return livewireComponent.get(name); }, (obj) => { Object.defineProperty(obj, "live", { @@ -5536,7 +5542,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); let evaluate2 = evaluateLater2(expression); let options = { rootMargin: getRootMargin(modifiers), threshold: getThreshhold(modifiers) threshold: getThreshold(modifiers) }; let observer2 = new IntersectionObserver((entries) => { entries.forEach((entry) => { @@ -5552,7 +5558,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); }); }); } function getThreshhold(modifiers) { function getThreshold(modifiers) { if (modifiers.includes("full")) return 0.99; if (modifiers.includes("half")) @@ -5832,13 +5838,13 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); function injectStyles() { let style = document.createElement("style"); style.innerHTML = `/* Make clicks pass-through */ #nprogress { pointer-events: none; } #nprogress .bar { // background: #FC70A9; background: #29d; background: var(--livewire-progress-bar-color, #29d); position: fixed; z-index: 1031; @@ -5879,8 +5885,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); box-sizing: border-box; border: solid 2px transparent; border-top-color: #29d; border-left-color: #29d; border-top-color: var(--livewire-progress-bar-color, #29d); border-left-color: var(--livewire-progress-bar-color, #29d); border-radius: 50%; -webkit-animation: nprogress-spinner 400ms linear infinite; @@ -6119,7 +6125,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); }); }); setTimeout(() => { fireEventForOtherLibariesToHookInto("alpine:navigated", true); fireEventForOtherLibariesToHookInto("alpine:navigated"); }); } function fetchHtmlOrUsePrefetchedHtml(fromDestination, callback) { @@ -6136,8 +6142,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); }); }); } function fireEventForOtherLibariesToHookInto(eventName, init = false) { document.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: { init } })); function fireEventForOtherLibariesToHookInto(eventName) { document.dispatchEvent(new CustomEvent(eventName, { bubbles: true })); } function nowInitializeAlpineOnTheNewPage(Alpine3) { Alpine3.initTree(document.body, void 0, (el, skip) => { @@ -6437,8 +6443,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); continue; } } let isIf = (node) => node && node.nodeType === 8 && node.textContent === " __BLOCK__ "; let isEnd = (node) => node && node.nodeType === 8 && node.textContent === " __ENDBLOCK__ "; let isIf = (node) => node && node.nodeType === 8 && node.textContent === "[if BLOCK]><![endif]"; let isEnd = (node) => node && node.nodeType === 8 && node.textContent === "[if ENDBLOCK]><![endif]"; if (isIf(currentTo) && isIf(currentFrom)) { let nestedIfCount = 0; let fromBlockStart = currentFrom; @@ -7078,20 +7084,16 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); }); // js/features/supportNavigate.js var isNavigating = false; shouldHideProgressBar() && Alpine.navigate.disableProgressBar(); document.addEventListener("alpine:navigated", (e) => { if (e.detail && e.detail.init) return; isNavigating = true; document.dispatchEvent(new CustomEvent("livewire:navigated", { bubbles: true })); }); document.addEventListener("alpine:navigating", (e) => { document.dispatchEvent(new CustomEvent("livewire:navigating", { bubbles: true })); }); function shouldRedirectUsingNavigateOr(effects, url, or) { let forceNavigate = effects.redirectUsingNavigate; if (forceNavigate || isNavigating) { if (forceNavigate) { Alpine.navigate(url); } else { or(); @@ -7225,7 +7227,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); el.__addedByMorph = true; }); directive2("transition", ({ el, directive: directive3, component, cleanup: cleanup3 }) => { let visibility = module_default.reactive({ state: false }); let visibility = module_default.reactive({ state: el.__addedByMorph ? false : true }); module_default.bind(el, { [directive3.rawName.replace("wire:", "x-")]: "", "x-show"() { @@ -7349,13 +7351,14 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); ]); }); function applyDelay(directive3) { if (!directive3.modifiers.includes("delay")) if (!directive3.modifiers.includes("delay") || directive3.modifiers.includes("none")) return [(i) => i(), (i) => i()]; let duration = 200; let delayModifiers = { "shortest": 50, "shorter": 100, "short": 150, "default": 200, "long": 300, "longer": 500, "longest": 1e3 @@ -7610,7 +7613,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el); return handleFileUpload(el, expression, component, cleanup3); } let isLive = modifiers.includes("live"); let isLazy = modifiers.includes("lazy"); let isLazy = modifiers.includes("lazy") || modifiers.includes("change"); let onBlur = modifiers.includes("blur"); let isDebounced = modifiers.includes("debounce"); let update = () => component.$wire.$commit(); 18 changes: 9 additions & 9 deletions18 dist/livewire.min.js Large diffs are not rendered by default. 2 changes: 1 addition & 1 deletion2 dist/manifest.json @@ -1,2 +1,2 @@ {"/livewire.js":"f41737f6"} {"/livewire.js":"5d3e67e0"} 16 changes: 13 additions & 3 deletions16 docs/__nav.md @@ -17,17 +17,27 @@ Features: Lazy Loading: { uri: /docs/lazy, file: /lazy.md } Validation: { uri: /docs/validation, file: /validation.md } File Uploads: { uri: /docs/uploads, file: /uploads.md } Loading States: { uri: /docs/loading, file: /loading.md } Pagination: { uri: /docs/pagination, file: /pagination.md } URL Query Parameters: { uri: /docs/url, file: /url.md } Computed Properties: { uri: /docs/computed-properties, file: /computed-properties.md } Redirecting: { uri: /docs/redirecting, file: /redirecting.md } Polling: { uri: /docs/polling, file: /polling.md } File Downloads: { uri: /docs/downloads, file: /downloads.md } Locked Properties: { uri: /docs/locked, file: /locked.md } Dirty States: { uri: /docs/dirty, file: /dirty.md } Offline States: { uri: /docs/offline, file: /offline.md } Teleport: { uri: /docs/teleport, file: /teleport.md } HTML Directives: wire:click: { uri: /docs/wire-click, file: /wire-click.md } wire:submit: { uri: /docs/wire-submit, file: /wire-submit.md } wire:model: { uri: /docs/wire-model, file: /wire-model.md } wire:loading: { uri: /docs/wire-loading, file: /wire-loading.md } wire:dirty: { uri: /docs/wire-dirty, file: /wire-dirty.md } wire:navigate: { uri: /docs/wire-navigate, file: /wire-navigate.md } wire:transition: { uri: /docs/wire-transition, file: /wire-transition.md } wire:init: { uri: /docs/wire-init, file: /wire-init.md } wire:poll: { uri: /docs/wire-poll, file: /wire-poll.md } wire:offline: { uri: /docs/wire-offline, file: /wire-offline.md } wire:ignore: { uri: /docs/wire-ignore, file: /wire-ignore.md } wire:stream: { uri: /docs/wire-stream, file: /wire-stream.md } Concepts: Morphing: { uri: /docs/morphing, file: /morph.md } Hydration: { uri: /docs/hydration, file: /hydration.md } 3 changes: 2 additions & 1 deletion3 docs/actions.md @@ -56,6 +56,7 @@ Livewire supports a variety of event listeners, allowing you to respond to vario | `wire:click` | Triggered when an element is clicked | | `wire:submit` | Triggered when a form is submitted | | `wire:keydown` | Triggered when a key is pressed down | | `wire:keyup` | Triggered when a key is released | `wire:mouseenter`| Triggered when the mouse enters an element | | `wire:*`| Whatever text follows `wire:` will be used as the event name of the listener | @@ -226,7 +227,7 @@ Livewire provides a `wire:loading` directive that makes it trivial to show and h </form> ``` `wire:loading` is a powerful feature with a variety of more powerful features. [Check out the full loading documentation for more information](/docs/loading). `wire:loading` is a powerful feature with a variety of more powerful features. [Check out the full loading documentation for more information](/docs/wire-loading). ## Passing parameters 76 changes: 0 additions & 76 deletions76 docs/attributes.md This file was deleted. 24 changes: 24 additions & 0 deletions24 docs/components.md @@ -634,3 +634,27 @@ class ShowPost extends Component ``` The `$post` property will automatically be assigned to the model bound via the route's `{post}` parameter. ### Modifying the response In some scenarios you might want to modify the response and set a custom response header. You can hook into the response object by calling the `response()` method on the view and use a closure to modify the response object: ```php <?php namespace App\Livewire; use Livewire\Component; use Illuminate\Http\Response; class ShowPost extends Component { public function render() { return view('livewire.show-post') ->response(function(Response $response) { $response->header('X-Custom-Header', true); }); } } ``` 20 changes: 19 additions & 1 deletion20 docs/computed-properties.md @@ -183,6 +183,25 @@ public function posts() In the above example, until the cache expires or is busted, every instance of this component in your application will share the same cached value for `$this->posts`. If you need to manually clear the cache for a computed property, you may set a custom cache key using the `key` parameter: ```php use Livewire\Attributes\Computed; use App\Models\Post; #[Computed(cache: true, key: 'homepage-posts')] public function posts() { return Post::all(); } ``` Then somewhere else in your app, you may clear the cache for that property: ```php Cache::forget('homepage-posts'); ``` ## When to use computed properties? In addition to offering performance advantages, there are a few other scenarios where computed properties are helpful. @@ -324,4 +343,3 @@ class ShowPosts extends Component @endforeach </div> ``` 131 changes: 3 additions & 128 deletions131 docs/forms.md @@ -169,6 +169,8 @@ class CreatePost extends Component public function save() { $this->validate(); Post::create( $this->form->all() ); @@ -387,7 +389,7 @@ Here's an example of adding a small loading spinner to the "Save" button via `wi Now, when a user presses "Save", a small, inline spinner will show up. Livewire's `wire:loading` feature has a lot more to offer. Visit the [Loading documentation to learn more.](/docs/loading) Livewire's `wire:loading` feature has a lot more to offer. Visit the [Loading documentation to learn more.](/docs/wire-loading) ## Live-updating fields @@ -678,130 +680,3 @@ Because `x-modelable` works for both `wire:model` and `x-model`, you can also us ``` Creating custom input elements in your application is extremely powerful but requires a deeper understanding of the utilities Livewire and Alpine provide and how they interact with each other. ## Input fields Livewire supports most native input elements out of the box. Meaning you should just be able to attach `wire:model` to any input element in the browser and easily bind properties to them. Here's a comprehensive list of the different available input types and how you use them in a Livewire context. ### Text inputs First and foremost, text inputs are the bedrock of most forms. Here's how to bind a property named "title" to one: ```blade <input type="text" wire:model="title"> ``` ### Textarea inputs Textarea elements are similarly straightforward. Simply add `wire:model` to a textarea and the value will be bound: ```blade <textarea type="text" wire:model="content"></textarea> ``` If the "content" value is initialized with a string, Livewire will fill the textarea with that value - there's no need to do something like the following: ```blade <!-- Warning: This snippet demonstrates what NOT to do... --> <textarea type="text" wire:model="content">{{ $content }}</textarea> ``` ### Checkboxes Checkboxes can be used for single values, such as when toggling a boolean property. Or, checkboxes may be used to toggle a single value in a group of related values. We'll discuss both scenarios: #### Single checkbox At the end of a signup form, you might have a checkbox allowing the user to opt-in to email updates. You might call this property `$receiveUpdates`. You can easily bind this value to the checkbox using `wire:model`: ```blade <input type="checkbox" wire:model="receiveUpdates"> ``` Now when the `$receiveUpdates` value is `false`, the checkbox will be unchecked. Of course, when the value is `true`, the checkbox will be checked. #### Multiple checkboxes Now, let's say in addition to allowing the user to decide to receive updates, you have an array property in your class called `$updateTypes`, allowing the user to choose from a variety of update types: ```php public $updateTypes = []; ``` By binding multiple checkboxes to the `$updateTypes` property, the user can select multiple update types and they will be added to the `$updateTypes` array property: ```blade <input type="checkbox" value="email" wire:model="updateTypes"> <input type="checkbox" value="sms" wire:model="updateTypes"> <input type="checkbox" value="notificaiton" wire:model="updateTypes"> ``` For example, if the user checks the first two boxes but not the third, the value of `$updateTypes` will be: `["email", "sms"]` ### Radio buttons To toggle between two different values for a single property, you may use radio buttons: ```blade <input type="radio" value="yes" wire:model="receiveUpdates"> <input type="radio" value="no" wire:model="receiveUpdates"> ``` ### Select dropdowns Livewire makes it simple to work with `<select>` dropdowns. When adding `wire:model` to a dropdown, the currently selected value will be bound to the provided property name and vice versa. In addition, there's no need to manually add `selected` to the option that will be selected - Livewire handles that for you automatically. Below is an example of a select dropdown filled with a static list of states: ```blade <select wire:model="state"> <option value="AL">Alabama<option> <option value="AK">Alaska</option> <option value="AZ">Arizona</option> ... </select> ``` When a specific state is selected, for example, "Alaska", the `$state` property on the component will be set to `AK`. If you would prefer the value to be set to "Alaska" instead of "AK", you can leave the `value=""` attribute off the `<option>` element entirely. Often, you may build your dropdown options dynamically using Blade: ```blade <select wire:model="state"> @foreach (\App\Models\State::all() as $state) <option value="{{ $state->id }}">{{ $state->label }}</option> @endforeach </select> ``` If you don't have a specific option selected by default, you may want to show a muted placeholder option by default, such as "Select a state": ```blade <select wire:model="state"> <option disabled>Select a state...</option> @foreach (\App\Models\State::all() as $state) <option value="{{ $option->id }}">{{ $option->label }}</option> @endforeach </select> ``` As you can see, there is no "placeholder" attribute for a select menu like there is for text inputs. Instead, you have to add a `disabled` option element as the first option in the list. ### Multi-select dropdowns If you are using a "multiple" select menu, Livewire works as expected. In this example, states will be added to the `$states` array property when they are selected and removed if they are deselected: ```blade <select wire:model="states" multiple> <option value="AL">Alabama<option> <option value="AK">Alaska</option> <option value="AZ">Arizona</option> ... </select> ``` 2 changes: 1 addition & 1 deletion2 docs/javascript.md @@ -55,7 +55,7 @@ let components = Livewire.all() ### Interacting with events In addition to dispatching and listening for events from individual components in PHP, the global `Livewire` object allows you interact with [Livewire's event system](/docs/events) from anywhere in your application: In addition to dispatching and listening for events from individual components in PHP, the global `Livewire` object allows you to interact with [Livewire's event system](/docs/events) from anywhere in your application: ```js // Dispatch an event to any Livewire components listening... 3 changes: 3 additions & 0 deletions3 docs/lazy.md @@ -86,6 +86,9 @@ class Revenue extends Component Because the above component specifies a "placeholder" by returning HTML from a `placeholder()` method, the user will see an SVG loading spinner on the page until the component is fully loaded. > [!warning] The placeholder and the component must share the same element type > For instance, if your placeholder's root element type is a 'div,' your component must also use a 'div' element. ### Rendering a placeholder via a view For more complex loaders (such as skeletons) you can return a `view` from the `placeholder()` similar to `render()`. 2 changes: 2 additions & 0 deletions2 docs/loading.md @@ -171,10 +171,12 @@ The above element will only appear if the request takes over 200 milliseconds. T To customize the amount of time to delay the loading indicator, you can use one of Livewire's helpful interval aliases: ```blade <div wire:loading.delay.none>...</div> <!-- 0ms --> <div wire:loading.delay.shortest>...</div> <!-- 50ms --> <div wire:loading.delay.shorter>...</div> <!-- 100ms --> <div wire:loading.delay.short>...</div> <!-- 150ms --> <div wire:loading.delay>...</div> <!-- 200ms --> <div wire:loading.delay.default>...</div> <!-- 200ms --> <div wire:loading.delay.long>...</div> <!-- 300ms --> <div wire:loading.delay.longer>...</div> <!-- 500ms --> <div wire:loading.delay.longest>...</div> <!-- 1000ms --> 7 changes: 3 additions & 4 deletions7 docs/morph.md @@ -133,7 +133,7 @@ Here is a visualization of the "look-ahead" algorithm in action: ### Injecting morph markers On the backend, Livewire automatically detects conditionals inside Blade templates and wraps them in markers that Livewire's JavaScript can use as a guide when morphing. On the backend, Livewire automatically detects conditionals inside Blade templates and wraps them in HTML comment markers that Livewire's JavaScript can use as a guide when morphing. Here's an example of the previous Blade template but with Livewire's injected markers: @@ -143,11 +143,11 @@ Here's an example of the previous Blade template but with Livewire's injected ma <input wire:model="title"> </div> <!-- __BLOCK__ --> <!-- [tl! highlight] --> <!--[if BLOCK]><![endif]--> <!-- [tl! highlight] --> @if ($errors->has('title')) <div>Error: {{ $errors->first('title') }}</div> @endif <!-- __ENDBLOCK__ --> <!-- [tl! highlight] --> <!--[if ENDBLOCK]><![endif]--> <!-- [tl! highlight] --> <div> <button>Save</button> @@ -188,4 +188,3 @@ For example, here's the above Blade template rewritten with wrapping `<div>` ele ``` Now that the conditional has been wrapped in a persistent element, Livewire will morph the two different HTML trees properly. 13 changes: 13 additions & 0 deletions13 docs/navigate.md @@ -226,3 +226,16 @@ If you have a `<script>` tag in the body that you only want to be run once, you console.log('Runs only on page one') </script> ``` ## Customizing the progress bar When a page takes longer than 150ms to load, Livewirew will show a progress bar at the top of the page. You can customize the color of this bar or disable it all together inside Livewire's config file (`config/livewire.php`): ```php 'navigate' => [ 'show_progress_bar' => false, 'progress_bar_color' => '#2299dd', ], ``` 2 changes: 1 addition & 1 deletion2 docs/nesting.md @@ -498,7 +498,7 @@ class Steps extends Component public function next() { $currentIndex = array_search($this->steps, $this->current); $currentIndex = array_search($this->current, $this->steps); $this->current = $this->steps[$currentIndex + 1]; } 16 changes: 16 additions & 0 deletions16 docs/pagination.md @@ -49,6 +49,22 @@ As you can see, in addition to limiting the number of posts shown via the `Post: For more information on pagination using Laravel, check out [Laravel's comprehensive pagination documentation](https://laravel.com/docs/pagination). ## Customizing scroll behavior By default, Livewire's paginator scrolls to the top of the page after every page change. You disable this behavior by passing `false` to the `scrollTo` parameter of the `links()` method like so: ```blade {{ $posts->links(data: ['scrollTo' => false]) }} ``` Alternatively, you can provide any CSS selector to the `scrollTo` parameter, and Livewire will find the nearest element matching that selector and scroll to it after each navigation: ```blade {{ $posts->links(data: ['scrollTo' => '#paginated-posts']) }} ``` ## Resetting the page When sorting or filtering results, it is common to want to reset the page number back to `1`. 5 changes: 5 additions & 0 deletions5 docs/properties.md @@ -195,6 +195,11 @@ Supported PHP types: | Carbon | `Carbon\Carbon` | | Stringable | `Illuminate\Support\Stringable` | > [!warning] Eloquent Collections and Models > When storing Eloquent Collections and Models in Livewire properties, additional query constraints like select(...) will not be re-applied on subsequent requests. > > See [Eloquent constraints aren't preserved between requests](#eloquent-constraints-arent-preserved-between-requests) for more details Here's a quick example of setting properties as these various types: ```php 56 changes: 55 additions & 1 deletion56 docs/testing.md @@ -319,6 +319,57 @@ class SearchPostsTest extends TestCase } ``` ### Setting cookies If your Livewire component depends on cookies, you can use the `withCookie()` or `withCookies()` methods to set the cookies manually for your test. Below is a basic `Cart` component that loads a discount token from a cookie on mount: ```php <?php namespace App\Livewire; use Livewire\Component; use Livewire\With\Url; use App\Models\Post; class Cart extends Component { public $discountToken; public mount() { $this->discountToken = request()->cookie('discountToken'); } } ``` As you can see, the `$discountToken` property above gets its value from a cookie in the request. Below is an example of how you would simulate the scenario of loading this component on a page with cookies: ```php <?php namespace Tests\Feature\Livewire; use App\Livewire\Cart; use Livewire\Livewire; use Tests\TestCase; class CartTest extends TestCase { /** @test */ public function can_load_discount_token_from_a_cookie() { Livewire::withCookies(['discountToken' => 'CALEB2023']) ->test(Cart::class) ->assertSet('discountToken', 'CALEB2023'); } } ``` ## Calling actions Livewire actions are typically called from the frontend using something like `wire:click`. @@ -391,7 +442,7 @@ class CreatePostTest extends TestCase If you want to test that a specific validation rule has failed, you can pass an array of rules: ```php $this->assertHasErrors(['title', ['required']]); $this->assertHasErrors(['title' => ['required']]); ``` ### Authorization @@ -587,6 +638,9 @@ Livewire provides many more testing utilities. Below is a comprehensive list of | `Livewire::test(UpdatePost::class, ['post' => $post])` | Test the `UpdatePost` component with the `post` parameter (To be received through the `mount()` method) | | `Livewire::actingAs($user)` | Set the provided user as the session's authenticated user | | `Livewire::withQueryParams(['search' => '...'])` | Set the test's `search` URL query parameter to the provided value (ex. `?search=...`). Typically in the context of a property using Livewire's [`#[Url]` attribute](/docs/url) | | `Livewire::withCookie('color', 'blue')` | Set the test's `color` cookie to the provided value (`blue`). | | `Livewire::withCookies(['color' => 'blue', 'name' => 'Taylor])` | Set the test's `color` and `name` cookies to the provided values (`blue`, `Taylor`). | ### Interacting with components | Method | Description | 2 changes: 1 addition & 1 deletion2 docs/upgrading.md @@ -349,7 +349,7 @@ class Dashboard extends Component The three main changes from Livewire 2 are: 1. `emit()` has been renamed to `dispatch()` 1. `emit()` has been renamed to `dispatch()` (Likewise `emitTo()` and `emitSelf()` are now `dispatchTo()` and `dispatchSelf()`) 1. `dispatchBrowserEvent()` has been renamed to `dispatch()` 2. All event parameters must be named 2 changes: 1 addition & 1 deletion2 docs/uploads.md @@ -291,7 +291,7 @@ You can display a loading indicator scoped to the file upload like so: Now, while the file is uploading, the "Uploading..." message will be shown and then hidden when the upload is finished. For more information on loading states, check out our comprehensive [loading state documentation](/docs/loading). For more information on loading states, check out our comprehensive [loading state documentation](/docs/wire-loading). ## Progress indicators 41 changes: 41 additions & 0 deletions41 docs/url.md @@ -158,3 +158,44 @@ class ShowUsers extends Component ``` In the example above, when a user changes the search value from "bob" to "frank" and then clicks the browser's back button, the search value (and the URL) will be set back to "bob" instead of navigating to the previously visited page. ## Using the queryString method The query string can also be defined as a method on the component. This can be useful if some properties have dynamic options. ```php use Livewire\Component; class ShowUsers extends Component { // ... protected function queryString() { return [ 'search' => [ 'as' => 'q', ], ]; } } ``` ## Trait hooks Livewire offers [hooks](/docs/lifecycle-hooks) for query strings as well. ```php trait WithSorting { // ... protected function queryStringWithSorting() { return [ 'sortBy' => ['as' => 'sort'], 'sortDirection' => ['as' => 'direction'], ]; } } ``` 4 changes: 2 additions & 2 deletions4 docs/validation.md @@ -376,10 +376,10 @@ class CreatePost extends Component return [ 'content.required' => 'The :attribute are missing.', 'content.min' => 'The :attribute is too short.', ] ]; } public function attributes() // [tl! highlight:6] public function validationAttributes() // [tl! highlight:6] { return [ 'content' => 'description', 7 changes: 7 additions & 0 deletions7 docs/volt.md @@ -52,6 +52,13 @@ By adding the `--test` directive when generating a component, a corresponding te php artisan make:volt counter --test --pest ``` By adding the `--class` directive it will generate a class-based volt component. ```bash php artisan make:volt counter --class ``` ## API style By utilizing Volt's functional API, we can define a Livewire component's logic through imported `Livewire\Volt` functions. Volt then transforms and compiles the functional code into a conventional Livewire class, enabling us to leverage the extensive capabilities of Livewire with reduced boilerplate. 45 changes: 45 additions & 0 deletions45 docs/wire-click.md @@ -0,0 +1,45 @@ Livewire provides a simple `wire:click` directive for calling component methods (aka actions) when a user clicks a specific element on the page. For example, given the `ShowInvoice` component below: ```php <?php namespace App\Livewire; use Livewire\Component; use App\Models\Invoice; class ShowInvoice extends Component { public Invoice $invoice; public function download() { return response()->download( $this->invoice->file_path, 'invoice.pdf' ); } } ``` You can trigger the `download()` method from the class above when a user clicks a "Download Invoice" button by adding `wire:click="download"`: ```html <button type="button" wire:click="download"> <!-- [tl! highlight] --> Download Invoice </button> ``` ## Using `wire:click` on links When using `wire:click` on `<a>` tags, you must append `.prevent` to prevent the default handling of a link in the browser. Otherwise, the browser will visit the provided link and update the page's URL. ```html <a href="#" wire:click.prevent="..."> ``` ## Going deeper The `wire:click` directive is just one of many different avilable event listeners in Livewire. For full documentation on its (and other event listeners) capabilities, visit [the Livewire actions documentation page](/docs/actions). 67 changes: 67 additions & 0 deletions67 docs/wire-dirty.md @@ -0,0 +1,67 @@ In a traditional HTML page containing a form, the form is only ever submitted when the user presses the "Submit" button. However, Livewire is capable of much more than traditional form submissions. You can validate form inputs in real-time or even save the form as a user types. In these "real-time" update scenarios, it can be helpful to signal to your users when a form or subset of a form has been changed, but hasn't been saved to the database. When a form contains un-saved input, that form is considered "dirty". It only becomes "clean" when a network request has been triggered to synchronize the server state with the client-side state. ## Basic usage Livewire allows you to easily toggle visual elements on the page using the `wire:dirty` directive. By adding `wire:dirty` to an element, you are instructing Livewire to only show the element when the client-side state diverges from the server-side state. To demonstrate, here is an example of an `UpdatePost` form containing a visual "Unsaved changes..." indication that signals to the user that the form contains input that has not been saved: ```blade <form wire:submit="update"> <input type="text" wire:model="title"> <!-- ... --> <button type="submit">Update</button> <div wire:dirty>Unsaved changes...</div> <!-- [tl! highlight] --> </form> ``` Because `wire:dirty` has been added to the "Unsaved changes..." message, the message will be hidden by default. Livewire will automatically display the message when the user starts modifying the form inputs. When the user submits the form, the message will disappear again, since the server / client data is back in sync. ### Removing elements By adding the `.remove` modifier to `wire:dirty`, you can instead show an element by default and only hide it when the component has "dirty" state: ```blade <div wire:dirty.remove>The data is in-sync...</div> ``` ## Targeting property updates Imagine you are using `wire:model.blur` to update a property on the server immediately after a user leaves an input field. In this scenario, you can provide a "dirty" indication for only that property by adding `wire:target` to the element that contains the `wire:dirty` directive. Here is an example of only showing a dirty indication when the title property has been changed: ```blade <form wire:submit="update"> <input wire:model.blur="title"> <div wire:dirty wire:target="title">Unsaved title...</div> <!-- [tl! highlight] --> <button type="submit">Update</button> </form> ``` ## Toggling classes Often, instead of toggling entire elements, you may want to toggle individual CSS classes on an input when its state is "dirty". Below is an example where a user types into an input field and the border becomes yellow, indicating an "unsaved" state. Then, when the user tabs away from the field, the border is removed, indicating that the state has been saved on the server: ```blade <input wire:model.blur="title" wire:dirty.class="border-yellow-500"> ``` 30 changes: 30 additions & 0 deletions30 docs/wire-ignore.md @@ -0,0 +1,30 @@ Livewire's ability to make updates to the page is what makes it "live", however, there are times where you might want to prevent Livewire from updating a portion of the page. In these cases, you can use the `wire:ignore` directive to instruct Livewire to ignore the contents of a particular element, even if they change between requests. This is most useful in the context of working with third-party javascript libraries for custom form inputs and such. Below is an example of wrapping an element used by a third-party library in `wire:ignore` so that Livewire doesn't tamper with the HTML generated by the library: ```blade <form> <!-- ... --> <div wire:ignore> <!-- This element would be reference by a --> <!-- third-party library for initialization... --> <input id="id-for-date-picker-library"> </div> <!-- ... --> </form> ``` You can also instruct Livewire to only ignore changes to attributes of the root element rather than observing changes to it's contents using `wire:ignore.self`. ```blade <div wire:ignore.self> <!-- ... --> </div> ``` 12 changes: 12 additions & 0 deletions12 docs/wire-init.md @@ -0,0 +1,12 @@ Livewire offers a `wire:init` directive to run an action as soon as the component is rendered. This can be helpful in cases where you don't want to hold up the entire page load, but want to load some data immediately after the page load. ```blade <div wire:init="loadPosts"> <!-- ... --> </div> ``` The `loadPosts` action will be run immediately after the Livewire component renders on the page. In most cases however, [Livewire's lazy loading feature](/docs/lazy) is preferable to using `wire:init`. 182 changes: 182 additions & 0 deletions182 docs/wire-loading.md @@ -0,0 +1,182 @@ Loading indicators are an important part of crafting good user interfaces. They give users visual feedback when a request is being made to the server so they know they are waiting for a process to complete. ## Basic usage Livewire provides a simple yet extremely powerful syntax for controlling loading indicators: `wire:loading`. Adding `wire:loading` to any element will hide it by default (using `display: none` in CSS) and show it when a request is sent to the server. Below is a basic example of a `CreatePost` component's form with `wire:loading` being used to toggle a loading message: ```blade <form wire:submit="save"> <!-- ... --> <button type="submit">Save</button> <div wire:loading> <!-- [tl! highlight:2] --> Saving post... </div> </form> ``` When a user presses "Save", the "Saving post..." message will appear below the button while the "save" action is being executed. The message will disappear when the response is received from the server and processed by Livewire. ### Removing elements Alternatively, you can append `.remove` for the inverse effect, showing an element by default and hiding it during requests to the server: ```blade <div wire:loading.remove>...</div> ``` ## Toggling classes In addition to toggling the visibility of entire elements, it's often useful to change the styling of an existing element by toggling CSS classes on and off during requests to the server. This technique can be used for things like changing background colors, lowering opacity, triggering spinning animations, and more. Below is a simple example of using the [Tailwind](https://tailwindcss.com/) class `opacity-50` to make the "Save" button fainter while the form is being submitted: ```blade <button wire:loading.class="opacity-50">Save</button> ``` Like toggling an element, you can perform the inverse class operation by appending `.remove` to the `wire:loading` directive. In the example below, the button's `bg-blue-500` class will be removed when the "Save" button is pressed: ```blade <button class="bg-blue-500" wire:loading.class.remove="bg-blue-500"> Save </button> ``` ## Toggling attributes By default, when a form is submitted, Livewire will automatically disable the submit button and add the `readonly` attribute to each input element while the form is being processed. However, in addition to this default behavior, Livewire offers the `.attr` modifier to allow you to toggle other attributes on an element or toggle attributes on elements that are outside of forms: ```blade <button type="button" wire:click="remove" wire:loading.attr="disabled" > Remove </button> ``` Because the button above isn't a submit button, it won't be disabled by Livewire's default form handling behavior when pressed. Instead, we manually added `wire:loading.attr="disabled"` to achieve this behavior. ## Targeting specific actions By default, `wire:loading` will be triggered whenever a component makes a request to the server. However, in components with multiple elements that can trigger server requests, you should scope your loading indicators down to individual actions. For example, consider the following "Save post" form. In addition to a "Save" button that submits the form, there might also be a "Remove" button that executes a "remove" action on the component. By adding `wire:target` to the following `wire:loading` element, you can instruct Livewire to only show the loading message when the "Remove" button is clicked: ```blade <form wire:submit="save"> <!-- ... --> <button type="submit">Save</button> <button type="button" wire:click="remove">Remove</button> <div wire:loading wire:target="remove"> <!-- [tl! highlight:2] --> Removing post... </div> </form> ``` When the above "Remove" button is pressed, the "Removing post..." message will be displayed to the user. However, the message will not be displayed when the "Save" button is pressed. ### Targeting action parameters In situations where the same action is triggered with different parameters from multiple places on a page, you can further scope `wire:target` to a specific action by passing in additional parameters. For example, consider the following scenario where a "Remove" button exists for each post on the page: ```blade <div> @foreach ($posts as $post) <div wire:key="{{ $post->id }}"> <h2>{{ $post->title }}</h2> <button wire:click="remove({{ $post->id }})">Remove</button> <div wire:loading wire:target="remove({{ $post->id }})"> <!-- [tl! highlight:2] --> Removing post... </div> </div> @endforeach </div> ``` Without passing `{{ $post->id }}` to `wire:target="remove"`, the "Removing post..." message would show when any of the buttons on the page are clicked. However, because we are passing in unique parameters to each instance of `wire:target`, Livewire will only show the loading message when the matching parameters are passed to the "remove" action. ### Targeting property updates Livewire also allows you to target specific component property updates by passing the property's name to the `wire:target` directive. Consider the following example where a form input named `username` uses `wire:model.live` for real-time validation as a user types: ```blade <form wire:submit="save"> <input type="text" wire:model.live="username"> @error('username') <span>{{ $message }}</span> @enderror <div wire:loading wire:target="username"> <!-- [tl! highlight:2] --> Checking availability of username... </div> <!-- ... --> </form> ``` The "Checking availability..." message will show when the server is updated with the new username as the user types into the input field. ## Customizing CSS display property When `wire:loading` is added to an element, Livewire updates the CSS `display` property of the element to show and hide the element. By default, Livewire uses `none` to hide and `inline-block` to show. If you are toggling an element that uses a display value other than `inline-block`, like `flex` in the following example, you can append `.flex` to `wire:loading`: ```blade <div class="flex" wire:loading.flex>...</div> ``` Below is the complete list of available display values: ```blade <div wire:loading.inline-flex>...</div> <div wire:loading.inline>...</div> <div wire:loading.block>...</div> <div wire:loading.table>...</div> <div wire:loading.flex>...</div> <div wire:loading.grid>...</div> ``` ## Delaying a loading indicator On fast connections, updates often happen so quickly that loading indicators only flash briefly on the screen before being removed. In these cases, the indicator is more of a distraction than a helpful affordance. For this reason, Livewire provides a `.delay` modifier to delay the showing of an indicator. For example, if you add `wire:loading.delay` to an element like so: ```blade <div wire:loading.delay>...</div> ``` The above element will only appear if the request takes over 200 milliseconds. The user will never see the indicator if the request completes before then. To customize the amount of time to delay the loading indicator, you can use one of Livewire's helpful interval aliases: ```blade <div wire:loading.delay.shortest>...</div> <!-- 50ms --> <div wire:loading.delay.shorter>...</div> <!-- 100ms --> <div wire:loading.delay.short>...</div> <!-- 150ms --> <div wire:loading.delay>...</div> <!-- 200ms --> <div wire:loading.delay.long>...</div> <!-- 300ms --> <div wire:loading.delay.longer>...</div> <!-- 500ms --> <div wire:loading.delay.longest>...</div> <!-- 1000ms --> ``` 243 changes: 243 additions & 0 deletions243 docs/wire-model.md @@ -0,0 +1,243 @@ Livewire makes it easy to bind a component property's value with form inputs using `wire:model`. Here is a simple example of using `wire:model` to bind the `$title` and `$content` properties with form inputs in a "Create Post" component: ```php use Livewire\Component; use App\Models\Post; class CreatePost extends Component { public $title = ''; public $content = ''; public function save() { $post = Post::create([ 'title' => $this->title 'content' => $this->content ]); // ... } } ``` ```blade <form wire:submit="save"> <label> <span>Title</span> <input type="text" wire:model="title"> <!-- [tl! highlight] --> </label> <label> <span>Content</span> <textarea wire:model="content"></textarea> <!-- [tl! highlight] --> </label> <button type="submit">Save</button> </form> ``` Because both inputs use `wire:model`, their values will be synchronized with the server's properties when the "Save" button is pressed. > [!warning] "Why isn't my component live updating as I type?" > If you tried this in your browser and are confused why the title isn't automatically updating, it's because Livewire only updates a component when an "action" is submitted—like pressing a submit button—not when a user types into a field. This cuts down on network requests and improves performance. To enable "live" updating as a user types, you can use `wire:model.live` instead. [Learn more about data binding](/docs/properties#data-binding). ## Customizing update timing By default, Livewire will only send a network request when an action is performed (like `wire:click` or `wire:submit`), NOT when a `wire:model` input is updated. This drastically improves the performance of Livewire by reducing network requests and providing a smoother experience for your users. However, there are occasions where you may want to update the server more frequently for things like real-time validation. ### Live updating To send property updates to the server as a user types into an input-field, you can append the `.live` modifier to `wire:model`: ```html <input type="text" wire:model.live="title"> ``` #### Customizing the debounce By default, when using `wire:model.live`, Livewire adds a 150 millisecond debounce to server updates. This means if a user is continually typing, Livewire will wait untill the user stops typing for 150 milliseconds before sending a request. You can customize this timing by appending `.debounce.Xms` to the input. Here is an example of changing the debounce to 250 milliseconds: ```html <input type="text" wire:model.live.debounce.250ms="title"> ``` ### Updating on "blur" event By appending the `.blur` modifier, Livewire will only send network requests with property updates when a user clicks away from an input, or presses the tab key to move to the next input. Adding `.blur` is helpful for scenarios where you want to update the server more frequently, but not as a user types. For example, real-time validation is a common instance where `.blur` is helpful. ```html <input type="text" wire:model.blur="title"> ``` ### Updating on "change" event There are times where the behavior of `.blur` isn't exactly what you want and instead `.change` is. For example, if you want to run validation every time a select input is changed, by adding `.change`, Livewire will send a network request and validate the property as soon as a user selects a new option. As opposed to `.blur` which will only update the server after the user tabs away from the select input. ```html <select wire:model.change="title"> <!-- ... --> </select> ``` Any changes made to the text input will be automatically synchronized with the `$title` property in your Livewire component. ## All available modifiers Modifier | Description --- | --- `.live` | Send updates as a user types `.blur` | Only send updates on the `blur` event `.change` | Only send updates on the the `change` event `.lazy` | An alias for `.change` `.debounce.[?]ms` | Debounce the sending of updates by specified millisecond delay `.throttle.[?]ms` | Throttle network request updates by the specified millisecond interval `.number` | Cast the text value of an input to `int` on the server `.fill` | Use the initial value provided by a "value" HTML attribute on page-load ## Input fields Livewire supports most native input elements out of the box. Meaning you should just be able to attach `wire:model` to any input element in the browser and easily bind properties to them. Here's a comprehensive list of the different available input types and how you use them in a Livewire context. ### Text inputs First and foremost, text inputs are the bedrock of most forms. Here's how to bind a property named "title" to one: ```blade <input type="text" wire:model="title"> ``` ### Textarea inputs Textarea elements are similarly straightforward. Simply add `wire:model` to a textarea and the value will be bound: ```blade <textarea type="text" wire:model="content"></textarea> ``` If the "content" value is initialized with a string, Livewire will fill the textarea with that value - there's no need to do something like the following: ```blade <!-- Warning: This snippet demonstrates what NOT to do... --> <textarea type="text" wire:model="content">{{ $content }}</textarea> ``` ### Checkboxes Checkboxes can be used for single values, such as when toggling a boolean property. Or, checkboxes may be used to toggle a single value in a group of related values. We'll discuss both scenarios: #### Single checkbox At the end of a signup form, you might have a checkbox allowing the user to opt-in to email updates. You might call this property `$receiveUpdates`. You can easily bind this value to the checkbox using `wire:model`: ```blade <input type="checkbox" wire:model="receiveUpdates"> ``` Now when the `$receiveUpdates` value is `false`, the checkbox will be unchecked. Of course, when the value is `true`, the checkbox will be checked. #### Multiple checkboxes Now, let's say in addition to allowing the user to decide to receive updates, you have an array property in your class called `$updateTypes`, allowing the user to choose from a variety of update types: ```php public $updateTypes = []; ``` By binding multiple checkboxes to the `$updateTypes` property, the user can select multiple update types and they will be added to the `$updateTypes` array property: ```blade <input type="checkbox" value="email" wire:model="updateTypes"> <input type="checkbox" value="sms" wire:model="updateTypes"> <input type="checkbox" value="notificaiton" wire:model="updateTypes"> ``` For example, if the user checks the first two boxes but not the third, the value of `$updateTypes` will be: `["email", "sms"]` ### Radio buttons To toggle between two different values for a single property, you may use radio buttons: ```blade <input type="radio" value="yes" wire:model="receiveUpdates"> <input type="radio" value="no" wire:model="receiveUpdates"> ``` ### Select dropdowns Livewire makes it simple to work with `<select>` dropdowns. When adding `wire:model` to a dropdown, the currently selected value will be bound to the provided property name and vice versa. In addition, there's no need to manually add `selected` to the option that will be selected - Livewire handles that for you automatically. Below is an example of a select dropdown filled with a static list of states: ```blade <select wire:model="state"> <option value="AL">Alabama<option> <option value="AK">Alaska</option> <option value="AZ">Arizona</option> ... </select> ``` When a specific state is selected, for example, "Alaska", the `$state` property on the component will be set to `AK`. If you would prefer the value to be set to "Alaska" instead of "AK", you can leave the `value=""` attribute off the `<option>` element entirely. Often, you may build your dropdown options dynamically using Blade: ```blade <select wire:model="state"> @foreach (\App\Models\State::all() as $state) <option value="{{ $state->id }}">{{ $state->label }}</option> @endforeach </select> ``` If you don't have a specific option selected by default, you may want to show a muted placeholder option by default, such as "Select a state": ```blade <select wire:model="state"> <option disabled>Select a state...</option> @foreach (\App\Models\State::all() as $state) <option value="{{ $option->id }}">{{ $option->label }}</option> @endforeach </select> ``` As you can see, there is no "placeholder" attribute for a select menu like there is for text inputs. Instead, you have to add a `disabled` option element as the first option in the list. ### Multi-select dropdowns If you are using a "multiple" select menu, Livewire works as expected. In this example, states will be added to the `$states` array property when they are selected and removed if they are deselected: ```blade <select wire:model="states" multiple> <option value="AL">Alabama<option> <option value="AK">Alaska</option> <option value="AZ">Arizona</option> ... </select> ``` ## Going deeper For a more complete documentation on using `wire:model` in the context of HTML forms, visit the [Livewire forms documentation page](/docs/forms). 28 changes: 28 additions & 0 deletions28 docs/wire-navigate.md @@ -0,0 +1,28 @@ Livewire's `wire:navigate` feature makes page navigate much faster, providing an SPA-like experience for your users. This page is a simple reference for the `wire:navigate` directive. Be sure to read the [page on Livewire's Navigate feature](/docs/navigate) for more complete documentation. Below is a simple example of adding `wire:navigate` to links in a nav bar: ```blade <nav> <a href="/" wire:navigate>Dashboard</a> <a href="/posts" wire:navigate>Posts</a> <a href="/users" wire:navigate>Users</a> </nav> ``` When any of these links are clicked, Livewire will intercept the click and instead of allowing the browser to perform a full page visit, Livewire will fetch the page in the background and swap it with the current page (resulting in much faster and smoother page navigation). ## Prefetching pages on hover By adding the `.hover` modifier, Livewire will pre-fetch a page when a user hovers over a link. This way, the page will have already been downloaded from the server when the user clicks on the link. ```blade <a href="/" wire:navigate.hover>Dashboard</a> ``` ## Going deeper For more complete documentation on this feature, visit [Livewire's navigate documentation page](/docs/navigate). 14 changes: 14 additions & 0 deletions14 docs/wire-offline.md @@ -0,0 +1,14 @@ In certain circumstances it can be helpful for your users to know if they are currently connected to the internet. If for example, you have built a blogging platform on Livewire, you may want to notify your users in some way if they are offline so that they don't draft an entire blog post without the ability for Livewire to save it to the database. Livewire make this trivial by providing the `wire:offline` directive. By attaching `wire:offline` to an element in your Livewire component, it will be hidden by default and only be displayed when Livewire detects the network connection has been interupted and is unavailable. It will then dissapear again when the network has regained connection. For example: ```blade <p class="alert alert-warning" wire:offline> Whoops, your device has lost connection. The web page you are viewing is offline. </p> ``` 78 changes: 78 additions & 0 deletions78 docs/wire-poll.md @@ -0,0 +1,78 @@ Polling is a technique used in web applications to "poll" the server (send requests on a regular interval) for updates. It's a simple way to keep a page up-to-date without the need for a more sophisticated technology like [WebSockets](/docs/events#real-time-events-using-laravel-echo). ## Basic usage Using polling inside Livewire is as simple as adding `wire:poll` to an element. Below is an example of a `SubscriberCount` component that shows a user's subscriber count: ```php <?php namespace App\Livewire; use Illuminate\Support\Facades\Auth; use Livewire\Component; class SubscriberCount extends Component { public function render() { return view('livewire.subscriber-count', [ 'count' => Auth::user()->subscribers->count(), ]); } } ``` ```blade <div wire:poll> <!-- [tl! highlight] --> Subscribers: {{ $count }} </div> ``` Normally, this component would show the subscriber count for the user and never update until the page was refreshed. However, because of `wire:poll` on the component's template, this component will now refresh itself every `2.5` seconds, keeping the subscriber count up-to-date. You can also specify an action to fire on the polling interval by passing a value to `wire:poll`: ```blade <div wire:poll="refreshSubscribers"> Subscribers: {{ $count }} </div> ``` Now, the `refreshSubscribers()` method on the component will be called every `2.5` seconds. ## Timing control The primary drawback of polling is that it can be resource intensive. If you have a thousand visitors on a page that uses polling, one thousand network requests will be triggered every `2.5` seconds. The best way to reduce requests in this scenario is simply to make the polling interval longer. You can manually control how often the component will poll by appending the desired duration to `wire:poll` like so: ```blade <div wire:poll.15s> <!-- In seconds... --> <div wire:poll.15000ms> <!-- In milliseconds... --> ``` ## Background throttling To further cut down on server requests, Livewire automatically throttles polling when a page is in the background. For example, if a user keeps a page open in a different browser tab, Livewire will reduce the number of polling requests by 95% until the user revisits the tab. If you want to opt-out of this behavior and keep polling continuously, even when a tab is in the background, you can add the `.keep-alive` modifier to `wire:poll`: ```blade <div wire:poll.keep-alive> ``` ## Viewport throttling Another measure you can take to only poll when necessary, is to add the `.visible` modifier to `wire:poll`. The `.visible` modifier instructs Livewire to only poll the component when it is visible on the page: ```blade <div wire:poll.visible> ``` If a component using `wire:visible` is at the bottom of a long page, it won't start polling until the user scrolls it into the viewport. When the user scrolls away, it will stop polling again. 158 changes: 158 additions & 0 deletions158 docs/wire-stream.md @@ -0,0 +1,158 @@ Livewire allows you to stream content to a web page before a request is complete via the `wire:stream` API. This is an extremely useful feature for things like AI chat-bots which stream responses as they are generated. To demonstrate the most basic functionality of `wire:stream`, below is a simple CountDown component that when a button is pressed displays a count-down to the user from "3" to "0": ```php use Livewire\Component; class CountDown extends Component { public $start = 3; public function begin() { while ($start >= 0) { // Stream the current count to the browser... $this->stream( // [tl! highlight:4] to: 'count', content: $this->start, replace: true, ); // Pause for 1 second between numbers... sleep(1); // Decrement the counter... $this->start = $this->start - 1; }; } public function render() { return <<<HTML <div> <button wire:click="begin">Start count-down</button> <h1>Count: <span wire:stream="count">{{ $start }}</span></h1> <!-- [tl! highlight] --> </div> HTML; } } ``` Here's what's happening from the user's perspective when they press "Start count-down": * "Count: 3" is shown on the page * They press the "Start count-down" button * One second elapses and "Count: 2" is shown * This process continues until "Count: 0" is shown All of the above happens while a single network request is out to the server. Here's what's happening from the system's perspective when the button is pressed: * A request is sent to Livewire to call the `begin()` method * The `begin()` method is called and the `while` loop begins * `$this->stream()` is called and immediately starts a "streamed response" to the browser * The browser receives a streamed response with instructions to find the element in the component with `wire:stream="count"`, and replace its contents with the received payload ("3" in the case of the first streamed number) * The `sleep(1)` method causes the server to hang for one second * The `while` loop is repeated and the process of streaming a new number ever second continues until the `while` condition is falsy * When `begin()` has finished running and all the counts have beeen streamed to the browser, Livewire finishes it's request lifecycle, rendering the component and sending the final response to the browser ## Streaming chat-bot responses A common use-case for `wire:stream` is streaming chat-bot responses as they are received from an API that supports streamed responses (like [OpenAI's ChatGPT](https://chat.openai.com/)). Below is an example of using `wire:stream` to accomplish a ChatGPT-like interface: ```php use Livewire\Component; class ChatBot extends Component { public $prompt = ''; public $question = ''; public $answer = ''; function submitPrompt() { $this->question = $this->prompt; $this->prompt = ''; $this->js('$wire.ask()'); } function ask() { $this->answer = OpenAI::ask($this->question, function ($partial) { $this->stream(to: 'answer', content: $partial); // [tl! highlight] }); } public function render() { return <<<'HTML' <div> <section> <div>ChatBot</div> @if ($question) <article> <hgroup> <h3>User</h3> <p>{{ $question }}</p> </hgroup> <hgroup> <h3>ChatBot</h3> <p wire:stream="answer">{{ $answer }}</p> <!-- [tl! highlight] --> </hgroup> </article> @endif </section> <form wire:submit="submitPrompt"> <input wire:model="prompt" type="text" placeholder="Send a message" autofocus> </form> </div> HTML; } } ``` Here's what's going on in the above example: * A user types into a text field labelled "Send a message" to ask the chat-bot a question * They press the [Enter] key * A network request is sent to the server, sets the message to the `$question` property, and clears the `$prompt` property. * The response is sent back to the browser and the input is cleared. Because `$this->js('...')` was called, a new request is triggered to the server calling the `ask()` method. * The `ask()` method calls on the ChatBot API and receives streamed response partials via the `$partial` parameter in the callback. * Each `$partial` get's streamed to the browser into the `wire:stream="answer"` element on the page, showing the answer progressively reveal itself to the user * When the entire response is received, the Livewire request finishes and the user receives the full response. ## Replace vs. append When streaming content to an element using `$this->stream()`, you can tell Livewire to either replace the contents of the target element with the streamed contents or append them to the existing contents. Replacing or appending can both be desireable depending on the scenario. For example, when streaming a response from a chatbot, typically appending is desired (and is therefore the default). However, when showing something like a count-down, replacing is more fitting. You can configure either by passing the `replace:` parameter to `$this->stream` with a boolean value: ```php // Append contents... $this->stream(to: 'target', content: '...'); // Replace contents... $this->stream(to: 'target', content: '...', replace: true); ``` Append/replace can also be specified at the target element level by appending or removing the `.replace` modifier: ```blade // Append contents... <div wire:stream="target"> // Replace contents... <div wire:stream.replace="target"> ``` 60 changes: 60 additions & 0 deletions60 docs/wire-submit.md @@ -0,0 +1,60 @@ Livewire makes it easy to handle form submissions via the `wire:submit` directive. By adding `wire:submit` to a `<form>` element, Livewire will intercept the form submission, prevent the default browser handling, and call any Livewire component method. Here's a basic example of using `wire:submit` to handle a "Create Post" form submission: ```php <?php namespace App\Livewire; use Livewire\Component; use App\Models\Post; class CreatePost extends Component { public $title = ''; public $content = ''; public function save() { Post::create([ 'title' => $this->title, 'content' => $this->content, ]); return redirect()->to('/posts'); } public function render() { return view('livewire.create-post'); } } ``` ```blade <form wire:submit="save"> <!-- [tl! highlight] --> <input type="text" wire:model="title"> <textarea wire:model="content"></textarea> <button type="submit">Save</button> </form> ``` In the above example, when a user submits the form by clicking "Save", `wire:submit` intercepts the `submit` event and calls the `save()` action on the server. > [!info] Livewire automatically calls `preventDefault()` > `wire:submit` is different than other Livewire event handlers in that it internally calls `event.preventDefault()` without the need for the `.prevent` modifier. This is because there are very few instances you would be listening for the `submit` event and NOT want to prevent it's default browser handling (performing a full form submission to an endpoint). > [!info] Livewire automatically disables forms while submitting > By default, when Livewire is sending a form submission to the server, it will disable form submit buttons and mark all form inputs as `readonly`. This way a user cannot submit the same form again until the initial submission is complete. ## Going deeper `wire:submit` is just one of many event listeners that Livewire provides. The following two pages provide much more complete documentation on using `wire:submit` in your application: * [Responding to browser events with Livewire](/docs/actions) * [Creating forms in Livewire](/docs/forms) 168 changes: 168 additions & 0 deletions168 docs/wire-transition.md @@ -0,0 +1,168 @@ ## Basic usage Showing or hiding content in Livewire is as simple as using one of Blade's conditional directives like `@if`. To enhance this experience for your users, Livewire provides a `wire:transition` directive that allows you to transition conditional elements smoothly in and out of the page. For example, below is a `ShowPost` component with the ability to toggle viewing comments on and off: ```php use App\Models\Post; class ShowPost extends Component { public Post $post; public $showComments = false; } ``` ```blade <div> <!-- ... --> <button wire:click="$set('showComments', true)">Show comments</button> @if ($showComments) <div wire:transition> <!-- [tl! highlight] --> @foreach ($post->comments as $comment) <!-- ... --> @endforeach </div> @endif </div> ``` Because `wire:transition` has been added to the `<div>` containing the post's comments, when the "Show comments" button is pressed, `$showComments` will be set to `true` and the comments will "fade" onto the page instead of abruptly appearing. ## Default transition style By default, Livewire applies both an opacity and a scale CSS transition to elements with `wire:transtion`. Here's a visual preview: <div x-data="{ show: false }" x-cloak class="border border-gray-700 rounded-xl p-6 w-full flex justify-between"> <a href="#" x-on:click.prevent="show = ! show" class="py-2.5 outline-none"> Preview transition <span x-text="show ? 'out' : 'in →'">in</span> </a> <div class="hey"> <div x-show="show" x-transition class="inline-flex px-16 py-2.5 rounded-[10px] bg-pink-400 text-white uppercase font-medium transition focus-visible:outline-none focus-visible:!ring-1 focus-visible:!ring-white" style=" background: linear-gradient(109.48deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.1) 100%), #EE5D99; box-shadow: inset 0px -1px 0px rgba(0, 0, 0, 0.5), inset 0px 1px 0px rgba(255, 255, 255, 0.1); " > </div> </div> </div> The above transition uses the following values for transitioning by default: Transition in | Transition out --- | --- `duration: 150ms` | `duration: 75ms` `opacity: [0 - 100]` | `opacity: [100 - 0]` `transform: scale([0.95 - 1])` | `transform: scale([1 - 0.95])` ## Customizing transitions To customize the CSS Livewire internally uses when transitioning, you can use any combination of the available modifiers: Modifier | Description --- | --- `.in` | Only transition the element "in" `.out` | Only transition the element "out" `.duration.[?]ms` | Customize the transtion duration in milliseconds `.duration.[?]s` | Customize the transtion duration in seconds `.delay.[?]ms` | Customize the transtion delay in milliseconds `.delay.[?]s` | Customize the transtion delay in seconds `.opacity` | Only apply the opacity transition `.scale` | Only apply the scale transition `.origin.[top\|bottom\|left\|right]` | Customize the scale "origin" used Below is a list of various transition combinations that may help to better visualize these customizations: **Fade-only transition** By default Livewire both fades and scales the element when transitioning. You can disable scaling and only fade by adding the `.opacity` modifier. This is useful for things like transitioning a full-page overlay, where adding a scale doesn't make sense. ```html <div wire:transition.opacity> ``` <div x-data="{ show: false }" x-cloak class="border border-gray-700 rounded-xl p-6 w-full flex justify-between"> <a href="#" x-on:click.prevent="show = ! show" class="py-2.5 outline-none"> Preview transition <span x-text="show ? 'out' : 'in →'">in</span> </a> <div class="hey"> <div x-show="show" x-transition.opacity class="inline-flex px-16 py-2.5 rounded-[10px] bg-pink-400 text-white uppercase font-medium transition focus-visible:outline-none focus-visible:!ring-1 focus-visible:!ring-white" style=" background: linear-gradient(109.48deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.1) 100%), #EE5D99; box-shadow: inset 0px -1px 0px rgba(0, 0, 0, 0.5), inset 0px 1px 0px rgba(255, 255, 255, 0.1); " > ... </div> </div> </div> **Fade-out transition** A common transition technique is to show an element immediately when transitioning in, and fade its opacity when transitioning out. You'll notice this effect on most native MacOS dropdowns and menus. Therefore it's commonly applied on the web to dropdowns, popovers, and menus. ```html <div wire:transition.out.opacity.duration.200ms> ``` <div x-data="{ show: false }" x-cloak class="border border-gray-700 rounded-xl p-6 w-full flex justify-between"> <a href="#" x-on:click.prevent="show = ! show" class="py-2.5 outline-none"> Preview transition <span x-text="show ? 'out' : 'in →'">in</span> </a> <div class="hey"> <div x-show="show" x-transition.out.opacity.duration.200ms class="inline-flex px-16 py-2.5 rounded-[10px] bg-pink-400 text-white uppercase font-medium transition focus-visible:outline-none focus-visible:!ring-1 focus-visible:!ring-white" style=" background: linear-gradient(109.48deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.1) 100%), #EE5D99; box-shadow: inset 0px -1px 0px rgba(0, 0, 0, 0.5), inset 0px 1px 0px rgba(255, 255, 255, 0.1); " > ... </div> </div> </div> **Origin-top transition** When using Livewire to transition an element such as a dropdown menu, it makes sense to scale in from the top of the menu as the origin, rather than center (Livewire's default). This way the menu feels visually anchored to the element that triggered it. ```html <div wire:transition.scale.origin.top> ``` <div x-data="{ show: false }" x-cloak class="border border-gray-700 rounded-xl p-6 w-full flex justify-between"> <a href="#" x-on:click.prevent="show = ! show" class="py-2.5 outline-none"> Preview transition <span x-text="show ? 'out' : 'in →'">in</span> </a> <div class="hey"> <div x-show="show" x-transition.origin.top class="inline-flex px-16 py-2.5 rounded-[10px] bg-pink-400 text-white uppercase font-medium transition focus-visible:outline-none focus-visible:!ring-1 focus-visible:!ring-white" style=" background: linear-gradient(109.48deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.1) 100%), #EE5D99; box-shadow: inset 0px -1px 0px rgba(0, 0, 0, 0.5), inset 0px 1px 0px rgba(255, 255, 255, 0.1); " > ... </div> </div> </div> > [!tip] Livewire uses Alpine transitions behind the scenes > When using `wire:transition` on an element, Livewire is internally applying Alpine's `x-transition` directive. Therefore you can use most if not all syntaxes you would normally use with `x-transition`. Check out [Alpine's transition documentation](https://alpinejs.dev/directives/transition) for all its capabilities. 3 changes: 2 additions & 1 deletion3 js/directives/wire-loading.js @@ -19,14 +19,15 @@ directive('loading', ({ el, directive, component }) => { }) function applyDelay(directive) { if (! directive.modifiers.includes('delay')) return [i => i(), i => i()] if (! directive.modifiers.includes('delay') || directive.modifiers.includes('none')) return [i => i(), i => i()] let duration = 200 let delayModifiers = { 'shortest': 50, 'shorter': 100, 'short': 150, 'default': 200, 'long': 300, 'longer': 500, 'longest': 1000, 2 changes: 1 addition & 1 deletion2 js/directives/wire-model.js @@ -36,7 +36,7 @@ directive('model', ({ el, directive, component, cleanup }) => { } let isLive = modifiers.includes('live') let isLazy = modifiers.includes('lazy') let isLazy = modifiers.includes('lazy') || modifiers.includes('change') let onBlur = modifiers.includes('blur') let isDebounced = modifiers.includes('debounce') 2 changes: 0 additions & 2 deletions2 js/directives/wire-stream.js @@ -2,8 +2,6 @@ import { contentIsFromDump } from '@/utils' import { directive } from '@/directives' import { on, trigger } from '@/events' // Warning: this API is still in development and is subject to change... directive('stream', ({el, directive, component, cleanup }) => { let { expression, modifiers } = directive 2 changes: 1 addition & 1 deletion2 js/directives/wire-transition.js @@ -7,7 +7,7 @@ on('morph.added', ({ el }) => { }) directive('transition', ({ el, directive, component, cleanup }) => { let visibility = Alpine.reactive({ state: false }) let visibility = Alpine.reactive({ state: el.__addedByMorph ? false : true }) // We're going to control the element's transition with Alpine transitions... Alpine.bind(el, { 4 changes: 1 addition & 3 deletions4 js/features/supportEntangle.js @@ -5,7 +5,7 @@ import Alpine from 'alpinejs' export function generateEntangleFunction(component, cleanup) { if (! cleanup) cleanup = () => {} return (name, live) => { return (name, live = false) => { let isLive = live let livewireProperty = name let livewireComponent = component.$wire @@ -19,7 +19,6 @@ export function generateEntangleFunction(component, cleanup) { return } queueMicrotask(() => { let release = Alpine.entangle({ // Outer scope... get() { @@ -39,7 +38,6 @@ export function generateEntangleFunction(component, cleanup) { }) cleanup(() => release()) }) return livewireComponent.get(name) }, obj => { 8 changes: 1 addition & 7 deletions8 js/features/supportNavigate.js @@ -1,14 +1,8 @@ import { on, trigger } from "@/events" let isNavigating = false shouldHideProgressBar() && Alpine.navigate.disableProgressBar() document.addEventListener('alpine:navigated', e => { if (e.detail && e.detail.init) return isNavigating = true // Forward a "livewire" version of the Alpine event... document.dispatchEvent(new CustomEvent('livewire:navigated', { bubbles: true })) }) @@ -21,7 +15,7 @@ document.addEventListener('alpine:navigating', e => { export function shouldRedirectUsingNavigateOr(effects, url, or) { let forceNavigate = effects.redirectUsingNavigate if (forceNavigate || isNavigating) { if (forceNavigate) { Alpine.navigate(url) } else { or() 8 changes: 4 additions & 4 deletions8 js/plugins/navigate/bar.js @@ -78,13 +78,13 @@ function destroyBar() { function injectStyles() { let style = document.createElement('style') style.innerHTML = `/* Make clicks pass-through */ #nprogress { pointer-events: none; } #nprogress .bar { // background: #FC70A9; background: #29d; background: var(--livewire-progress-bar-color, #29d); position: fixed; z-index: 1031; @@ -125,8 +125,8 @@ function injectStyles() { box-sizing: border-box; border: solid 2px transparent; border-top-color: #29d; border-left-color: #29d; border-top-color: var(--livewire-progress-bar-color, #29d); border-left-color: var(--livewire-progress-bar-color, #29d); border-radius: 50%; -webkit-animation: nprogress-spinner 400ms linear infinite; 6 changes: 3 additions & 3 deletions6 js/plugins/navigate/index.js @@ -126,7 +126,7 @@ export default function (Alpine) { // Because DOMContentLoaded is fired on first load, // we should fire alpine:navigated as a replacement as well... setTimeout(() => { fireEventForOtherLibariesToHookInto('alpine:navigated', true) fireEventForOtherLibariesToHookInto('alpine:navigated') }) } @@ -148,8 +148,8 @@ function preventAlpineFromPickingUpDomChanges(Alpine, callback) { }) } function fireEventForOtherLibariesToHookInto(eventName, init = false) { document.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: { init } })) function fireEventForOtherLibariesToHookInto(eventName) { document.dispatchEvent(new CustomEvent(eventName, { bubbles: true })) } function nowInitializeAlpineOnTheNewPage(Alpine) { 5 changes: 5 additions & 0 deletions5 js/utils.js @@ -168,6 +168,11 @@ export function isSynthetic(subject) { export function getCsrfToken() { // Purposely not caching. Fetching it fresh every time ensures we're // not depending on a stale session's CSRF token... if (document.querySelector('meta[name="csrf-token"]')) { return document.querySelector('meta[name="csrf-token"]').getAttribute('content') } if (document.querySelector('[data-csrf]')) { return document.querySelector('[data-csrf]').getAttribute('data-csrf') } 2 changes: 2 additions & 0 deletions2 legacy_tests/Browser/Loading/ComponentWithLoadingDelays.php @@ -20,9 +20,11 @@ public function render() <div> <button wire:click="$refresh" dusk="load">Load</button> <h1 wire:loading.delay.none dusk="delay-none">Loading delay none</h1> <h1 wire:loading.delay.shortest dusk="delay-shortest">Loading delay shortest</h1> <h1 wire:loading.delay.shorter dusk="delay-shorter">Loading delay shorter</h1> <h1 wire:loading.delay.short dusk="delay-short">Loading delay short</h1> <h1 wire:loading.delay.default dusk="delay-default">Loading delay default</h1> <h1 wire:loading.delay dusk="delay">Loading delay</h1> <h1 wire:loading.delay.long dusk="delay-long">Loading delay long</h1> <h1 wire:loading.delay.longer dusk="delay-longer">Loading delay longer</h1> 9 changes: 8 additions & 1 deletion9 legacy_tests/Browser/Loading/Test.php @@ -158,8 +158,13 @@ public function test_different_delay_durations() { $this->browse(function ($browser) { $this->visitLivewireComponent($browser, ComponentWithLoadingDelays::class) ->assertNotVisible('@delay-none') ->assertNotVisible('@delay-shortest') ->waitForLivewire(function (Browser $browser) { $browser->click('@load') ->assertNotVisible('@delay-shortest') ->assertVisible('@delay-none'); })->waitForLivewire(function (Browser $browser) { $browser->click('@load') ->pause(51) ->assertNotVisible('@delay-shorter') @@ -173,12 +178,14 @@ public function test_different_delay_durations() $browser->click('@load') ->pause(151) ->assertNotVisible('@delay') ->assertNotVisible('@delay-default') ->assertVisible('@delay-short'); })->waitForLivewire(function (Browser $browser) { $browser->click('@load') ->pause(201) ->assertNotVisible('@delay-long') ->assertVisible('@delay'); ->assertVisible('@delay') ->assertVisible('@delay-default'); })->waitForLivewire(function (Browser $browser) { $browser->click('@load') ->pause(301) 84 changes: 42 additions & 42 deletions84 package-lock.json 7 changes: 6 additions & 1 deletion7 src/Features/SupportAttributes/SupportAttributes.php @@ -51,7 +51,12 @@ function update($propertyName, $fullPath, $newValue) ->whereInstanceOf(LivewireAttribute::class) ->filter(fn ($attr) => $attr->getLevel() === AttributeLevel::PROPERTY) // Call "update" on the root property attribute even if it's a deep update... ->filter(fn ($attr) => str($fullPath)->startsWith($attr->getName())) ->filter(function ($attr) use ($fullPath) { $attributeRoot = (string) str($attr->getName())->before('.'); $updatePathRoot = (string) str($fullPath)->before('.'); return $attributeRoot === $updatePathRoot; }) ->map(function ($attribute) use ($fullPath, $newValue) { if (method_exists($attribute, 'update')) { return $attribute->update($fullPath, $newValue); 3 changes: 3 additions & 0 deletions3 src/Features/SupportComputed/BaseComputed.php @@ -18,6 +18,7 @@ function __construct( public $persist = false, public $seconds = 3600, // 1 hour... public $cache = false, public $key = null, ) {} function boot() @@ -118,6 +119,8 @@ protected function generatePersistedKey() protected function generateCachedKey() { if ($this->key) return $this->key; return 'lw_computed.'.$this->component->getName().'.'.$this->getName(); } 221 changes: 221 additions & 0 deletions221 src/Features/SupportEntangle/BrowserTest.php @@ -10,6 +10,120 @@ class BrowserTest extends BrowserTestCase { /** @test */ public function can_persist_entangled_data() { Livewire::visit(new class extends Component { public $input; public function render() { return <<<'BLADE' <div> <div x-data="{ value: $persist(@entangle('input')) }"> <input dusk="input" x-model="value" /> </div> </div> BLADE; } }) ->type('@input', 'Hello World') ->assertScript('localStorage.getItem("_x_value") == \'"Hello World"\'') ->tap(fn ($b) => $b->refresh()) ->assertScript("localStorage.getItem('_x_value')", '"Hello World"') ; } /** @test */ public function is_not_live_by_default() { Livewire::visit(new class extends Component { public $foo = 'foo'; function render() { return <<<'HTML' <div> <div x-data="{ state: $wire.$entangle('foo') }"> <button dusk="set" x-on:click="state = 'bar'" type="button"> Set to bar </button> </div> <div dusk="state">{{ $foo }}</div> <button dusk="refresh" x-on:click="$wire.$refresh()" type="button"> Refresh </button> </div> HTML; } }) ->assertSeeIn('@state', 'foo') ->waitForNoLivewire()->click('@set') ->assertSeeIn('@state', 'foo') ->waitForLivewire()->click('@refresh') ->assertSeeIn('@state', 'bar'); } /** @test */ public function can_be_forced_to_not_be_live() { Livewire::visit(new class extends Component { public $foo = 'foo'; function render() { return <<<'HTML' <div> <div x-data="{ state: $wire.$entangle('foo', false) }"> <button dusk="set" x-on:click="state = 'bar'" type="button"> Set to bar </button> </div> <div dusk="state">{{ $foo }}</div> <button dusk="refresh" x-on:click="$wire.$refresh()" type="button"> Refresh </button> </div> HTML; } }) ->assertSeeIn('@state', 'foo') ->waitForNoLivewire()->click('@set') ->assertSeeIn('@state', 'foo') ->waitForLivewire()->click('@refresh') ->assertSeeIn('@state', 'bar'); } /** @test */ public function can_be_live() { Livewire::visit(new class extends Component { public $foo = 'foo'; function render() { return <<<'HTML' <div> <div x-data="{ state: $wire.$entangle('foo', true) }"> <button dusk="set" x-on:click="state = 'bar'" type="button"> Set to bar </button> </div> <div dusk="state">{{ $foo }}</div> </div> HTML; } }) ->assertSeeIn('@state', 'foo') ->waitForLivewire()->click('@set') ->assertSeeIn('@state', 'bar'); } /** @test */ public function can_remove_entangled_components_from_dom_without_side_effects() { @@ -165,4 +279,111 @@ function render() ->assertMissing('@item2') ->assertMissing('@item3'); } /** @test */ public function can_removed_nested_items_without_multiple_requests_when_entangled_items_are_present() { Livewire::visit(new class extends Component { public $components = []; public $filters = [ 'name' => 'bob', 'phone' => '123', 'address' => 'street', ]; public $counter = 0; public function boot() { $this->counter++; } public function removeFilter($filter) { $this->filters[$filter] = null; } public function addBackFiltersWithEntangled() { $this->filters = [ 'name' => 'bob', 'phone' => '123', 'address' => 'street', 'entangled' => 'hello world', // This filter will be entangled, ]; } public function addBackFiltersWithoutEntangled() { // Add back the same non entangled filters to show that // removing/adding non entangled items is not the issue. $this->filters = [ 'name' => 'bob', 'phone' => '123', 'address' => 'street', ]; } function render() { return <<<'HTML' <div> <div>Page</div> <div dusk="counter">Boot counter: {{ $counter }}</div> @foreach ($filters as $filter => $value) @if ($filter === 'entangled') <div x-data="{ value: $wire.entangle('filters.{{ $filter }}') }"> <span x-text="'Entangled: ' + value" ></span> </div> <div> <button dusk="remove-{{ $filter }}" wire:click="removeFilter('{{ $filter }}')">Remove {{ $filter }}</button> </div> @else <div> Normal: {{ $value }} </div> <div> <button dusk="remove-{{ $filter }}" wire:click="removeFilter('{{ $filter }}')">Remove {{ $filter }}</button> </div> @endif @endforeach <button dusk="add-entangled-filter" wire:click="addEntangledFilter()">Add entangled filter</button> <button dusk="add-back-filters-without-entangled" wire:click="addBackFiltersWithoutEntangled()">Add back filters without entangled</button> <button dusk="add-back-filters-with-entangled" wire:click="addBackFiltersWithEntangled()">Add back filters with entangled</button> </div> HTML; } }) ->assertSeeIn('@counter', '1') ->waitForLivewire()->click('@remove-name') ->assertSeeIn('@counter', '2') ->waitForLivewire()->click('@remove-phone') ->assertSeeIn('@counter', '3') ->waitForLivewire()->click('@remove-address') ->assertSeeIn('@counter', '4') ->waitForLivewire()->click('@add-back-filters-without-entangled') ->assertSeeIn('@counter', '5') ->waitForLivewire()->click('@remove-name') ->assertSeeIn('@counter', '6') ->waitForLivewire()->click('@remove-phone') ->assertSeeIn('@counter', '7') ->waitForLivewire()->click('@remove-address') ->assertSeeIn('@counter', '8') ->waitForLivewire()->click('@add-back-filters-with-entangled') ->assertSeeIn('@counter', '9') ->waitForLivewire()->click('@remove-name') ->assertSeeIn('@counter', '10') // This test will fail here since there will be duplicate requests ->waitForLivewire()->click('@remove-phone') ->assertSeeIn('@counter', '11') ->waitForLivewire()->click('@remove-address') ->assertSeeIn('@counter', '12') ->waitForLivewire()->click('@remove-entangled') ->assertSeeIn('@counter', '13'); } } 16 changes: 12 additions & 4 deletions16 src/Features/SupportFileUploads/FileUploadController.php @@ -8,10 +8,18 @@ class FileUploadController { public function getMiddleware() { return [[ 'middleware' => FileUploadConfiguration::middleware(), 'options' => [], ]]; /** * Laravel requires the returned array to contain an array for each * middleware with `middleware` and `options` keys. So we'll map * through the file upload config middleware and format them. */ return array_map( fn($middleware) => [ 'middleware' => $middleware, 'options' => [], ], (array) FileUploadConfiguration::middleware() ); } public function handle() 2 changes: 1 addition & 1 deletion2 src/Features/SupportFileUploads/TemporaryUploadedFile.php @@ -87,7 +87,7 @@ public function temporaryUrl() return $this->storage->temporaryUrl( $this->path, now()->addDay()->endOfHour(), ['ResponseContentDisposition' => 'filename="' . $this->getClientOriginalName() . '"'] ['ResponseContentDisposition' => 'attachment; filename="' . $this->getClientOriginalName() . '"'] ); } 19 changes: 17 additions & 2 deletions19 src/Features/SupportFileUploads/UnitTest.php @@ -419,7 +419,22 @@ public function the_global_upload_route_middleware_is_configurable() try { $this->withoutExceptionHandling()->post($url); } catch (\Throwable $th) { $this->assertEquals('Middleware was hit!', $th->getMessage()); $this->assertStringContainsString(DummyMiddleware::class, $th->getMessage()); } } /** @test */ public function the_global_upload_route_middleware_supports_multiple_middleware() { config()->set('livewire.temporary_file_upload.middleware', ['throttle:60,1', DummyMiddleware::class]); $url = GenerateSignedUploadUrl::forLocal(); try { $this->withoutExceptionHandling()->post($url); } catch (\Throwable $th) { $this->assertStringContainsString('throttle:60,1', $th->getMessage()); $this->assertStringContainsString(DummyMiddleware::class, $th->getMessage()); } } @@ -688,7 +703,7 @@ class DummyMiddleware { public function handle($request, $next) { throw new \Exception('Middleware was hit!'); throw new \Exception(implode(',', $request->route()->computedMiddleware)); } } 17 changes: 17 additions & 0 deletions17 src/Features/SupportLockedProperties/UnitTest.php @@ -48,4 +48,21 @@ public function render() { ->assertSet('foo.count', 1) ->set('foo.count', 2); } /** @test */ function can_update_locked_property_with_similar_name() { Livewire::test(new class extends Component { #[BaseLocked] public $count = 1; public $count2 = 1; public function render() { return '<div></div>'; } }) ->assertSet('count2', 1) ->set('count2', 2); } } 89 changes: 89 additions & 0 deletions89 src/Features/SupportModels/BrowserTest.php @@ -0,0 +1,89 @@ <?php namespace Livewire\Features\SupportModels; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Component; use Livewire\Features\SupportEvents\BaseOn; use Livewire\Livewire; use Sushi\Sushi; class BrowserTest extends \Tests\BrowserTestCase { use RefreshDatabase; /** @test */ public function parent_component_with_eloquent_collection_property_does_not_error_when_child_deletes_a_model_contained_within_it() { Livewire::visit([ new class extends Component { public $posts; public function mount() { $this->posts = Post::all(); } #[BaseOn('postDeleted')] public function setPosts() { $this->posts = Post::all(); } public function render() { return <<<'HTML' <div> @foreach($posts as $post) <div wire:key="parent-post-{{ $post->id }}"> <livewire:child wire:key="{{ $post->id }}" :post="$post" /> </div> @endforeach </div> HTML; } }, 'child' => new class extends Component { public $post; public function delete($id) { Post::find($id)->delete(); $this->dispatch('postDeleted'); } public function render() { return <<<'HTML' <div dusk="post-{{ $post->id }}"> {{ $post->title }} <button dusk="delete-{{ $post->id }}" wire:click="delete({{ $post->id }})">Delete</button> </div> HTML; } }, ]) ->waitForLivewireToLoad() ->assertPresent('@post-1') ->assertSeeIn('@post-1', 'Post #1') ->waitForLivewire()->click('@delete-1') ->assertNotPresent('@parent-post-1') ; } } class Post extends Model { use Sushi; protected $guarded = []; public function getRows() { return [ ['id' => 1, 'title' => 'Post #1'], ['id' => 2, 'title' => 'Post #2'], ['id' => 3, 'title' => 'Post #3'], ]; } } 86 changes: 86 additions & 0 deletions86 src/Features/SupportModels/EloquentCollectionSynth.php @@ -0,0 +1,86 @@ <?php namespace Livewire\Features\SupportModels; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Queue\SerializesAndRestoresModelIdentifiers; use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth; class EloquentCollectionSynth extends Synth { use SerializesAndRestoresModelIdentifiers; public static $key = 'elcln'; static function match($target) { return $target instanceof EloquentCollection; } function dehydrate(EloquentCollection $target, $dehydrateChild) { $class = $target::class; $modelClass = $target->getQueueableClass(); /** * `getQueueableClass` above checks all models are the same and * then returns the class. We then instantiate a model object * so we can call `getMorphClass()` on it. * * If no alias is found, this just returns the class name */ $modelAlias = (new $modelClass)->getMorphClass(); $meta = []; $serializedCollection = (array) $this->getSerializedPropertyValue($target); $meta['keys'] = $serializedCollection['id']; $meta['class'] = $class; $meta['modelClass'] = $modelAlias; return [ null, $meta ]; } function hydrate($data, $meta, $hydrateChild) { $class = $meta['class']; $modelClass = $meta['modelClass']; // If no alias found, this returns `null` $modelAlias = Relation::getMorphedModel($modelClass); if (! is_null($modelAlias)) { $modelClass = $modelAlias; } $keys = $meta['keys'] ?? []; if (count($keys) === 0) { return new $class(); } // We are using Laravel's method here for restoring the collection, which ensures // that all models in the collection are restored in one query, preventing n+1 // issues and also only restores models that exist. $collection = (new $modelClass)->newQueryForRestoration($keys)->useWritePdo()->get(); return $collection; } function get(&$target, $key) { throw new \Exception('Can\'t access model properties directly'); } function set(&$target, $key, $value, $pathThusFar, $fullPath) { throw new \Exception('Can\'t set model properties directly'); } function call($target, $method, $params, $addEffect) { throw new \Exception('Can\'t call model methods directly'); } } 25 changes: 18 additions & 7 deletions25 src/Features/SupportModels/ModelSynth.php @@ -17,23 +17,27 @@ static function match($target) { } function dehydrate($target) { if (! $target->exists) { throw new \Exception('Can\'t set model as property if it hasn\'t been persisted yet'); } // If no alias is found, this just returns the class name $alias = $target->getMorphClass(); $serializedModel = (array) $this->getSerializedPropertyValue($target); $serializedModel = $target->exists ? (array) $this->getSerializedPropertyValue($target) : null; $meta = ['class' => $alias]; // If the model doesn't exist as it's an empty model or has been // recently deleted, then we don't want to include any key. if ($serializedModel) $meta['key'] = $serializedModel['id']; return [ null, ['class' => $alias, 'key' => $serializedModel['id']], $meta, ]; } function hydrate($data, $meta) { $key = $meta['key']; $class = $meta['class']; // If no alias found, this returns `null` @@ -43,6 +47,13 @@ function hydrate($data, $meta) { $class = $aliasClass; } // If no key is provided then an empty model is returned if (! array_key_exists('key', $meta)) { return new $class; } $key = $meta['key']; $model = (new $class)->newQueryForRestoration($key)->useWritePdo()->firstOrFail(); return $model; 1 change: 1 addition & 0 deletions1 src/Features/SupportModels/SupportModels.php @@ -10,6 +10,7 @@ static function provide() { app('livewire')->propertySynthesizer([ ModelSynth::class, EloquentCollectionSynth::class, ]); } } 23 changes: 23 additions & 0 deletions23 src/Features/SupportModels/UnitTest.php @@ -70,6 +70,29 @@ public function render() { return <<<'HTML' $this->assertNull($data['post']); } /** @test */ public function unpersisted_models_can_be_assigned_but_no_data_is_persisted_between_requests() { $component = Livewire::test(new class extends \Livewire\Component { public Post $post; public function mount() { $this->post = new Post(); } public function render() { return <<<'HTML' <div>{{ $post->title }}</div> HTML; } }) ->call('$refresh') ->assertSet('post', new Post()) ; $data = $component->getData(); $this->assertNull($data['post']); } /** @test */ public function model_properties_are_lazy_loaded() { 4 changes: 2 additions & 2 deletions4 src/Features/SupportMorphAwareIfStatement/SupportMorphAwareIfStatement.php @@ -71,13 +71,13 @@ static function registerPrecompilers() $entire = preg_replace_callback($generatePattern($openings), function ($matches) { $original = $matches[0]; return '<!-- __BLOCK__ -->'.$original; return '<!--[if BLOCK]><![endif]-->'.$original; }, $entire) ?? $entire; $entire = preg_replace_callback($generatePattern($closings), function ($matches) { $original = $matches[0]; return $original.' <!-- __ENDBLOCK__ -->'; return $original.' <!--[if ENDBLOCK]><![endif]-->'; }, $entire) ?? $entire; return $entire; 12 changes: 6 additions & 6 deletions12 src/Features/SupportMorphAwareIfStatement/UnitTest.php @@ -22,8 +22,8 @@ public function render() { <livewire:foo /> '); $this->assertCount(2, explode('__BLOCK__', $output)); $this->assertCount(2, explode('__ENDBLOCK__', $output)); $this->assertCount(2, explode('<!--[if BLOCK]><![endif]-->', $output)); $this->assertCount(2, explode('<!--[if ENDBLOCK]><![endif]-->', $output)); } /** @test */ @@ -41,8 +41,8 @@ public function handles_custom_blade_conditional_directives() </div> HTML); $this->assertOccurrences(1, '__BLOCK__', $output); $this->assertOccurrences(1, '__ENDBLOCK__', $output); $this->assertOccurrences(1, '<!--[if BLOCK]><![endif]-->', $output); $this->assertOccurrences(1, '<!--[if ENDBLOCK]><![endif]-->', $output); } /** @@ -53,8 +53,8 @@ function foo($occurances, $template, $expectedCompiled = null) { $compiled = $this->compile($template); $this->assertOccurrences($occurances, '__BLOCK__', $compiled); $this->assertOccurrences($occurances, '__ENDBLOCK__', $compiled); $this->assertOccurrences($occurances, '<!--[if BLOCK]><![endif]-->', $compiled); $this->assertOccurrences($occurances, '<!--[if ENDBLOCK]><![endif]-->', $compiled); $expectedCompiled && $this->assertEquals($expectedCompiled, $compiled); } 37 changes: 35 additions & 2 deletions37 src/Features/SupportNavigate/BrowserTest.php @@ -299,7 +299,7 @@ public function can_navigate_to_page_from_child_via_parent_component_without_rel } /** @test */ public function can_redirect_without_reloading_from_a_page_that_was_loaded_by_wire_navigate() public function can_redirect_with_reloading_from_a_page_that_was_loaded_by_wire_navigate() { $this->browse(function ($browser) { $browser @@ -313,7 +313,7 @@ public function can_redirect_without_reloading_from_a_page_that_was_loaded_by_wi ->assertScript('return window._lw_dusk_test') ->click('@redirect.to.first') ->waitFor('@link.to.second') ->assertScript('return window._lw_dusk_test') ->assertScript('return window._lw_dusk_test', false) ->assertSee('On first'); }); } @@ -517,6 +517,33 @@ public function navigate_is_only_triggered_on_left_click() }); } /** @test */ public function livewire_navigated_event_is_fired_on_first_page_load() { $this->browse(function ($browser) { $browser ->visit('/second') ->assertSee('On second') ->assertScript('window.foo_navigated', 'bar'); }); } /** @test */ public function livewire_navigated_event_is_fired_after_redirect_without_reloading() { $this->browse(function ($browser) { $browser ->visit('/first') ->tap(fn ($b) => $b->script('window._lw_dusk_test = true')) ->assertScript('return window._lw_dusk_test') ->assertSee('On first') ->click('@link.to.second') ->waitFor('@link.to.first') ->assertSee('On second') ->assertScript('window.foo_navigated', 'bar'); }); } /** @test */ public function navigate_is_not_triggered_on_cmd_click() { @@ -703,6 +730,12 @@ public function render() @endpersist <script data-navigate-once>window.foo = 'bar';</script> <script> document.addEventListener('livewire:navigated', () => { window.foo_navigated = 'bar' }) </script> </div> HTML; } 10 changes: 8 additions & 2 deletions10 src/Features/SupportPageComponents/HandlesPageComponents.php @@ -17,10 +17,16 @@ function __invoke() $html = app('livewire')->mount($this::class, $params); }); $layoutConfig = $layoutConfig ?: new LayoutConfig; $layoutConfig = $layoutConfig ?: new PageComponentConfig; $layoutConfig->normalizeViewNameAndParamsForBladeComponents(); return SupportPageComponents::renderContentsIntoLayout($html, $layoutConfig); $response = response(SupportPageComponents::renderContentsIntoLayout($html, $layoutConfig)); if (is_callable($layoutConfig->response)) { call_user_func($layoutConfig->response, $response); } return $response; } } 3 changes: 2 additions & 1 deletion3 ...es/SupportPageComponents/LayoutConfig.php → ...ortPageComponents/PageComponentConfig.php @@ -5,10 +5,11 @@ use Illuminate\View\AnonymousComponent; use Livewire\Mechanisms\HandleComponents\ViewContext; class LayoutConfig class PageComponentConfig { public $slots = []; public $viewContext = null; public $response; function __construct( public $type = 'component', 22 changes: 15 additions & 7 deletions22 src/Features/SupportPageComponents/SupportPageComponents.php @@ -20,39 +20,39 @@ static function provide() static function registerLayoutViewMacros() { View::macro('layoutData', function ($data = []) { if (! isset($this->layoutConfig)) $this->layoutConfig = new LayoutConfig; if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; $this->layoutConfig->mergeParams($data); return $this; }); View::macro('section', function ($section) { if (! isset($this->layoutConfig)) $this->layoutConfig = new LayoutConfig; if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; $this->layoutConfig->slotOrSection = $section; return $this; }); View::macro('title', function ($title) { if (! isset($this->layoutConfig)) $this->layoutConfig = new LayoutConfig; if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; $this->layoutConfig->mergeParams(['title' => $title]); return $this; }); View::macro('slot', function ($slot) { if (! isset($this->layoutConfig)) $this->layoutConfig = new LayoutConfig; if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; $this->layoutConfig->slotOrSection = $slot; return $this; }); View::macro('extends', function ($view, $params = []) { if (! isset($this->layoutConfig)) $this->layoutConfig = new LayoutConfig; if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; $this->layoutConfig->type = 'extends'; $this->layoutConfig->slotOrSection = 'content'; @@ -63,7 +63,7 @@ static function registerLayoutViewMacros() }); View::macro('layout', function ($view, $params = []) { if (! isset($this->layoutConfig)) $this->layoutConfig = new LayoutConfig; if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; $this->layoutConfig->type = 'component'; $this->layoutConfig->slotOrSection = 'slot'; @@ -72,6 +72,14 @@ static function registerLayoutViewMacros() return $this; }); View::macro('response', function (callable $callback) { if (! isset($this->layoutConfig)) $this->layoutConfig = new PageComponentConfig; $this->layoutConfig->response = $callback; return $this; }); } static function interceptTheRenderOfTheComponentAndRetreiveTheLayoutConfiguration($callback) @@ -93,7 +101,7 @@ static function interceptTheRenderOfTheComponentAndRetreiveTheLayoutConfiguratio $view->title($titleAttr->content); } $layoutConfig = $view->layoutConfig ?? new LayoutConfig; $layoutConfig = $view->layoutConfig ?? new PageComponentConfig; return function ($html, $replace, $viewContext) use ($view, $layoutConfig) { // Gather up any slots and sections declared in the component template and store them 20 changes: 19 additions & 1 deletion20 src/Features/SupportPageComponents/UnitTest.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; use Livewire\Component; use Livewire\Livewire; @@ -393,6 +394,16 @@ public function can_use_layout_slots_in_full_page_components() ->assertSee('I am a footer - foo'); } /** @test */ public function can_modify_response() { Route::get('/configurable-layout', ComponentWithCustomResponseHeaders::class); $this ->get('/configurable-layout') ->assertHeader('x-livewire', 'awesome'); } /** @test */ public function can_configure_title_in_render_method_and_layout_using_layout_attribute() { @@ -535,7 +546,6 @@ public function render() } } class ComponentWithExtendsLayout extends Component { public function render() @@ -650,6 +660,14 @@ public function render() } } class ComponentWithCustomResponseHeaders extends Component { public function render() { return view('null-view')->response(fn(Response $response) => $response->header('x-livewire', 'awesome')); } } class FrameworkModel extends Model { public function resolveRouteBinding($value, $field = null) 36 changes: 36 additions & 0 deletions36 src/Features/SupportPagination/BrowserTest.php @@ -1012,6 +1012,42 @@ public function render() ->assertSeeIn('@child', 'Child') ; } public function test_pagination_links_scroll_to_top_by_default() { Livewire::visit(new class extends Component { use WithPagination; public function render() { return Blade::render( <<< 'HTML' <div> <div id="top">Top...</div> @foreach ($posts as $post) <h1 wire:key="post-{{ $post->id }}">{{ $post->title }}</h1> @endforeach <div style="min-height: 100vh"> </div> {{ $posts->links() }} <div id="bottom">Bottom...</div> </div> HTML, [ 'posts' => Post::paginate(), ] ); } }) ->scrollTo('#bottom') ->assertNotInViewPort('#top') ->waitForLivewire()->click('@nextPage.before') ->assertInViewPort('#top') ; } } class Post extends Model 3 changes: 2 additions & 1 deletion3 src/Features/SupportPagination/HandlesPagination.php @@ -2,6 +2,7 @@ namespace Livewire\Features\SupportPagination; use Illuminate\Pagination\Paginator; use Illuminate\Support\Str; trait HandlesPagination @@ -17,7 +18,7 @@ public function queryStringHandlesPagination() public function getPage($pageName = 'page') { return $this->paginators[$pageName] ?? 1; return $this->paginators[$pageName] ?? Paginator::resolveCurrentPage($pageName); } public function previousPage($pageName = 'page') 33 changes: 33 additions & 0 deletions33 src/Features/SupportPagination/UnitTest.php @@ -82,6 +82,39 @@ function render() } })->assertSee('Custom pagination theme'); } public function test_calling_pagination_getPage_before_paginate_method_resolve_the_correct_page_number_in_first_visit_or_after_reload() { Livewire::withQueryParams(['page' => 5])->test(new class extends Component { use WithPagination; public int $page = 1; #[Computed] function posts() { $this->page = $this->getPage(); return PaginatorPostTestModel::paginate(); } function render() { return <<<'HTML' <div> @foreach ($this->posts as $post) @endforeach {{ $this->posts->links() }} </div> HTML; } }) ->assertSet('page', 5) ->assertSet('paginators.page', 5) ->call('gotoPage', 3) ->assertSet('page', 3) ->assertSet('paginators.page', 3); } } class ComponentWithPaginationStub extends Component 18 changes: 15 additions & 3 deletions18 src/Features/SupportPagination/views/bootstrap.blade.php @@ -1,3 +1,15 @@ @php if (! isset($scrollTo)) { $scrollTo = 'body'; } $scrollIntoViewJsSnippet = ($scrollTo !== false) ? <<<JS (\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView() JS : ''; @endphp <div> @if ($paginator->hasPages()) <nav> @@ -9,7 +21,7 @@ </li> @else <li class="page-item"> <button type="button" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="previousPage('{{ $paginator->getPageName() }}')" wire:loading.attr="disabled" rel="prev" aria-label="@lang('pagination.previous')">‹</button> <button type="button" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" rel="prev" aria-label="@lang('pagination.previous')">‹</button> </li> @endif @@ -26,7 +38,7 @@ @if ($page == $paginator->currentPage()) <li class="page-item active" wire:key="paginator-{{ $paginator->getPageName() }}-page-{{ $page }}" aria-current="page"><span class="page-link">{{ $page }}</span></li> @else <li class="page-item" wire:key="paginator-{{ $paginator->getPageName() }}-page-{{ $page }}"><button type="button" class="page-link" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')">{{ $page }}</button></li> <li class="page-item" wire:key="paginator-{{ $paginator->getPageName() }}-page-{{ $page }}"><button type="button" class="page-link" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}">{{ $page }}</button></li> @endif @endforeach @endif @@ -35,7 +47,7 @@ {{-- Next Page Link --}} @if ($paginator->hasMorePages()) <li class="page-item"> <button type="button" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="nextPage('{{ $paginator->getPageName() }}')" wire:loading.attr="disabled" rel="next" aria-label="@lang('pagination.next')">›</button> <button type="button" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" rel="next" aria-label="@lang('pagination.next')">›</button> </li> @else <li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')"> 22 changes: 16 additions & 6 deletions22 src/Features/SupportPagination/views/simple-bootstrap.blade.php @@ -1,3 +1,15 @@ @php if (! isset($scrollTo)) { $scrollTo = 'body'; } $scrollIntoViewJsSnippet = ($scrollTo !== false) ? <<<JS (\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView() JS : ''; @endphp <div> @if ($paginator->hasPages()) <nav> @@ -10,12 +22,11 @@ @else @if(method_exists($paginator,'getCursorName')) <li class="page-item"> {{-- // @todo: Remove `wire:key` once mutation observer has been fixed to detect parameter change for the `setPage()` method call --}} <button dusk="previousPage" type="button" class="page-link" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->previousCursor()->encode() }}" wire:click="setPage('{{$paginator->previousCursor()->encode()}}','{{ $paginator->getCursorName() }}')" wire:loading.attr="disabled" rel="prev">@lang('pagination.previous')</button> <button dusk="previousPage" type="button" class="page-link" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->previousCursor()->encode() }}" wire:click="setPage('{{$paginator->previousCursor()->encode()}}','{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" rel="prev">@lang('pagination.previous')</button> </li> @else <li class="page-item"> <button type="button" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="previousPage('{{ $paginator->getPageName() }}')" wire:loading.attr="disabled" rel="prev">@lang('pagination.previous')</button> <button type="button" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" rel="prev">@lang('pagination.previous')</button> </li> @endif @endif @@ -24,12 +35,11 @@ @if ($paginator->hasMorePages()) @if(method_exists($paginator,'getCursorName')) <li class="page-item"> {{-- // @todo: Remove `wire:key` once mutation observer has been fixed to detect parameter change for the `setPage()` method call --}} <button dusk="nextPage" type="button" class="page-link" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->nextCursor()->encode() }}" wire:click="setPage('{{$paginator->nextCursor()->encode()}}','{{ $paginator->getCursorName() }}')" wire:loading.attr="disabled" rel="next">@lang('pagination.next')</button> <button dusk="nextPage" type="button" class="page-link" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->nextCursor()->encode() }}" wire:click="setPage('{{$paginator->nextCursor()->encode()}}','{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" rel="next">@lang('pagination.next')</button> </li> @else <li class="page-item"> <button type="button" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="nextPage('{{ $paginator->getPageName() }}')" wire:loading.attr="disabled" rel="next">@lang('pagination.next')</button> <button type="button" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" rel="next">@lang('pagination.next')</button> </li> @endif @else 23 changes: 17 additions & 6 deletions23 src/Features/SupportPagination/views/simple-tailwind.blade.php @@ -1,3 +1,15 @@ @php if (! isset($scrollTo)) { $scrollTo = 'body'; } $scrollIntoViewJsSnippet = ($scrollTo !== false) ? <<<JS (\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView() JS : ''; @endphp <div> @if ($paginator->hasPages()) <nav role="navigation" aria-label="Pagination Navigation" class="flex justify-between"> @@ -9,12 +21,12 @@ </span> @else @if(method_exists($paginator,'getCursorName')) {{-- // @todo: Remove `wire:key` once mutation observer has been fixed to detect parameter change for the `setPage()` method call --}} <button type="button" dusk="previousPage" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->previousCursor()->encode() }}" wire:click="setPage('{{$paginator->previousCursor()->encode()}}','{{ $paginator->getCursorName() }}')" wire:loading.attr="disabled" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> <button type="button" dusk="previousPage" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->previousCursor()->encode() }}" wire:click="setPage('{{$paginator->previousCursor()->encode()}}','{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> {!! __('pagination.previous') !!} </button> @else <button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> <button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> {!! __('pagination.previous') !!} </button> @endif @@ -25,12 +37,11 @@ {{-- Next Page Link --}} @if ($paginator->hasMorePages()) @if(method_exists($paginator,'getCursorName')) {{-- // @todo: Remove `wire:key` once mutation observer has been fixed to detect parameter change for the `setPage()` method call --}} <button type="button" dusk="nextPage" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->nextCursor()->encode() }}" wire:click="setPage('{{$paginator->nextCursor()->encode()}}','{{ $paginator->getCursorName() }}')" wire:loading.attr="disabled" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> <button type="button" dusk="nextPage" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->nextCursor()->encode() }}" wire:click="setPage('{{$paginator->nextCursor()->encode()}}','{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> {!! __('pagination.next') !!} </button> @else <button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> <button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> {!! __('pagination.next') !!} </button> @endif 22 changes: 17 additions & 5 deletions22 src/Features/SupportPagination/views/tailwind.blade.php @@ -1,3 +1,15 @@ @php if (! isset($scrollTo)) { $scrollTo = 'body'; } $scrollIntoViewJsSnippet = ($scrollTo !== false) ? <<<JS (\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView() JS : ''; @endphp <div> @if ($paginator->hasPages()) <nav role="navigation" aria-label="Pagination Navigation" class="flex items-center justify-between"> @@ -8,15 +20,15 @@ {!! __('pagination.previous') !!} </span> @else <button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> <button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> {!! __('pagination.previous') !!} </button> @endif </span> <span> @if ($paginator->hasMorePages()) <button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> <button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> {!! __('pagination.next') !!} </button> @else @@ -53,7 +65,7 @@ </span> </span> @else <button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.after" rel="prev" class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150" aria-label="{{ __('pagination.previous') }}"> <button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.after" rel="prev" class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150" aria-label="{{ __('pagination.previous') }}"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /> </svg> @@ -79,7 +91,7 @@ <span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 select-none">{{ $page }}</span> </span> @else <button type="button" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 hover:text-gray-500 focus:z-10 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150" aria-label="{{ __('Go to page :page', ['page' => $page]) }}"> <button type="button" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 hover:text-gray-500 focus:z-10 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150" aria-label="{{ __('Go to page :page', ['page' => $page]) }}"> {{ $page }} </button> @endif @@ -91,7 +103,7 @@ <span> {{-- Next Page Link --}} @if ($paginator->hasMorePages()) <button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.after" rel="next" class="relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-r-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150" aria-label="{{ __('pagination.next') }}"> <button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.after" rel="next" class="relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-r-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150" aria-label="{{ __('pagination.next') }}"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> </svg> 5 changes: 2 additions & 3 deletions5 src/Features/SupportStreaming/HandlesStreaming.php @@ -6,10 +6,9 @@ trait HandlesStreaming { function stream($name, $content, $replace = false) function stream($to, $content, $replace = false) { $hook = ComponentHookRegistry::getHook($this, SupportStreaming::class); $hook->stream($name, $content, $replace); } $hook->stream($to, $content, $replace); } } 46 changes: 46 additions & 0 deletions46 src/Features/SupportTesting/DuskBrowserMacros.php @@ -205,6 +205,52 @@ public function __call($method, $params) }; } public function waitForNoLivewire() { return function ($callback = null) { /** @var \Laravel\Dusk\Browser $this */ $id = str()->random(); $this->script([ "window.duskIsWaitingForLivewireRequest{$id} = true", "window.Livewire.hook('request', ({ respond }) => { window.duskIsWaitingForLivewireRequest{$id} = true respond(() => { queueMicrotask(() => { delete window.duskIsWaitingForLivewireRequest{$id} }) }) })", ]); if ($callback) { $callback($this); return $this->waitUsing(6, 25, function () use ($id) { return $this->driver->executeScript("return window.duskIsWaitingForLivewireRequest{$id}"); }, 'Livewire request was triggered'); } // If no callback is passed, make ->waitForNoLivewire a higher-order method. return new class($this, $id) { protected $browser; protected $id; public function __construct($browser, $id) { $this->browser = $browser; $this->id = $id; } public function __call($method, $params) { return tap($this->browser->{$method}(...$params), function ($browser) { $browser->waitUsing(6, 25, function () use ($browser) { return $browser->driver->executeScript("return window.duskIsWaitingForLivewireRequest{$this->id}"); }, 'Livewire request was triggered'); }); } }; }; } public function waitForNavigate() { return function ($callback = null) { 12 changes: 6 additions & 6 deletions12 src/Features/SupportTesting/InitialRender.php @@ -10,14 +10,14 @@ function __construct( protected RequestBroker $requestBroker, ) {} static function make($requestBroker, $name, $params = [], $fromQueryString = []) static function make($requestBroker, $name, $params = [], $fromQueryString = [], $cookies = []) { $instance = new static($requestBroker); return $instance->makeInitialRequest($name, $params, $fromQueryString); return $instance->makeInitialRequest($name, $params, $fromQueryString, $cookies); } function makeInitialRequest($name, $params, $fromQueryString = []) { function makeInitialRequest($name, $params, $fromQueryString = [], $cookies = []) { $uri = '/livewire-unit-test-endpoint/'.str()->random(20); $this->registerRouteBeforeExistingRoutes($uri, function () use ($name, $params) { @@ -27,9 +27,9 @@ function makeInitialRequest($name, $params, $fromQueryString = []) { ]); }); [$response, $componentInstance, $componentView] = $this->extractComponentAndBladeView(function () use ($uri, $fromQueryString) { return $this->requestBroker->temporarilyDisableExceptionHandlingAndMiddleware(function ($requestBroker) use ($uri, $fromQueryString) { return $requestBroker->call('GET', $uri, $fromQueryString); [$response, $componentInstance, $componentView] = $this->extractComponentAndBladeView(function () use ($uri, $fromQueryString, $cookies) { return $this->requestBroker->temporarilyDisableExceptionHandlingAndMiddleware(function ($requestBroker) use ($uri, $fromQueryString, $cookies) { return $requestBroker->call('GET', $uri, $fromQueryString, $cookies); }); }); 3 changes: 2 additions & 1 deletion3 src/Features/SupportTesting/Testable.php @@ -25,7 +25,7 @@ protected function __construct( protected ComponentState $lastState, ) {} static function create($name, $params = [], $fromQueryString = []) static function create($name, $params = [], $fromQueryString = [], $cookies = []) { $name = static::normalizeAndRegisterComponentName($name); @@ -36,6 +36,7 @@ static function create($name, $params = [], $fromQueryString = []) $name, $params, $fromQueryString, $cookies, ); return new static($requestBroker, $initialState); 24 changes: 24 additions & 0 deletions24 src/Features/SupportTesting/UnitTest.php @@ -501,6 +501,30 @@ function assert_response_of_calling_method() ->assertReturned('bar') ->assertReturned(fn ($data) => $data === 'bar'); } /** @test */ public function can_set_cookies_for_use_with_testing() { // Test both the `withCookies` and `withCookie` methods that Laravel normally provides Livewire::withCookies(['colour' => 'blue']) ->withCookie('name', 'Taylor') ->test(new class extends Component { public $colourCookie = ''; public $nameCookie = ''; public function mount() { $this->colourCookie = request()->cookie('colour'); $this->nameCookie = request()->cookie('name'); } public function render() { return '<div></div>'; } }) ->assertSet('colourCookie', 'blue') ->assertSet('nameCookie', 'Taylor') ; } } class HasMountArguments extends Component 40 changes: 40 additions & 0 deletions40 src/Features/SupportTransitions/BrowserTest.php @@ -48,4 +48,44 @@ public function render() { return <<<'HTML' ->assertMissing('@target') ; } /** @test */ public function elements_the_contain_transition_are_displayed_on_page_load() { Livewire::visit( new class extends \Livewire\Component { public $messages = [ 'message 1', 'message 2', 'message 3', 'message 4', ]; public function addMessage() { $this->messages[] = 'message ' . count($this->messages) + 1; } public function render() { return <<< 'HTML' <div> <ul class="text-xs"> @foreach($messages as $index => $message) <li wire:transition.fade.duration.1000ms dusk="message-{{ $index + 1 }}">{{$message}}</li> @endforeach </ul> <button type="button" wire:click="addMessage" dusk="add-message">Add message</button> </div> HTML; } } ) ->assertVisible('@message-1') ->assertVisible('@message-2') ->assertVisible('@message-3') ->assertVisible('@message-4') ; } } 10 changes: 8 additions & 2 deletions10 src/Features/SupportValidation/BaseRule.php @@ -10,7 +10,6 @@ #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)] class BaseRule extends LivewireAttribute { // @todo: support custom messages... function __construct( public $rule, protected $attribute = null, @@ -68,7 +67,14 @@ function boot() $this->component->addMessagesFromOutside($messages); } else { $this->component->addMessagesFromOutside([$this->getName() => $this->translate ? trans($this->message) : $this->message]); // If a single message was provided, apply it to the first given rule. // There should have only been one rule provided in this case anyways... $rule = head(array_values($rules)); // In the case of "min:5" or something, we only want "min"... $rule = (string) str($rule)->before(':'); $this->component->addMessagesFromOutside([$this->getName().'.'.$rule => $this->translate ? trans($this->message) : $this->message]); } } 17 changes: 17 additions & 0 deletions17 src/Features/SupportValidation/UnitTest.php @@ -186,6 +186,23 @@ function save() { $this->validate(); } ; } /** @test */ public function rule_attribute_supports_custom_messages_when_using_repeated_attributes() { Livewire::test(new class extends TestComponent { #[BaseRule('required', message: 'Please provide a post title')] #[BaseRule('min:3', message: 'This title is too short')] public $title = ''; }) ->set('title', '') ->assertHasErrors(['title' => 'required']) ->tap(function ($component) { $messages = $component->errors()->getMessages(); $this->assertEquals('Please provide a post title', $messages['title'][0]); }) ; } /** @test */ public function rule_attribute_message_is_translatable() { 1 change: 0 additions & 1 deletion1 src/Livewire.php @@ -15,7 +15,6 @@ * @method static array update($snapshot, $diff, $calls) * @method static bool isLivewireRequest() * @method static void setUpdateRoute($callback) * @method static void setUpdateUri() * @method static string getUpdateUri($callback) * @method static void setScriptRoute($callback) * @method static void useScriptTagAttributes($attributes) 18 changes: 17 additions & 1 deletion18 src/LivewireManager.php @@ -140,6 +140,8 @@ function useScriptTagAttributes($attributes) protected $queryParamsForTesting = []; protected $cookiesForTesting = []; function withUrlParams($params) { return $this->withQueryParams($params); @@ -152,9 +154,23 @@ function withQueryParams($params) return $this; } function withCookie($name, $value) { $this->cookiesForTesting[$name] = $value; return $this; } function withCookies($cookies) { $this->cookiesForTesting = array_merge($this->cookiesForTesting, $cookies); return $this; } function test($name, $params = []) { return Testable::create($name, $params, $this->queryParamsForTesting); return Testable::create($name, $params, $this->queryParamsForTesting, $this->cookiesForTesting); } function visit($name) 8 changes: 7 additions & 1 deletion8 src/Mechanisms/FrontendAssets/FrontendAssets.php @@ -82,14 +82,16 @@ public static function styles($options = []) $nonce = isset($options['nonce']) ? "nonce=\"{$options['nonce']}\"" : ''; $progressBarColor = config('livewire.navigate.progress_bar_color', '#2299dd'); $html = <<<HTML <!-- Livewire Styles --> <style {$nonce}> [wire\:loading], [wire\:loading\.delay], [wire\:loading\.inline-block], [wire\:loading\.inline], [wire\:loading\.block], [wire\:loading\.flex], [wire\:loading\.table], [wire\:loading\.grid], [wire\:loading\.inline-flex] { display: none; } [wire\:loading\.delay\.shortest], [wire\:loading\.delay\.shorter], [wire\:loading\.delay\.short], [wire\:loading\.delay\.long], [wire\:loading\.delay\.longer], [wire\:loading\.delay\.longest] { [wire\:loading\.delay\.none], [wire\:loading\.delay\.shortest], [wire\:loading\.delay\.shorter], [wire\:loading\.delay\.short], [wire\:loading\.delay\.default], [wire\:loading\.delay\.long], [wire\:loading\.delay\.longer], [wire\:loading\.delay\.longest] { display:none; } @@ -101,6 +103,10 @@ public static function styles($options = []) display: none; } :root { --livewire-progress-bar-color: {$progressBarColor}; } [x-cloak] { display: none; } 8 changes: 6 additions & 2 deletions8 src/Mechanisms/HandleRequests/HandleRequests.php @@ -26,7 +26,9 @@ function boot() function getUpdateUri() { return (string) str($this->updateRoute->uri)->start('/'); return (string) str( route($this->updateRoute->getName(), [], false) )->start('/'); } function skipRequestPayloadTamperingMiddleware() @@ -45,7 +47,9 @@ function setUpdateRoute($callback) $route = $callback([self::class, 'handleUpdate']); // Append `livewire.update` to the existing name, if any. $route->name('livewire.update'); if (! str($route->getName())->endsWith('livewire.update')) { $route->name('livewire.update'); } $this->updateRoute = $route; } 69 changes: 69 additions & 0 deletions69 src/Mechanisms/Tests/CustomUpdateRouteBrowserTest.php @@ -0,0 +1,69 @@ <?php namespace Livewire\Mechanisms\Tests; use Livewire\Livewire; use Livewire\Component; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\URL; class CustomUpdateRouteBrowserTest extends \Tests\BrowserTestCase { public static function tweakApplicationHook() { return function () { // This would normally be done in something like middleware URL::defaults(['tenant' => 'custom-tenant']); Livewire::setUpdateRoute(function ($handle) { return Route::post('/{tenant}/livewire/update', $handle)->name('tenant.livewire.update'); }); // Doesn't seem to be needed in real applications, but is needed in tests app('router')->getRoutes()->refreshNameLookups(); Route::prefix('/{tenant}')->group(function () { Route::get('/page', function ($tenant) { return (app('livewire')->new('test'))(); })->name('tenant.page'); }); Livewire::component('test', new class extends Component { public $count = 0; function increment() { $this->count++; } public function render() { return <<<'HTML' <div> <h1 dusk="count">Count: {{ $count }}</h1> <button wire:click="increment" dusk="button">Increment</button> <p>Tenant: {{ request()->route()->parameter('tenant') }}</p> <p>Route: {{ request()->route()->getName() }}</p> </div> HTML; } }); }; } /** @test */ public function can_use_a_custom_update_route_with_a_uri_segment() { $this->browse(function (\Laravel\Dusk\Browser $browser) { $browser ->visit('/custom-tenant/page') ->assertSee('Count: 0') ->assertSee('Tenant: custom-tenant') ->assertSee('Route: tenant.page') ->waitForLivewire() ->click('button') ->assertSee('Count: 1') ->assertSee('Tenant: custom-tenant') ->assertSee('Route: tenant.livewire.update'); }); } } Footer © 2023 GitHub, Inc. Footer navigation Terms Privacy Security Status Docs Contact GitHub Pricing API Training Blog About
Editor is loading...