diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f5c4d3f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [8.3, 8.4] + + name: PHP ${{ matrix.php }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, bcmath, soap, intl, gd, exif, iconv + coverage: none + + - name: Install dependencies + run: | + composer config allow-plugins.pestphp/pest-plugin true + composer install --prefer-dist --no-interaction + + - name: Install Playwright + run: | + npm install playwright@latest + npx playwright install --with-deps + + - name: Execute tests + run: vendor/bin/pest diff --git a/.gitignore b/.gitignore index ffb9d24..d4829bf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ vendor/ composer.lock .idea .DS_Store +tests/Browser/Screenshots +node_modules diff --git a/composer.json b/composer.json index 8a27f37..52ed6f9 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "library", "license": "MIT", "homepage": "https://github.com/power-components/partials", - "minimum-stability": "dev", + "minimum-stability": "stable", "authors": [ { "name": "Luan Freitas", @@ -23,11 +23,29 @@ "src/functions.php" ] }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, "extra": { "laravel": { "providers": [ "PowerComponents\\Partials\\PartialsServiceProvider" ] } + }, + "require-dev": { + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "orchestra/testbench": "^10.0", + "pestphp/pest-plugin-browser": "^4.0", + "laravel/pint": "^1.29", + "mockery/mockery": "^1.6.12" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f56f41b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "partials", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "partials", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "playwright": "^1.60.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9be7697 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "partials", + "version": "1.0.0", + "description": "Livewire Partials provide a structured and explicit way to update **specific DOM fragments** of a Livewire component instead of re-rendering the entire component tree. This is especially useful for complex components such as data tables, where partial updates significantly improve performance and user experience.", + "homepage": "https://github.com/Power-Components/partials#readme", + "bugs": { + "url": "https://github.com/Power-Components/partials/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Power-Components/partials.git" + }, + "license": "ISC", + "author": "", + "type": "commonjs", + "main": "index.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "playwright": "^1.60.0" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..0b4810c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,32 @@ + + + + + tests/Feature + + + tests/Browser + + + + + src + + + + + + + + + + + + + + + diff --git a/resources/js/partials.js b/resources/js/partials.js index 9d70c16..0e79c96 100644 --- a/resources/js/partials.js +++ b/resources/js/partials.js @@ -26,10 +26,6 @@ document.addEventListener('livewire:init', () => { continue } - if (els.length > 1) { - throw `Multiple elements found for partial [${name}].` - } - let el = els[0] const getIgnoreKey = (node) => { diff --git a/src/PartialsHook.php b/src/PartialsHook.php index cc2f4d5..d61e666 100644 --- a/src/PartialsHook.php +++ b/src/PartialsHook.php @@ -4,9 +4,11 @@ use Closure; use Illuminate\View\View; -use Livewire\{Component, ComponentHook}; +use Livewire\Component; +use Livewire\ComponentHook; use Livewire\Drawer\Utils; -use Livewire\Mechanisms\HandleComponents\{ComponentContext, ViewContext}; +use Livewire\Mechanisms\HandleComponents\ComponentContext; +use Livewire\Mechanisms\HandleComponents\ViewContext; use PowerComponents\Partials\Attribute\PartialRender; use ReflectionMethod; diff --git a/src/PartialsServiceProvider.php b/src/PartialsServiceProvider.php index 1558827..f866c16 100644 --- a/src/PartialsServiceProvider.php +++ b/src/PartialsServiceProvider.php @@ -2,6 +2,7 @@ namespace PowerComponents\Partials; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Livewire\Mechanisms\DataStore; @@ -10,6 +11,22 @@ class PartialsServiceProvider extends ServiceProvider public function boot(): void { $this->publishConfigs(); + $this->registerRoutes(); + } + + private function registerRoutes(): void + { + Route::get('/powergrid-partials/partials.js', function () { + return response(file_get_contents(__DIR__.'/../resources/js/partials.js'), 200, [ + 'Content-Type' => 'application/javascript', + ]); + }); + + Route::get('/powergrid-partials/utils.js', function () { + return response(file_get_contents(__DIR__.'/../resources/js/utils.js'), 200, [ + 'Content-Type' => 'application/javascript', + ]); + }); } public function register(): void diff --git a/tests/Browser/PartialsBrowserTest.php b/tests/Browser/PartialsBrowserTest.php new file mode 100644 index 0000000..a3e51cb --- /dev/null +++ b/tests/Browser/PartialsBrowserTest.php @@ -0,0 +1,309 @@ +mainId = uniqid(); + } + + #[PartialRender('partial-count', 'count-partial')] + public function increment(): void + { + $this->count++; + } + + public function render() + { + return <<<'BLADE' +
+
Main: {{ $mainId }}
+
Main Uniqid: {{ uniqid() }}
+ +
+ @include('partial-count', ['__partial' => $this]) +
+ + +
+BLADE; + } +} + +class PartialsIgnoreBrowserTest extends Component +{ + public int $count = 0; + + #[PartialRender('partial-ignore', 'ignore-partial')] + public function increment() + { + $this->count++; + } + + public function render() + { + return <<<'BLADE' +
+
+ @include('partial-ignore', ['__partial' => $this]) +
+ +
+BLADE; + } +} + +class PartialsMultipleBrowserTest extends Component +{ + public int $countA = 0; + + public int $countB = 0; + + public string $mainUniqid; + + public function mount(): void + { + $this->mainUniqid = uniqid(); + } + + #[PartialRender('partial-count-a', 'partial-a')] + public function incrementA(): void + { + $this->countA++; + } + + #[PartialRender('partial-count-b', 'partial-b')] + public function incrementB(): void + { + $this->countB++; + } + + public function render() + { + return <<<'BLADE' +
+
{{ $mainUniqid }}
+ +
+ @include('partial-count-a', ['__partial' => $this]) +
+ +
+ @include('partial-count-b', ['__partial' => $this]) +
+ + + +
+BLADE; + } +} + +beforeEach(function () { + Livewire::component('browser-partial-component', PartialsBrowserTest::class); + Livewire::component('browser-ignore-component', PartialsIgnoreBrowserTest::class); + Livewire::component('browser-multiple-component', PartialsMultipleBrowserTest::class); + + Route::get('/browser-test', fn () => Blade::render(' + + @livewireStyles + + + @livewireScripts + + + + '))->middleware('web'); + + Route::get('/browser-ignore-test', fn () => Blade::render(' + + @livewireStyles + + + @livewireScripts + + + + '))->middleware('web'); + + Route::get('/browser-multiple-test', fn () => Blade::render(' + + @livewireStyles + + + @livewireScripts + + + + '))->middleware('web'); +}); + +it('updates only the partial fragment and preserves main content', function () { + $page = $this->visit('/browser-test') + ->assertSeeIn('#count', '0'); + + $initialMainUniqid = $page->text('#main-uniqid'); + $initialPartialUniqid = $page->text('#partial-uniqid'); + + $page->click('#increment') + ->waitForEvent('networkidle') + ->assertSeeIn('#count', '1'); + + expect($page->text('#main-uniqid')) + ->toBe($initialMainUniqid) + ->and($page->text('#partial-uniqid'))->not->toBe($initialPartialUniqid); +}); + +it('accumulates state correctly across multiple increments', function () { + $page = $this->visit('/browser-test') + ->assertSeeIn('#count', '0'); + + foreach (range(1, 3) as $expected) { + $page->click('#increment') + ->waitForEvent('networkidle') + ->assertSeeIn('#count', (string) $expected); + } +}); + +it('payload contains partialFragments effect and no html effect on partial render', function () { + $page = $this->visit('/browser-test'); + + $page->script(<<<'JS' + () => { + window.__livewireEffects = null; + Livewire.interceptMessage(({ message, onSuccess }) => { + onSuccess(({ payload }) => { + window.__livewireEffects = payload.effects; + }); + }); + } + JS); + + $page->click('#increment') + ->waitForEvent('networkidle'); + + $page->assertScript('() => window.__livewireEffects !== null'); + + $hasPartialFragments = $page->script(<<<'JS' + () => { + const effects = window.__livewireEffects; + if (!effects) return false; + return !!(effects.partialFragments && Object.keys(effects.partialFragments).length > 0); + } + JS); + + $hasHtmlEffect = $page->script(<<<'JS' + () => { + const effects = window.__livewireEffects; + if (!effects) return false; + return !!effects.html; + } + JS); + + expect($hasPartialFragments)->toBeTrue() + ->and($hasHtmlEffect)->toBeFalse(); +}); + +it('payload partialFragments contains the correct partial name key', function () { + $page = $this->visit('/browser-test'); + + $page->script(<<<'JS' + () => { + window.__livewireEffects = null; + Livewire.interceptMessage(({ message, onSuccess }) => { + onSuccess(({ payload }) => { + window.__livewireEffects = payload.effects; + }); + }); + } + JS); + + $page->click('#increment') + ->waitForEvent('networkidle'); + + $page->assertScript('() => window.__livewireEffects !== null'); + + $partialKeys = $page->script(<<<'JS' + () => { + const effects = window.__livewireEffects; + if (!effects?.partialFragments) return []; + return Object.keys(effects.partialFragments); + } + JS); + + expect($partialKeys)->toContain('count-partial'); +}); + +it('preserves wire:partial.ignore content after partial update', function () { + $page = $this->visit('/browser-ignore-test'); + + $ignoredBefore = $page->text('#ignored-content'); + + $page->click('#increment') + ->waitForEvent('networkidle'); + + expect($page->text('#ignored-content'))->toBe($ignoredBefore); +}); + +it('updates dynamic content inside partial while ignoring wire:partial.ignore block', function () { + $page = $this->visit('/browser-ignore-test') + ->assertSeeIn('#count', '0'); + + $ignoredBefore = $page->text('#ignored-content'); + + $page->click('#increment') + ->waitForEvent('networkidle') + ->assertSeeIn('#count', '1'); + + expect($page->text('#ignored-content'))->toBe($ignoredBefore); +}); + +it('updates only the targeted partial when multiple partials exist', function () { + $page = $this->visit('/browser-multiple-test') + ->assertSeeIn('#count-a', '0') + ->assertSeeIn('#count-b', '0'); + + $uniqidABefore = $page->text('#partial-uniqid-a'); + $uniqidBBefore = $page->text('#partial-uniqid-b'); + $mainUniqid = $page->text('#main-uniqid'); + + $page->click('#increment-a') + ->waitForEvent('networkidle') + ->assertSeeIn('#count-a', '1') + ->assertSeeIn('#count-b', '0'); + + expect($page->text('#partial-uniqid-a'))->not->toBe($uniqidABefore) + ->and($page->text('#partial-uniqid-b'))->toBe($uniqidBBefore) + ->and($page->text('#main-uniqid'))->toBe($mainUniqid); +}); + +it('updates each partial independently', function () { + $page = $this->visit('/browser-multiple-test'); + + $page->click('#increment-a') + ->waitForEvent('networkidle') + ->assertSeeIn('#count-a', '1') + ->assertSeeIn('#count-b', '0'); + + $page->click('#increment-b') + ->waitForEvent('networkidle') + ->assertSeeIn('#count-a', '1') + ->assertSeeIn('#count-b', '1'); + + $page->click('#increment-b') + ->waitForEvent('networkidle') + ->assertSeeIn('#count-a', '1') + ->assertSeeIn('#count-b', '2'); +}); diff --git a/tests/Feature/DataStoreOverrideTest.php b/tests/Feature/DataStoreOverrideTest.php new file mode 100644 index 0000000..215362d --- /dev/null +++ b/tests/Feature/DataStoreOverrideTest.php @@ -0,0 +1,39 @@ +Dummy'; + } +} + +it('tests DataStoreOverride when not a livewire request', function () { + $store = new DataStoreOverride; + $instance = new DummyComponentForDataStore; + + $store->set($instance, 'skipRender', 'some-value'); + expect($store->get($instance, 'skipRender'))->toBe('some-value'); +}); + +it('tests DataStoreOverride during livewire request', function () { + $store = new DataStoreOverride; + $instance = new DummyComponentForDataStore; + + request()->headers->set('X-Livewire', 'true'); + + $store->set($instance, 'skipRender', 'some-value'); + + app()->bind(PartialsHook::class, function () { + throw new Exception('Test exception'); + }); + + expect($store->get($instance, 'skipRender'))->toBeNull(); + + request()->headers->remove('X-Livewire'); + app()->forgetInstance(PartialsHook::class); +}); diff --git a/tests/Feature/FunctionsTest.php b/tests/Feature/FunctionsTest.php new file mode 100644 index 0000000..5957555 --- /dev/null +++ b/tests/Feature/FunctionsTest.php @@ -0,0 +1,21 @@ +Dummy'; + } +} + +it('test functions.php partials helper', function () { + $hook = partials(); + expect($hook)->toBeInstanceOf(PartialsHook::class); + + $component = new DummyComponentForFunctions; + $hook2 = partials($component); + expect($hook2)->toBeInstanceOf(PartialsHook::class); +}); diff --git a/tests/Feature/PartialsHookTest.php b/tests/Feature/PartialsHookTest.php new file mode 100644 index 0000000..64dba47 --- /dev/null +++ b/tests/Feature/PartialsHookTest.php @@ -0,0 +1,68 @@ +Dummy'; + } + + #[PartialRender('dummy-partial', 'dummy-view')] + protected function protectedIncrement() + { + // This is to hit the !isPublic() return early + } +} + +it('tests PartialsHook edge cases', function () { + $hook = app(PartialsHook::class); + $component = new DummyComponentForPartialsHook; + + $hook->setComponent($component); + + store($component)->set('forceRender', true); + expect($hook->shouldForceRender())->toBeTrue(); + + expect($hook->shouldSkipRender())->toBeFalse(); + + store($component)->set('forceRender', false); + expect($hook->shouldSkipRender())->toBeFalse(); + + store($component)->set('partialFragments', [function () { + return []; + }]); + + expect($hook->shouldSkipRender())->toBeTrue(); + + store($component)->set('isPendingPartialRender', false); + + $hook->renderPartial($component, function () { + return []; + }); + + expect(store($component)->get('partialRendersCount'))->toBeNull(); + + $hook->partial('my-partial', '
HTML String
'); + $fragments = store($component)->get('partialFragments'); + expect($fragments)->toBeArray(); +}); + +it('tests PartialsHook call method with protected method', function () { + $hook = app(PartialsHook::class); + $component = new DummyComponentForPartialsHook; + $hook->setComponent($component); + + View::addLocation(__DIR__.'/../views'); + + $returnEarly = false; + $closure = $hook->call('protectedIncrement', [], $returnEarly, null, null); + + expect($returnEarly)->toBeTrue(); +}); diff --git a/tests/Feature/PartialsTest.php b/tests/Feature/PartialsTest.php new file mode 100644 index 0000000..91bb282 --- /dev/null +++ b/tests/Feature/PartialsTest.php @@ -0,0 +1,37 @@ +count++; + } + + public function render() + { + return <<<'BLADE' +
+
+ {{ $count }} +
+ +
+BLADE; + } +} + +it('can increment count via partial render', function () { + Livewire::test(PartialsTest::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..29d0948 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,14 @@ +extend(TestCase::class) + ->in('Feature'); + +pest()->extend(TestCase::class, Browsable::class) + ->in('Browser'); + +pest()->browser() + ->inChrome() + ->withHost('localhost'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..c52acc4 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,34 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $app['config']->set('app.key', 'base64:Hupx3yAySlyS9lS1k4pXFwZ7eWq2G4c/3gB5G4y7+o4='); + + $app['config']->set('powergrid-partials.enabled', true); + + $app['view']->addLocation(__DIR__.'/views'); + } +} diff --git a/tests/views/partial-count-a.blade.php b/tests/views/partial-count-a.blade.php new file mode 100644 index 0000000..85b4825 --- /dev/null +++ b/tests/views/partial-count-a.blade.php @@ -0,0 +1,4 @@ +
+ {{ $__partial->countA }} + {{ uniqid() }} +
diff --git a/tests/views/partial-count-b.blade.php b/tests/views/partial-count-b.blade.php new file mode 100644 index 0000000..b2612ca --- /dev/null +++ b/tests/views/partial-count-b.blade.php @@ -0,0 +1,4 @@ +
+ {{ $__partial->countB }} + {{ uniqid() }} +
diff --git a/tests/views/partial-count.blade.php b/tests/views/partial-count.blade.php new file mode 100644 index 0000000..5405035 --- /dev/null +++ b/tests/views/partial-count.blade.php @@ -0,0 +1,4 @@ +
+ {{ $__partial->count }} + {{ uniqid() }} +
diff --git a/tests/views/partial-ignore.blade.php b/tests/views/partial-ignore.blade.php new file mode 100644 index 0000000..068b746 --- /dev/null +++ b/tests/views/partial-ignore.blade.php @@ -0,0 +1,6 @@ +
+ {{ $__partial->count }} +
+ {{ uniqid() }} +
+