diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c68765b..47ef499 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: :vendor_name +github: David Vincent diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 96701be..f605708 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Ask a question - url: https://github.com/:vendor_name/:package_name/discussions/new?category=q-a + url: https://github.com/David Vincent/filament-nested-list/discussions/new?category=q-a about: Ask the community for help - name: Request a feature - url: https://github.com/:vendor_name/:package_name/discussions/new?category=ideas + url: https://github.com/David Vincent/filament-nested-list/discussions/new?category=ideas about: Share ideas for new features - name: Report a security issue - url: https://github.com/:vendor_name/:package_name/security/policy + url: https://github.com/David Vincent/filament-nested-list/security/policy about: Learn how to notify us for sensitive bugs diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 39b1580..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,19 +0,0 @@ -# Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - labels: - - "dependencies" - - - package-ecosystem: "composer" - directory: "/" - schedule: - interval: "weekly" - labels: - - "dependencies" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index 70d8e7b..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: dependabot-auto-merge -on: pull_request_target - -permissions: - pull-requests: write - contents: write - -jobs: - dependabot: - runs-on: ubuntu-latest - timeout-minutes: 5 - if: ${{ github.actor == 'dependabot[bot]' }} - steps: - - - name: Dependabot metadata - id: metadata - uses: dependabot/fetch-metadata@v1.6.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - - name: Auto-merge Dependabot PRs for semver-minor updates - if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} - run: gh pr merge --auto --merge "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - - name: Auto-merge Dependabot PRs for semver-patch updates - if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} - run: gh pr merge --auto --merge "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml deleted file mode 100644 index 56d54d3..0000000 --- a/.github/workflows/fix-php-code-style-issues.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Fix PHP code style issues - -on: - push: - paths: - - '**.php' - -permissions: - contents: write - -jobs: - php-code-styling: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - - name: Fix PHP code style issues - uses: aglipanci/laravel-pint-action@2.4 - - - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml deleted file mode 100644 index f495e76..0000000 --- a/.github/workflows/phpstan.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: PHPStan - -on: - push: - paths: - - '**.php' - - 'phpstan.neon.dist' - - '.github/workflows/phpstan.yml' - -jobs: - phpstan: - name: phpstan - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - coverage: none - - - name: Install composer dependencies - uses: ramsey/composer-install@v3 - - - name: Run PHPStan - run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml deleted file mode 100644 index 39de30d..0000000 --- a/.github/workflows/update-changelog.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: "Update Changelog" - -on: - release: - types: [released] - -permissions: - contents: write - -jobs: - update: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: main - - - name: Update Changelog - uses: stefanzweifel/changelog-updater-action@v1 - with: - latest-version: ${{ github.event.release.name }} - release-notes: ${{ github.event.release.body }} - - - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v5 - with: - branch: main - commit_message: Update CHANGELOG - file_pattern: CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b3242..322833f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ # Changelog -All notable changes to `:package_name` will be documented in this file. +All notable changes to `filament-nested-list` will be documented in this file. diff --git a/LICENSE.md b/LICENSE.md index 58c9ad4..c3e7600 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) :vendor_name +Copyright (c) David Vincent Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 375da96..4f7cf83 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,15 @@ -# :package_description - -[![Latest Version on Packagist](https://img.shields.io/packagist/v/:vendor_slug/:package_slug.svg?style=flat-square)](https://packagist.org/packages/:vendor_slug/:package_slug) -[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/:vendor_slug/:package_slug/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/:vendor_slug/:package_slug/actions?query=workflow%3Arun-tests+branch%3Amain) -[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/:vendor_slug/:package_slug/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/:vendor_slug/:package_slug/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) -[![Total Downloads](https://img.shields.io/packagist/dt/:vendor_slug/:package_slug.svg?style=flat-square)](https://packagist.org/packages/:vendor_slug/:package_slug) - ---- -This repo can be used to scaffold a Laravel package. Follow these steps to get started: - -1. Press the "Use this template" button at the top of this repo to create a new repo with the contents of this skeleton. -2. Run "php ./configure.php" to run a script that will replace all placeholders throughout all the files. -3. Have fun creating your package. -4. If you need help creating a package, consider picking up our Laravel Package Training video course. ---- - +# This is my package filament-nested-list + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/invaders-xx/filament-nested-list.svg?style=flat-square)](https://packagist.org/packages/invaders-xx/filament-nested-list) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/invaders-xx/filament-nested-list/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/invaders-xx/filament-nested-list/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/invaders-xx/filament-nested-list/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/invaders-xx/filament-nested-list/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/invaders-xx/filament-nested-list.svg?style=flat-square)](https://packagist.org/packages/invaders-xx/filament-nested-list) + This is where your description should go. Limit it to a paragraph or two. Consider adding a small example. ## Support us -[](https://spatie.be/github-ad-click/:package_name) +[](https://spatie.be/github-ad-click/filament-nested-list) We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). @@ -29,20 +20,20 @@ We highly appreciate you sending us a postcard from your hometown, mentioning wh You can install the package via composer: ```bash -composer require :vendor_slug/:package_slug +composer require invaders-xx/filament-nested-list ``` You can publish and run the migrations with: ```bash -php artisan vendor:publish --tag=":package_slug-migrations" +php artisan vendor:publish --tag="filament-nested-list-migrations" php artisan migrate ``` You can publish the config file with: ```bash -php artisan vendor:publish --tag=":package_slug-config" +php artisan vendor:publish --tag="filament-nested-list-config" ``` This is the contents of the published config file: @@ -55,14 +46,14 @@ return [ Optionally, you can publish the views using ```bash -php artisan vendor:publish --tag=":package_slug-views" +php artisan vendor:publish --tag="filament-nested-list-views" ``` ## Usage ```php -$variable = new VendorName\Skeleton(); -echo $variable->echoPhrase('Hello, VendorName!'); +$filamentNestedList = new David Vincent\FilamentNestedList(); +echo $filamentNestedList->echoPhrase('Hello, David Vincent!'); ``` ## Testing @@ -85,7 +76,7 @@ Please review [our security policy](../../security/policy) on how to report secu ## Credits -- [:author_name](https://github.com/:author_username) +- [David Vincent](https://github.com/invaders-xx) - [All Contributors](../../contributors) ## License diff --git a/bin/build.js b/bin/build.js new file mode 100644 index 0000000..f10ef8e --- /dev/null +++ b/bin/build.js @@ -0,0 +1,50 @@ +import esbuild from 'esbuild' + +const isDev = process.argv.includes('--dev') + +async function compile(options) { + const context = await esbuild.context(options) + + if (isDev) { + await context.watch() + } else { + await context.rebuild() + await context.dispose() + } +} + +const defaultOptions = { + define: { + 'process.env.NODE_ENV': isDev ? `'development'` : `'production'`, + }, + bundle: true, + mainFields: ['module', 'main'], + platform: 'neutral', + sourcemap: isDev ? 'inline' : false, + sourcesContent: isDev, + treeShaking: true, + target: ['es2020'], + minify: !isDev, + plugins: [{ + name: 'watchPlugin', + setup: function (build) { + build.onStart(() => { + console.log(`Build started at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) + }) + + build.onEnd((result) => { + if (result.errors.length > 0) { + console.log(`Build failed at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`, result.errors) + } else { + console.log(`Build finished at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) + } + }) + } + }], +} + +compile({ + ...defaultOptions, + entryPoints: ['./node_modules/nested-sort/dist/nested-sort.umd.min.js'], + outfile: './resources/dist/filament-nested-list.js', +}) \ No newline at end of file diff --git a/composer.json b/composer.json index 65e9908..f70f176 100644 --- a/composer.json +++ b/composer.json @@ -1,85 +1,82 @@ { - "name": ":vendor_slug/:package_slug", - "description": ":package_description", - "keywords": [ - ":vendor_name", - "laravel", - ":package_slug" + "name": "invaders-xx/filament-nested-list", + "description": "Nested lists layout plugin for Filament", + "keywords": [ + "Envahisseur", + "laravel", + "filament-nested-list" + ], + "homepage": "https://github.com/invaders-xx/filament-nested-list", + "license": "MIT", + "authors": [ + { + "name": "David Vincent", + "email": "envahisseur@gmail.com", + "role": "Owner" + } + ], + "require": { + "php": "^8.2", + "filament/filament": "^3.0", + "filament/support": "^3.0", + "spatie/laravel-package-tools": "^1.16", + "illuminate/contracts": "^10.0||^11.0" + }, + "require-dev": { + "laravel/pint": "^1.15", + "nunomaduro/collision": "^8.1.1||^7.10.0", + "orchestra/testbench": "^9.0.0||^8.22.0", + "pestphp/pest": "^2.34", + "pestphp/pest-plugin-arch": "^2.7", + "pestphp/pest-plugin-laravel": "^2.3" + }, + "autoload": { + "psr-4": { + "InvadersXX\\FilamentNestedList\\": "src/", + "InvadersXX\\FilamentNestedList\\Database\\Factories\\": "database/factories/" + } + }, + "autoload-dev": { + "psr-4": { + "InvadersXX\\FilamentNestedList\\Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": "@composer run prepare", + "clear": "@php vendor/bin/testbench package:purge-filament-nested-list --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": [ + "@composer run prepare", + "@php vendor/bin/testbench workbench:build --ansi" ], - "homepage": "https://github.com/:vendor_slug/:package_slug", - "license": "MIT", - "authors": [ - { - "name": ":author_name", - "email": "author@domain.com", - "role": "Developer" - } + "start": [ + "Composer\\Config::disableProcessTimeout", + "@composer run build", + "@php vendor/bin/testbench serve" ], - "require": { - "php": "^8.2", - "spatie/laravel-package-tools": "^1.16", - "illuminate/contracts": "^10.0||^11.0" - }, - "require-dev": { - "laravel/pint": "^1.14", - "nunomaduro/collision": "^8.1.1||^7.10.0", - "larastan/larastan": "^2.9", - "orchestra/testbench": "^9.0.0||^8.22.0", - "pestphp/pest": "^2.34", - "pestphp/pest-plugin-arch": "^2.7", - "pestphp/pest-plugin-laravel": "^2.3", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan-deprecation-rules": "^1.1", - "phpstan/phpstan-phpunit": "^1.3", - "spatie/laravel-ray": "^1.35" - }, - "autoload": { - "psr-4": { - "VendorName\\Skeleton\\": "src/", - "VendorName\\Skeleton\\Database\\Factories\\": "database/factories/" - } - }, - "autoload-dev": { - "psr-4": { - "VendorName\\Skeleton\\Tests\\": "tests/", - "Workbench\\App\\": "workbench/app/" - } - }, - "scripts": { - "post-autoload-dump": "@composer run prepare", - "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", - "prepare": "@php vendor/bin/testbench package:discover --ansi", - "build": [ - "@composer run prepare", - "@php vendor/bin/testbench workbench:build --ansi" - ], - "start": [ - "Composer\\Config::disableProcessTimeout", - "@composer run build", - "@php vendor/bin/testbench serve" - ], - "analyse": "vendor/bin/phpstan analyse", - "test": "vendor/bin/pest", - "test-coverage": "vendor/bin/pest --coverage", - "format": "vendor/bin/pint" - }, - "config": { - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "phpstan/extension-installer": true - } - }, - "extra": { - "laravel": { - "providers": [ - "VendorName\\Skeleton\\SkeletonServiceProvider" - ], - "aliases": { - "Skeleton": "VendorName\\Skeleton\\Facades\\Skeleton" - } - } - }, - "minimum-stability": "dev", - "prefer-stable": true + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "InvadersXX\\FilamentNestedList\\FilamentNestedListServiceProvider" + ], + "aliases": { + "FilamentNestedList": "InvadersXX\\FilamentNestedList\\Facades\\FilamentNestedList" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/config/filament-nested-list.php b/config/filament-nested-list.php new file mode 100644 index 0000000..bb8d379 --- /dev/null +++ b/config/filament-nested-list.php @@ -0,0 +1,14 @@ + [ + 'order' => 'order', + 'parent' => 'parent_id', + 'title' => 'title', + ], + // Tree model default parent key + 'default_parent_id' => -1, + // Tree model default children key name + 'default_children_key_name' => 'children', +]; diff --git a/config/skeleton.php b/config/skeleton.php deleted file mode 100644 index 7e74186..0000000 --- a/config/skeleton.php +++ /dev/null @@ -1,6 +0,0 @@ - $version) { - if (in_array($name, $names, true)) { - unset($data['require-dev'][$name]); - } - } - - file_put_contents(__DIR__.'/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); -} - -function remove_composer_script($scriptName) -{ - $data = json_decode(file_get_contents(__DIR__.'/composer.json'), true); - - foreach ($data['scripts'] as $name => $script) { - if ($scriptName === $name) { - unset($data['scripts'][$name]); - break; - } - } - - file_put_contents(__DIR__.'/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); -} - -function remove_readme_paragraphs(string $file): void -{ - $contents = file_get_contents($file); - - file_put_contents( - $file, - preg_replace('/.*/s', '', $contents) ?: $contents - ); -} - -function safeUnlink(string $filename) -{ - if (file_exists($filename) && is_file($filename)) { - unlink($filename); - } -} - -function determineSeparator(string $path): string -{ - return str_replace('/', DIRECTORY_SEPARATOR, $path); -} - -function replaceForWindows(): array -{ - return preg_split('/\\r\\n|\\r|\\n/', run('dir /S /B * | findstr /v /i .git\ | findstr /v /i vendor | findstr /v /i '.basename(__FILE__).' | findstr /r /i /M /F:/ ":author :vendor :package VendorName skeleton migration_table_name vendor_name vendor_slug author@domain.com"')); -} - -function replaceForAllOtherOSes(): array -{ - return explode(PHP_EOL, run('grep -E -r -l -i ":author|:vendor|:package|VendorName|skeleton|migration_table_name|vendor_name|vendor_slug|author@domain.com" --exclude-dir=vendor ./* ./.github/* | grep -v '.basename(__FILE__))); -} - -function getGitHubApiEndpoint(string $endpoint): ?stdClass -{ - try { - $curl = curl_init("https://api.github.com/{$endpoint}"); - curl_setopt_array($curl, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_HTTPGET => true, - CURLOPT_HTTPHEADER => [ - 'User-Agent: spatie-configure-script/1.0', - ], - ]); - - $response = curl_exec($curl); - $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); - - curl_close($curl); - - if ($statusCode === 200) { - return json_decode($response); - } - } catch (Exception $e) { - // ignore - } - - return null; -} - -function searchCommitsForGitHubUsername(): string -{ - $authorName = strtolower(trim(shell_exec('git config user.name'))); - - $committersRaw = shell_exec("git log --author='@users.noreply.github.com' --pretty='%an:%ae' --reverse"); - $committersLines = explode("\n", $committersRaw ?? ''); - $committers = array_filter(array_map(function ($line) use ($authorName) { - $line = trim($line); - [$name, $email] = explode(':', $line) + [null, null]; - - return [ - 'name' => $name, - 'email' => $email, - 'isMatch' => strtolower($name) === $authorName && ! str_contains($name, '[bot]'), - ]; - }, $committersLines), fn ($item) => $item['isMatch']); - - if (empty($committers)) { - return ''; - } - - $firstCommitter = reset($committers); - - return explode('@', $firstCommitter['email'])[0] ?? ''; -} - -function guessGitHubUsernameUsingCli() -{ - try { - if (preg_match('/ogged in to github\.com as ([a-zA-Z-_]+).+/', shell_exec('gh auth status -h github.com 2>&1'), $matches)) { - return $matches[1]; - } - } catch (Exception $e) { - // ignore - } - - return ''; -} - -function guessGitHubUsername(): string -{ - $username = searchCommitsForGitHubUsername(); - if (! empty($username)) { - return $username; - } - - $username = guessGitHubUsernameUsingCli(); - if (! empty($username)) { - return $username; - } - - // fall back to using the username from the git remote - $remoteUrl = shell_exec('git config remote.origin.url'); - $remoteUrlParts = explode('/', str_replace(':', '/', trim($remoteUrl))); - - return $remoteUrlParts[1] ?? ''; -} - -function guessGitHubVendorInfo($authorName, $username): array -{ - $remoteUrl = shell_exec('git config remote.origin.url'); - $remoteUrlParts = explode('/', str_replace(':', '/', trim($remoteUrl))); - - $response = getGitHubApiEndpoint("orgs/{$remoteUrlParts[1]}"); - - if ($response === null) { - return [$authorName, $username]; - } - - return [$response->name ?? $authorName, $response->login ?? $username]; -} - -$gitName = run('git config user.name'); -$authorName = ask('Author name', $gitName); - -$gitEmail = run('git config user.email'); -$authorEmail = ask('Author email', $gitEmail); -$authorUsername = ask('Author username', guessGitHubUsername()); - -$guessGitHubVendorInfo = guessGitHubVendorInfo($authorName, $authorUsername); - -$vendorName = ask('Vendor name', $guessGitHubVendorInfo[0]); -$vendorUsername = ask('Vendor username', $guessGitHubVendorInfo[1] ?? slugify($vendorName)); -$vendorSlug = slugify($vendorUsername); - -$vendorNamespace = str_replace('-', '', ucwords($vendorName)); -$vendorNamespace = ask('Vendor namespace', $vendorNamespace); - -$currentDirectory = getcwd(); -$folderName = basename($currentDirectory); - -$packageName = ask('Package name', $folderName); -$packageSlug = slugify($packageName); -$packageSlugWithoutPrefix = remove_prefix('laravel-', $packageSlug); - -$className = title_case($packageName); -$className = ask('Class name', $className); -$variableName = lcfirst($className); -$description = ask('Package description', "This is my package {$packageSlug}"); - -$usePhpStan = confirm('Enable PhpStan?', true); -$useLaravelPint = confirm('Enable Laravel Pint?', true); -$useDependabot = confirm('Enable Dependabot?', true); -$useLaravelRay = confirm('Use Ray for debugging?', true); -$useUpdateChangelogWorkflow = confirm('Use automatic changelog updater workflow?', true); - -writeln('------'); -writeln("Author : {$authorName} ({$authorUsername}, {$authorEmail})"); -writeln("Vendor : {$vendorName} ({$vendorSlug})"); -writeln("Package : {$packageSlug} <{$description}>"); -writeln("Namespace : {$vendorNamespace}\\{$className}"); -writeln("Class name : {$className}"); -writeln('---'); -writeln('Packages & Utilities'); -writeln('Use Laravel/Pint : '.($useLaravelPint ? 'yes' : 'no')); -writeln('Use Larastan/PhpStan : '.($usePhpStan ? 'yes' : 'no')); -writeln('Use Dependabot : '.($useDependabot ? 'yes' : 'no')); -writeln('Use Ray App : '.($useLaravelRay ? 'yes' : 'no')); -writeln('Use Auto-Changelog : '.($useUpdateChangelogWorkflow ? 'yes' : 'no')); -writeln('------'); - -writeln('This script will replace the above values in all relevant files in the project directory.'); - -if (! confirm('Modify files?', true)) { - exit(1); -} - -$files = (str_starts_with(strtoupper(PHP_OS), 'WIN') ? replaceForWindows() : replaceForAllOtherOSes()); - -foreach ($files as $file) { - replace_in_file($file, [ - ':author_name' => $authorName, - ':author_username' => $authorUsername, - 'author@domain.com' => $authorEmail, - ':vendor_name' => $vendorName, - ':vendor_slug' => $vendorSlug, - 'VendorName' => $vendorNamespace, - ':package_name' => $packageName, - ':package_slug' => $packageSlug, - ':package_slug_without_prefix' => $packageSlugWithoutPrefix, - 'Skeleton' => $className, - 'skeleton' => $packageSlug, - 'migration_table_name' => title_snake($packageSlug), - 'variable' => $variableName, - ':package_description' => $description, - ]); - - match (true) { - str_contains($file, determineSeparator('src/Skeleton.php')) => rename($file, determineSeparator('./src/'.$className.'.php')), - str_contains($file, determineSeparator('src/SkeletonServiceProvider.php')) => rename($file, determineSeparator('./src/'.$className.'ServiceProvider.php')), - str_contains($file, determineSeparator('src/Facades/Skeleton.php')) => rename($file, determineSeparator('./src/Facades/'.$className.'.php')), - str_contains($file, determineSeparator('src/Commands/SkeletonCommand.php')) => rename($file, determineSeparator('./src/Commands/'.$className.'Command.php')), - str_contains($file, determineSeparator('database/migrations/create_skeleton_table.php.stub')) => rename($file, determineSeparator('./database/migrations/create_'.title_snake($packageSlugWithoutPrefix).'_table.php.stub')), - str_contains($file, determineSeparator('config/skeleton.php')) => rename($file, determineSeparator('./config/'.$packageSlugWithoutPrefix.'.php')), - str_contains($file, 'README.md') => remove_readme_paragraphs($file), - default => [], - }; -} - -if (! $useLaravelPint) { - safeUnlink(__DIR__.'/.github/workflows/fix-php-code-style-issues.yml'); - safeUnlink(__DIR__.'/pint.json'); -} - -if (! $usePhpStan) { - safeUnlink(__DIR__.'/phpstan.neon.dist'); - safeUnlink(__DIR__.'/phpstan-baseline.neon'); - safeUnlink(__DIR__.'/.github/workflows/phpstan.yml'); - - remove_composer_deps([ - 'phpstan/extension-installer', - 'phpstan/phpstan-deprecation-rules', - 'phpstan/phpstan-phpunit', - 'larastan/larastan', - ]); - - remove_composer_script('phpstan'); -} - -if (! $useDependabot) { - safeUnlink(__DIR__.'/.github/dependabot.yml'); - safeUnlink(__DIR__.'/.github/workflows/dependabot-auto-merge.yml'); -} - -if (! $useLaravelRay) { - remove_composer_deps(['spatie/laravel-ray']); -} - -if (! $useUpdateChangelogWorkflow) { - safeUnlink(__DIR__.'/.github/workflows/update-changelog.yml'); -} - -confirm('Execute `composer install` and run tests?') && run('composer install && composer test'); - -confirm('Let this script delete itself?', true) && unlink(__FILE__); diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php deleted file mode 100644 index c51604f..0000000 --- a/database/factories/ModelFactory.php +++ /dev/null @@ -1,19 +0,0 @@ -id(); - - // add fields - - $table->timestamps(); - }); - } -}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9ad3445 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,426 @@ +{ + "name": "filament-nested-list", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "nested-sort": "^5.2.0" + }, + "devDependencies": { + "esbuild": "^0.20.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/nested-sort": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/nested-sort/-/nested-sort-5.2.0.tgz", + "integrity": "sha512-mXTXphDItOlpE1in88OgMLMzGaOkpYVtN4jbt1l6ufv9SINCMmbK+mnMXRJdSdfrsMtltairQIo206cJrQrF3Q==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e25f793 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev:styles": "npx tailwindcss -i resources/css/plugin.css -o resources/dist/filament-tree.css --watch", + "dev:scripts": "node bin/build.js --dev", + "build:styles": "npx tailwindcss -i resources/css/plugin.css -o resources/dist/filament-tree.css --minify && npm run purge", + "build:scripts": "node bin/build.js", + "purge": "filament-purge -i resources/dist/filament-tree.css -o resources/dist/filament-tree.css -v 3.x", + "dev": "npm-run-all --parallel dev:*", + "build": "npm-run-all build:*" + }, + "devDependencies": { + "nested-sort": "^5.2.0", + "esbuild": "^0.20.2" + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 489fa4e..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,14 +0,0 @@ -includes: - - phpstan-baseline.neon - -parameters: - level: 5 - paths: - - src - - config - - database - tmpDir: build/phpstan - checkOctaneCompatibility: true - checkModelProperties: true - checkMissingIterableValueType: false - diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4c61042..5d281e3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,7 +16,7 @@ backupStaticProperties="false" > - + tests diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..70b0e18 --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "laravel" +} \ No newline at end of file diff --git a/resources/dist/filament-nested-list.css b/resources/dist/filament-nested-list.css new file mode 100644 index 0000000..2c30cdf --- /dev/null +++ b/resources/dist/filament-nested-list.css @@ -0,0 +1,25 @@ +.nested-sort { + @apply p-0; +} + +.nested-sort--enabled li { + @apply cursor-move; +} + +.nested-sort li { + @apply mt-0 mb-0 mr-5 p-2 bg-gray-200 dark:bg-gray-800/50; +} + +.nested-sort li ol { + @apply p-0 mt-5; +} + +/* ns-dragged is the class name of the item which is being dragged */ +.nested-sort .ns-dragged { + @apply border border-danger-500; +} + +/* ns-targeted is the class name of the item on which the dragged item is hovering */ +.nested-sort .ns-targeted { + @apply border border-success-500; +} \ No newline at end of file diff --git a/resources/dist/filament-nested-list.js b/resources/dist/filament-nested-list.js new file mode 100644 index 0000000..c85271f --- /dev/null +++ b/resources/dist/filament-nested-list.js @@ -0,0 +1,2 @@ +var S=(u,l)=>()=>(l||u((l={exports:{}}).exports,l),l.exports);var M=S((f,p)=>{(function(u,l){typeof f=="object"&&typeof p<"u"?p.exports=l():typeof define=="function"&&define.amd?define(l):(u=typeof globalThis<"u"?globalThis:u||self).NestedSort=l()})(f,function(){"use strict";function u(n,t){if(!(n instanceof t))throw new TypeError("Cannot call a class as a function")}function l(n,t){for(var e=0;en.length)&&(t=n.length);for(var e=0,i=new Array(t);e1&&arguments[1]!==void 0?arguments[1]:"li",i=t.id,a=t.text,s=document.createElement(e);return s.dataset.id=i,e==="li"&&a&&(s.innerHTML=a),e==="li"&&typeof this.renderListItem=="function"?this.renderListItem(s,t):s}},{key:"elementIsParentOfItem",value:function(t,e){return t.dataset.id==="".concat(e.parent)}},{key:"getParentNodeOfItem",value:function(t,e,i){return t.querySelector("".concat(i,'[data-id="').concat(e.parent,'"]'))}},{key:"elementIsAncestorOfItem",value:function(t,e){return!!this.getParentNodeOfItem(t,e,"li")}},{key:"getDirectListParentOfItem",value:function(t,e){return this.getParentNodeOfItem(t,e,"ol")}},{key:"maybeAppendItemToParentDom",value:function(t){var e=this,i=t.parent,a=this.sortedDataDomArray.find(function(d){return e.elementIsParentOfItem(d,t)||e.elementIsAncestorOfItem(d,t)});if(!a)return!1;var s=this.createItemElement(t),o=this.getDirectListParentOfItem(a,t);return o||(o=this.createItemElement({id:i},"ol"),(this.getParentNodeOfItem(a,t,"li")||a).appendChild(o)),o.appendChild(s),!0}},{key:"getListItemsDom",value:function(){var t=this;this.sortedDataDomArray=[];for(var e=[];e.length!==this.sortListItems().length;)e=this.sortedData.reduce(function(i,a){if(!a.id)return i;var s,o=a.id.toString();if(i.includes(o))return i;if(a.parent)s=t.maybeAppendItemToParentDom(a);else{var d=t.createItemElement(a);t.sortedDataDomArray.push(d),s=!0}return s&&i.push(o),i},e);return this.sortedDataDomArray}},{key:"convertDomToData",value:function(t){var e=this;return Array.from(t?.querySelectorAll("li")||[]).map(function(i){var a,s=i.parentNode,o=s.dataset.id,d=Array.from(s.children).findIndex(function(h){return h===i})+1;return r(a={},e.getItemPropProxyName("id"),i.dataset.id),r(a,e.getItemPropProxyName("parent"),o),r(a,e.getItemPropProxyName("order"),d),a})}},{key:"render",value:function(){var t=document.createElement("ol");return this.getListItemsDom().forEach(function(e){return t.appendChild(e)}),t}}]),n}(),E=function(){function n(t){var e=t.actions,i=e===void 0?{}:e,a=t.data,s=t.droppingEdge,o=s===void 0?15:s,d=t.el,h=t.init,g=h===void 0||h,k=t.listClassNames,P=t.listItemClassNames,A=t.nestingLevels,N=t.propertyMap,T=N===void 0?{}:N,C=t.renderListItem;u(this,n),r(this,"actions",void 0),r(this,"classNames",void 0),r(this,"cursor",void 0),r(this,"data",void 0),r(this,"dataEngine",void 0),r(this,"distances",void 0),r(this,"draggedNode",void 0),r(this,"initialised",void 0),r(this,"listClassNames",void 0),r(this,"listEventListeners",void 0),r(this,"listInterface",void 0),r(this,"listItemClassNames",void 0),r(this,"mainListClassName",void 0),r(this,"nestingLevels",void 0),r(this,"placeholderList",void 0),r(this,"placeholderInUse",void 0),r(this,"propertyMap",void 0),r(this,"renderListItem",void 0),r(this,"sortableList",void 0),r(this,"targetedNode",void 0),r(this,"targetNode",void 0),r(this,"wrapper",void 0),this.renderListItem=C;var m=typeof d=="string"?document.querySelector(d):d,I=m instanceof HTMLOListElement||m instanceof HTMLUListElement;this.wrapper=I?void 0:m,this.sortableList=I?m:null,this.data=a,this.listClassNames=this.createListClassNamesArray(k),this.mainListClassName=this.listClassNames[0]||"nested-sort",this.listItemClassNames=this.createListClassNamesArray(P),this.propertyMap=T,this.actions={onDrop:i.onDrop},this.initialised=!1,this.distances={droppingEdge:o},this.classNames={dragged:"ns-dragged",placeholder:"ns-placeholder",targeted:"ns-targeted"},this.listEventListeners={dragover:this.onDragOver.bind(this),dragstart:this.onDragStart.bind(this),dragenter:this.onDragEnter.bind(this),dragend:this.onDragEnd.bind(this),drop:this.onDrop.bind(this)};var b=parseInt(A);this.nestingLevels=isNaN(b)?-1:b,this.listInterface=this.getListInterface(),this.maybeInitDataDom(),this.addListAttributes(),g&&this.initDragAndDrop()}return y(n,[{key:"getListInterface",value:function(){return Array.isArray(this.data)&&this.data.length||this.sortableList instanceof HTMLOListElement?HTMLOListElement:HTMLUListElement}},{key:"getDataEngine",value:function(){return this.dataEngine||(this.dataEngine=new D({data:this.data,propertyMap:this.propertyMap,renderListItem:this.renderListItem})),this.dataEngine}},{key:"createListClassNamesArray",value:function(t){return t?Array.isArray(t)?t:t.split(" "):[]}},{key:"maybeInitDataDom",value:function(){if(Array.isArray(this.data)&&this.data.length&&this.wrapper){var t=this.getDataEngine().render();this.wrapper.innerHTML="",this.wrapper.appendChild(t),this.sortableList=t}}},{key:"getListTagName",value:function(){return this.listInterface===HTMLOListElement?"ol":"ul"}},{key:"getSortableList",value:function(){var t;return this.sortableList instanceof this.listInterface||(this.sortableList=(t=this.wrapper)===null||t===void 0?void 0:t.querySelector(this.getListTagName())),this.sortableList}},{key:"addListAttributes",value:function(){var t,e=this,i=this.getSortableList();i&&((t=i.classList).add.apply(t,c(this.listClassNames.concat(this.mainListClassName))),i.querySelectorAll(this.getListTagName()).forEach(function(a){var s;(s=a.classList).add.apply(s,c(e.listClassNames))}),i.querySelectorAll("li").forEach(function(a){var s;(s=a.classList).add.apply(s,c(e.listItemClassNames))}))}},{key:"toggleMainListLifeCycleClassName",value:function(){var t=!(arguments.length>0&&arguments[0]!==void 0)||arguments[0],e=this.getSortableList();if(e){var i="".concat(this.mainListClassName,"--enabled");t?e.classList.add(i):e.classList.remove(i)}}},{key:"toggleListItemAttributes",value:function(){var t,e=!(arguments.length>0&&arguments[0]!==void 0)||arguments[0];(t=this.getSortableList())===null||t===void 0||t.querySelectorAll("li").forEach(function(i){i.setAttribute("draggable",e.toString())})}},{key:"toggleListEventListeners",value:function(){var t=this,e=arguments.length>0&&arguments[0]!==void 0&&arguments[0],i=this.getSortableList();i&&Object.keys(this.listEventListeners).forEach(function(a){e?i.removeEventListener(a,t.listEventListeners[a]):i.addEventListener(a,t.listEventListeners[a],!1)})}},{key:"initDragAndDrop",value:function(){this.initialised||(this.toggleListEventListeners(),this.initPlaceholderList(),this.toggleListItemAttributes(),this.toggleMainListLifeCycleClassName(),this.initialised=!0)}},{key:"init",value:function(){this.initDragAndDrop()}},{key:"destroy",value:function(){this.toggleListEventListeners(!0),this.toggleListItemAttributes(!1),this.toggleMainListLifeCycleClassName(!1),this.initialised=!1}},{key:"removeClassFromEl",value:function(t,e){e&&e.classList.contains(t)&&e.classList.remove(t)}},{key:"canBeTargeted",value:function(t){return!(!this.draggedNode||this.draggedNode===t)&&(t.nodeName==="LI"?!this.nestingThresholdReached(t):t instanceof this.listInterface&&t.classList.contains(this.classNames.placeholder))}},{key:"onDragStart",value:function(t){var e;this.draggedNode=t.target,this.draggedNode.classList.add(this.classNames.dragged),(e=t.dataTransfer)===null||e===void 0||e.setData("text","")}},{key:"onDragOver",value:function(t){t.preventDefault(),this.updateCoordination(t),this.managePlaceholderLists()}},{key:"onDragEnter",value:function(t){this.canBeTargeted(t.target)&&(this.removeClassFromEl(this.classNames.targeted,this.targetedNode),this.targetedNode=t.target,this.targetedNode.classList.add(this.classNames.targeted))}},{key:"onDragEnd",value:function(t){t.stopPropagation(),this.removeClassFromEl(this.classNames.dragged,this.draggedNode),this.removeClassFromEl(this.classNames.targeted,this.targetedNode),this.cleanupPlaceholderLists(),delete this.draggedNode,delete this.targetedNode}},{key:"onDrop",value:function(t){t.stopPropagation(),this.maybeDrop(),this.cleanupPlaceholderLists(),typeof this.actions.onDrop=="function"&&this.actions.onDrop(this.getDataEngine().convertDomToData(this.getSortableList()))}},{key:"updateCoordination",value:function(t){this.calcMouseCoords(t),this.calcMouseToTargetedElDist()}},{key:"getDropLocation",value:function(){if(this.canBeDropped()){var t;if(((t=this.targetedNode)===null||t===void 0?void 0:t.nodeName)==="LI")return"before";if(this.targetedNode instanceof this.listInterface)return"inside"}}},{key:"maybeDrop",value:function(){var t=this.getDropLocation();t&&this.dropTheItem(t)}},{key:"dropTheItem",value:function(t){var e,i,a;switch(t){case"before":(e=this.targetedNode)===null||e===void 0||(i=e.parentNode)===null||i===void 0||i.insertBefore(this.draggedNode,this.targetedNode);break;case"inside":(a=this.targetedNode)===null||a===void 0||a.appendChild(this.draggedNode)}}},{key:"calcMouseCoords",value:function(t){this.cursor={X:t.clientX,Y:t.clientY}}},{key:"calcMouseToTargetedElDist",value:function(){if(this.targetedNode){var t=this.targetedNode.getBoundingClientRect();this.targetNode={X:t.left,Y:t.top};var e=this.targetNode.Y-this.cursor.Y,i=Math.abs(e);this.distances.mouseTo={targetedElTop:e,targetedElTopAbs:i,targetedElBot:i-this.targetedNode.clientHeight}}}},{key:"areNested",value:function(t,e){return!!t&&!!e&&Array.from(e?.querySelectorAll("li")).some(function(i){return i===t})}},{key:"cursorIsIndentedEnough",value:function(){return this.cursor.X-this.targetNode.X>50}},{key:"mouseIsTooCloseToTop",value:function(){var t;return!((t=this.distances)===null||t===void 0||!t.droppingEdge)&&this.cursor.Y-this.targetNode.Yo?s:o}for(;i!==((d=t)===null||d===void 0?void 0:d.parentElement);){var d,h,g;((h=t)===null||h===void 0?void 0:h.parentElement)instanceof this.listInterface&&e++,t=(g=t)===null||g===void 0?void 0:g.parentElement}return e+a}},{key:"nestingThresholdReached",value:function(t){var e=arguments.length>1&&arguments[1]!==void 0&&arguments[1];return!(this.nestingLevels<0)&&(e?this.getNodeDepth(t)>=this.nestingLevels:this.getNodeDepth(t)>this.nestingLevels)}},{key:"analysePlaceHolderSituation",value:function(){if(!this.targetedNode||this.areNested(this.targetedNode,this.draggedNode))return[];var t=[];return!this.cursorIsIndentedEnough()||this.mouseIsTooCloseToTop()?this.targetedNodeIsPlaceholder()||t.push("cleanup"):this.targetedNode===this.draggedNode||this.targetedNode.nodeName!=="LI"||this.targetedNode.querySelectorAll(this.getListTagName()).length||this.nestingThresholdReached(this.targetedNode,!0)||t.push("add"),t}},{key:"animatePlaceholderList",value:function(){var t;this.placeholderInUse.style.minHeight="0",this.placeholderInUse.style.transition="min-height ease .2s",this.placeholderInUse.style.minHeight="".concat((t=this.draggedNode)===null||t===void 0?void 0:t.offsetHeight,"px")}},{key:"addPlaceholderList",value:function(){var t;this.getPlaceholderList(),(t=this.targetedNode)===null||t===void 0||t.appendChild(this.placeholderInUse),this.animatePlaceholderList()}},{key:"targetNodeIsIdentified",value:function(){return!!this.targetedNode}},{key:"targetNodeIsBeingDragged",value:function(){return this.targetNodeIsIdentified()&&this.targetedNode===this.draggedNode}},{key:"targetNodeIsListWithItems",value:function(){return this.targetNodeIsIdentified()&&this.targetedNode instanceof this.listInterface&&!!this.targetedNode.querySelectorAll("li").length}},{key:"canBeDropped",value:function(){return this.targetNodeIsIdentified()&&!this.targetNodeIsBeingDragged()&&!this.targetNodeIsListWithItems()&&!this.areNested(this.targetedNode,this.draggedNode)}},{key:"cleanupPlaceholderLists",value:function(){var t,e=this,i=this.getListTagName(),a=((t=this.getSortableList())===null||t===void 0?void 0:t.querySelectorAll(i))||[];Array.from(a).forEach(function(s){s.querySelectorAll("li").length?s.classList.contains(e.classNames.placeholder)&&(s.classList.remove(e.classNames.placeholder),s.style.minHeight="auto",s.dataset.id=s.parentNode.dataset.id):s.remove()})}},{key:"initPlaceholderList",value:function(){var t;this.placeholderList=document.createElement(this.getListTagName()),(t=this.placeholderList.classList).add.apply(t,[this.classNames.placeholder].concat(c(this.listClassNames)))}},{key:"getPlaceholderList",value:function(){return this.placeholderInUse=this.placeholderList.cloneNode(!0),this.placeholderInUse}},{key:"addNewItem",value:function(t){var e,i=t.item,a=t.asLastChild,s=a!==void 0&&a,o=this.getDataEngine().addNewItem({item:i,asLastChild:s});return o.setAttribute("draggable",String(this.initialised)),(e=this.getSortableList())===null||e===void 0||e[s?"append":"prepend"](o),{data:this.getDataEngine().convertDomToData(this.getSortableList())}}}]),n}();return E})});export default M(); diff --git a/phpstan-baseline.neon b/resources/lang/en/.gitkeep similarity index 100% rename from phpstan-baseline.neon rename to resources/lang/en/.gitkeep diff --git a/resources/lang/en/filament-nested-list.php b/resources/lang/en/filament-nested-list.php new file mode 100644 index 0000000..6032cdf --- /dev/null +++ b/resources/lang/en/filament-nested-list.php @@ -0,0 +1,31 @@ + 'Root', + + /* + |-------------------------------------------------------------------------- + | Buttons + |-------------------------------------------------------------------------- + */ + 'button.save' => 'Save', + 'button.expand_all' => 'Expand All', + 'button.collapse_all' => 'Collapse All', + + /* + |-------------------------------------------------------------------------- + | Form + |-------------------------------------------------------------------------- + */ + 'components.tree.buttons.select_all.label' => 'Select All', + 'components.tree.buttons.deselect_all.label' => 'Deselect All', + 'components.tree.buttons.expand_all.label' => 'Expand All', + 'components.tree.buttons.collapse_all.label' => 'Collapse All', + + /* + |-------------------------------------------------------------------------- + | Message + |-------------------------------------------------------------------------- + */ + 'actions.delete.confirmation.with_children' => 'Are you sure delete this record and its children?', +]; diff --git a/resources/views/actions/button-action.blade.php b/resources/views/actions/button-action.blade.php new file mode 100644 index 0000000..6553f58 --- /dev/null +++ b/resources/views/actions/button-action.blade.php @@ -0,0 +1,12 @@ + + {{ $getLabel() }} + + diff --git a/resources/views/actions/group.blade.php b/resources/views/actions/group.blade.php new file mode 100644 index 0000000..0f0b2ef --- /dev/null +++ b/resources/views/actions/group.blade.php @@ -0,0 +1,9 @@ + diff --git a/resources/views/actions/grouped-action.blade.php b/resources/views/actions/grouped-action.blade.php new file mode 100644 index 0000000..79196a9 --- /dev/null +++ b/resources/views/actions/grouped-action.blade.php @@ -0,0 +1,8 @@ + + {{ $getLabel() }} + diff --git a/resources/views/actions/icon-button-action.blade.php b/resources/views/actions/icon-button-action.blade.php new file mode 100644 index 0000000..b662a87 --- /dev/null +++ b/resources/views/actions/icon-button-action.blade.php @@ -0,0 +1,6 @@ + diff --git a/resources/views/actions/link-action.blade.php b/resources/views/actions/link-action.blade.php new file mode 100644 index 0000000..4a9b011 --- /dev/null +++ b/resources/views/actions/link-action.blade.php @@ -0,0 +1,9 @@ + + {{ $getLabel() }} + diff --git a/resources/views/actions/modal/actions/button-action.blade.php b/resources/views/actions/modal/actions/button-action.blade.php new file mode 100644 index 0000000..092a3d0 --- /dev/null +++ b/resources/views/actions/modal/actions/button-action.blade.php @@ -0,0 +1,280 @@ +@if ($this instanceof \Filament\Actions\Contracts\HasActions && (! $this->hasActionsModalRendered)) +
+ @php + $action = $this->getMountedAction(); + @endphp + + + @if ($action) + {{ $action->getModalContent() }} + + @if (count(($infolist = $action->getInfolist())?->getComponents() ?? [])) + {{ $infolist }} + @elseif ($this->mountedActionHasForm()) + {{ $this->getMountedActionForm() }} + @endif + + {{ $action->getModalContentFooter() }} + @endif + +
+ + @php + $this->hasActionsModalRendered = true; + @endphp +@endif + +@if ($this instanceof \Filament\Infolists\Contracts\HasInfolists && (! $this->hasInfolistsModalRendered)) +
+ @php + $action = $this->getMountedInfolistAction(); + @endphp + + + @if ($action) + {{ $action->getModalContent() }} + + @if (count(($infolist = $action->getInfolist())?->getComponents() ?? [])) + {{ $infolist }} + @elseif ($this->mountedInfolistActionHasForm()) + {{ $this->getMountedInfolistActionForm() }} + @endif + + {{ $action->getModalContentFooter() }} + @endif + +
+ + @php + $this->hasInfolistsModalRendered = true; + @endphp +@endif + +@if ($this instanceof \Filament\Tables\Contracts\HasTable && (! $this->hasTableModalRendered)) +
+ @php + $action = $this->getMountedTableAction(); + @endphp + + + @if ($action) + {{ $action->getModalContent() }} + + @if (count(($infolist = $action->getInfolist())?->getComponents() ?? [])) + {{ $infolist }} + @elseif ($this->mountedTableActionHasForm()) + {{ $this->getMountedTableActionForm() }} + @endif + + {{ $action->getModalContentFooter() }} + @endif + +
+ +
+ @php + $action = $this->getMountedTableBulkAction(); + @endphp + + + @if ($action) + {{ $action->getModalContent() }} + + @if (count(($infolist = $action->getInfolist())?->getComponents() ?? [])) + {{ $infolist }} + @elseif ($this->mountedTableBulkActionHasForm()) + {{ $this->getMountedTableBulkActionForm() }} + @endif + + {{ $action->getModalContentFooter() }} + @endif + +
+ + @php + $this->hasTableModalRendered = true; + @endphp +@endif + +@if (! $this->hasFormsModalRendered) + @php + $action = $this->getMountedFormComponentAction(); + @endphp + +
+ + @if ($action) + {{ $action->getModalContent() }} + + @if (count(($infolist = $action->getInfolist())?->getComponents() ?? [])) + {{ $infolist }} + @elseif ($this->mountedFormComponentActionHasForm()) + {{ $this->getMountedFormComponentActionForm() }} + @endif + + {{ $action->getModalContentFooter() }} + @endif + +
+ + @php + $this->hasFormsModalRendered = true; + @endphp +@endif \ No newline at end of file diff --git a/resources/views/components/actions/action.blade.php b/resources/views/components/actions/action.blade.php new file mode 100644 index 0000000..de6c34c --- /dev/null +++ b/resources/views/components/actions/action.blade.php @@ -0,0 +1,35 @@ +@props([ + 'action', + 'dynamicComponent', + 'icon' => null, +]) + +@php + $isDisabled = $action->isDisabled(); + $url = $action->getUrl(); +@endphp + + + {{ $slot }} + diff --git a/resources/views/components/actions/index.blade.php b/resources/views/components/actions/index.blade.php new file mode 100644 index 0000000..b289b27 --- /dev/null +++ b/resources/views/components/actions/index.blade.php @@ -0,0 +1,40 @@ +@props([ + 'actions', + 'alignment' => null, + 'record' => null, + 'wrap' => false, +]) + +@php + use Filament\Support\Enums\Alignment; + + $actions = array_filter( + $actions, + function ($action) use ($record): bool { + + if (! $action instanceof \SolutionForest\FilamentTree\Actions\Modal\Action) { + $action->record($record); + } + + return $action->isVisible(); + }, + ); +@endphp + +
class([ + 'fi-tree-actions flex shrink-0 items-center gap-3', + 'flex-wrap' => $wrap, + 'sm:flex-nowrap' => $wrap === '-sm', + match ($alignment) { + Alignment::Center, 'center' => 'justify-center', + Alignment::Start, Alignment::Left, 'start', 'left' => 'justify-start', + 'start md:end' => 'justify-start md:justify-end', + default => 'justify-end', + }, + ]) + }} +> + +
\ No newline at end of file diff --git a/resources/views/components/modal/actions.blade.php b/resources/views/components/modal/actions.blade.php new file mode 100644 index 0000000..745e345 --- /dev/null +++ b/resources/views/components/modal/actions.blade.php @@ -0,0 +1,7 @@ + + {{ $slot }} + diff --git a/resources/views/components/modal/heading.blade.php b/resources/views/components/modal/heading.blade.php new file mode 100644 index 0000000..6e2bc87 --- /dev/null +++ b/resources/views/components/modal/heading.blade.php @@ -0,0 +1,6 @@ + + {{ $slot }} + diff --git a/resources/views/components/modal/index.blade.php b/resources/views/components/modal/index.blade.php new file mode 100644 index 0000000..8853089 --- /dev/null +++ b/resources/views/components/modal/index.blade.php @@ -0,0 +1,21 @@ +@php use function Filament\Support\prepare_inherited_attributes; @endphp +@php use Filament\Facades\Filament; @endphp +@captureSlots([ +'actions', +'content', +'footer', +'header', +'heading', +'subheading', +'trigger', +]) + + + {{ $slot }} + diff --git a/resources/views/components/modal/subheading.blade.php b/resources/views/components/modal/subheading.blade.php new file mode 100644 index 0000000..50c60ae --- /dev/null +++ b/resources/views/components/modal/subheading.blade.php @@ -0,0 +1,6 @@ + + {{ $slot }} + diff --git a/resources/views/components/tree/index.blade.php b/resources/views/components/tree/index.blade.php new file mode 100644 index 0000000..0f3e51b --- /dev/null +++ b/resources/views/components/tree/index.blade.php @@ -0,0 +1,111 @@ +@php + use Illuminate\Support\Js; + $containerKey = 'filament_tree_container_' . $this->getId(); + $maxDepth = $getMaxDepth() ?? -1; + $data = collect($this->getRecords() ?? []) + ->map( + fn ($record) => [ + 'id' => $record->id, + 'parent_id' => $this->getParentKey($record), + 'text' => $this->getTreeRecordTitle($record), + ], + ) + ->toArray(); + ray($data); +@endphp + +
+ + + + + + {{ __('filament-nested-list::filament-nested-list.button.save') }} + + + + +
+
+
+ +
+ @php + $action = $this->getMountedTreeAction(); + @endphp + + + @if ($action) + {{ $action->getModalContent() }} + + @if (count(($infolist = $action->getInfolist())?->getComponents() ?? [])) + {{ $infolist }} + @elseif ($this->mountedTreeActionHasForm()) + {{ $this->getMountedTreeActionForm() }} + @endif + + {{ $action->getModalContentFooter() }} + @endif + +
diff --git a/resources/views/components/tree/item.blade.php b/resources/views/components/tree/item.blade.php new file mode 100644 index 0000000..834de74 --- /dev/null +++ b/resources/views/components/tree/item.blade.php @@ -0,0 +1,138 @@ +@php + use Illuminate\Database\Eloquent\Model; + use Filament\Facades\Filament; + use InvadersXX\FilamentNestedList\Components\NestedList; +@endphp + +@props([ + 'record', + 'containerKey', + 'tree', + 'title' => null, + 'icon' => null, +]) +@php + /** @var $record Model */ + /** @var $containerKey string */ + /** @var $tree NestedList */ + + $recordKey = $tree->getRecordKey($record); + $parentKey = $tree->getParentKey($record); + + $children = $record->children; + $hasChildren = (bool) count($children); + + $actions = $tree->getActions(); +@endphp + +@if ($hasChildren) +
  • +
    +
    + + +
    + +
    + @if ($icon) +
    + +
    + @endif + + ! $icon, + 'font-semibold', + ]) + > + {{ $title }} + + +
    ! $hasChildren, 'flex items-center justify-center pl-3'])> + + +
    +
    + + @if (count($actions)) +
    + +
    + @endif +
    +
      + +
    +
  • +@else +
  • +
    +
    + + +
    + +
    + @if ($icon) +
    + +
    + @endif + + ! $icon, + 'font-semibold', + ]) + > + {{ $title }} + +
    + + @if (count($actions)) +
    + +
    + @endif +
    +
  • +@endif diff --git a/resources/views/components/tree/list.blade.php b/resources/views/components/tree/list.blade.php new file mode 100644 index 0000000..6b6e923 --- /dev/null +++ b/resources/views/components/tree/list.blade.php @@ -0,0 +1,14 @@ +@props([ + 'records', + 'tree', +]) +
    + @foreach ($records ?? [] as $record) + @php + $title = $this->getTreeRecordTitle($record); + $icon = $this->getTreeRecordIcon($record); + @endphp + + + @endforeach +
    diff --git a/resources/views/forms/tree.blade.php b/resources/views/forms/tree.blade.php new file mode 100644 index 0000000..2648685 --- /dev/null +++ b/resources/views/forms/tree.blade.php @@ -0,0 +1,120 @@ + +
    merge($getExtraAttributes())->class([ + 'filament-forms-tree-component py-2 px-5 bg-white border border-gray-300 rounded-xl shadow-sm', + 'dark:bg-gray-500/10' => config('forms.dark_mode'), + ]) }} + wire:ignore + x-data="{ + + areAllCheckboxesChecked: false, + + treeOptions: Array.from($root.querySelectorAll('.filament-forms-tree-component-option-label')), + + collapsedAll: false, + + init: function () { + + this.checkIfAllCheckboxesAreChecked() + + Livewire.hook('message.processed', () => { + this.checkIfAllCheckboxesAreChecked() + }) + }, + + checkIfAllCheckboxesAreChecked: function () { + this.areAllCheckboxesChecked = this.treeOptions.length === this.treeOptions.filter((checkboxLabel) => checkboxLabel.querySelector('input[type=checkbox]:checked')).length + }, + + toggleAllCheckboxes: function () { + state = ! this.areAllCheckboxesChecked + + this.treeOptions.forEach((checkboxLabel) => { + checkbox = checkboxLabel.querySelector('input[type=checkbox]') + + checkbox.checked = state + checkbox.dispatchEvent(new Event('change')) + }) + + this.areAllCheckboxesChecked = state + }, + + toggleCollapseAll: function () { + this.collapsedAll = ! this.collapsedAll + } + }"> + +
    + + {{ __('filament-nested-list::filament-nested-list.components.tree.buttons.select_all.label') }} + + + + {{ __('filament-nested-list::filament-nested-list.components.tree.buttons.deselect_all.label') }} + + + + + +
    + + + @foreach ($getOptions() as $optionValue => $item) + @include('filament-nested-list::forms.tree.option-item', ['optionValue' => $optionValue, 'item' => $item]) + @endforeach + +
    +
    diff --git a/resources/views/forms/tree/option-item.blade.php b/resources/views/forms/tree/option-item.blade.php new file mode 100644 index 0000000..aa3b0fd --- /dev/null +++ b/resources/views/forms/tree/option-item.blade.php @@ -0,0 +1,83 @@ +@props(['optionValue', 'item', 'parent' => null]) +@php + $optionLabel = $item['label'] ?? null; + $children = $item['children'] ?? []; + $key = "{$this->id}." . $getStatePath() . '.' . $field::class . '.options.' . $optionValue; +@endphp +
    +
    + + +
    + @if (count($children)) + @foreach ($children as $childValue => $childItem) +
    + @include('filament-nested-list::forms.tree.option-item', ['optionValue' => $childValue, 'item' => $childItem, 'parent' => $optionValue]) +
    + @endforeach + @endif +
    \ No newline at end of file diff --git a/resources/views/pages/tree.blade.php b/resources/views/pages/tree.blade.php new file mode 100644 index 0000000..62e4ca5 --- /dev/null +++ b/resources/views/pages/tree.blade.php @@ -0,0 +1,16 @@ +@php + $columns = [ + 'default' => 1, + ]; +@endphp + + + + {{ $this->tree }} + + + + \ No newline at end of file diff --git a/resources/views/tree/scripts.blade.php b/resources/views/tree/scripts.blade.php new file mode 100644 index 0000000..3bc43a7 --- /dev/null +++ b/resources/views/tree/scripts.blade.php @@ -0,0 +1,54 @@ +@props(['containerKey', 'maxDepth']) + + diff --git a/resources/views/widgets/nested-list.blade.php b/resources/views/widgets/nested-list.blade.php new file mode 100644 index 0000000..31656d6 --- /dev/null +++ b/resources/views/widgets/nested-list.blade.php @@ -0,0 +1,3 @@ + + {{ $this->tree }} + diff --git a/src/Actions/Action.php b/src/Actions/Action.php new file mode 100644 index 0000000..94fa5ae --- /dev/null +++ b/src/Actions/Action.php @@ -0,0 +1,130 @@ +isLivewireClickHandlerEnabled()) { + return null; + } + + if (is_string($this->action)) { + return $this->action; + } + + if ($record = $this->getRecord()) { + $recordKey = $this->getLivewire()->getRecordKey($record); + + return "mountTreeAction('{$this->getName()}', '{$recordKey}')"; + } + + return "mountTreeAction('{$this->getName()}')"; + } + + public function getRecordTitle(?Model $record = null): string + { + $record ??= $this->getRecord(); + + return $this->getCustomRecordTitle($record) ?? $this->getLivewire()->getTreeRecordTitle($record); + } + + public function getRecordTitleAttribute(): ?string + { + return $this->getCustomRecordTitleAttribute() ?? $this->getTree()->getRecordTitleAttribute(); + } + + public function getModelLabel(): string + { + return $this->getCustomModelLabel() ?? $this->getTree()->getModelLabel(); + } + + public function getPluralModelLabel(): string + { + return $this->getCustomPluralModelLabel() ?? $this->getTree()->getPluralModelLabel(); + } + + public function prepareModalAction(StaticAction $action): StaticAction + { + $action = parent::prepareModalAction($action); + + if (! $action instanceof Action) { + return $action; + } + + return $action + ->tree($this->getTree()) + ->record($this->getRecord()); + } + + /** + * @return array + */ + protected function resolveDefaultClosureDependencyForEvaluationByType(string $parameterType): array + { + $record = $this->getRecord(); + + if (! $record) { + return parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType); + } + + return match ($parameterType) { + Model::class, $record::class => [$record], + default => parent::resolveDefaultClosureDependencyForEvaluationByType($parameterType), + }; + } + + protected function getDefaultEvaluationParameters(): array + { + return collect(['record', 'model', 'tree']) + ->flip() + ->map(fn ($v, $name) => $this->resolveDefaultClosureDependencyForEvaluationByName($name)[0] ?? null) + ->toArray(); + } + + /** + * @return array + */ + protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array + { + return match ($parameterName) { + 'model' => [$this->getModel()], + 'record' => [$this->getRecord()], + 'tree' => [$this->getTree()], + default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName), + }; + } + + public function getModel(): string + { + return $this->getCustomModel() ?? $this->getLivewire()->getModel(); + } +} diff --git a/src/Actions/ActionGroup.php b/src/Actions/ActionGroup.php new file mode 100644 index 0000000..fb1c16a --- /dev/null +++ b/src/Actions/ActionGroup.php @@ -0,0 +1,40 @@ +actions as $action) { + $actions[$action->getName()] = $action->grouped()->record($this->getRecord()); + } + + return $actions; + } + + public function tree(NestedList $tree): static + { + foreach ($this->actions as $action) { + if (! $action instanceof HasNestedList) { + continue; + } + + $action->tree($tree); + } + + return $this; + } +} diff --git a/src/Actions/DeleteAction.php b/src/Actions/DeleteAction.php new file mode 100644 index 0000000..98f16ab --- /dev/null +++ b/src/Actions/DeleteAction.php @@ -0,0 +1,65 @@ +label(__('filament-actions::delete.single.label')); + + $this->modalHeading(fn (): string => __('filament-actions::delete.single.modal.heading', ['label' => $this->getRecordTitle()])); + + $this->modalSubmitActionLabel(__('filament-actions::delete.single.modal.actions.delete.label')); + + $this->successNotificationTitle(__('filament-actions::delete.single.notifications.deleted.title')); + + $this->color('danger'); + + $this->icon('heroicon-m-trash'); + + $this->requiresConfirmation(); + + $this->modalSubheading(function (Model $record) { + if (collect($record->children)->isNotEmpty()) { + return __('filament-nested-list::filament-nested-list.actions.delete.confirmation.with_children'); + } + + return __('filament-actions::modal.confirmation'); + }); + + $this->modalIcon('heroicon-o-trash'); + + $this->hidden(static function (Model $record): bool { + if (! method_exists($record, 'trashed')) { + return false; + } + + return $record->trashed(); + }); + + $this->action(function (): void { + $result = $this->process(static fn (Model $record) => $record->delete()); + + if (! $result) { + $this->failure(); + + return; + } + + $this->success(); + }); + } + + public static function getDefaultName(): ?string + { + return 'delete'; + } +} diff --git a/src/Actions/EditAction.php b/src/Actions/EditAction.php new file mode 100644 index 0000000..58128b5 --- /dev/null +++ b/src/Actions/EditAction.php @@ -0,0 +1,82 @@ +mutateRecordDataUsing = $callback; + + return $this; + } + + public function mutateFormDataBeforeSaveUsing(?Closure $callback): static + { + $this->mutateFormDataBeforeSaveUsing = $callback; + + return $this; + } + + public function getMutateFormDataBeforeSave(): ?Closure + { + return $this->mutateFormDataBeforeSaveUsing; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->label(__('filament-actions::edit.single.label')); + + $this->modalHeading(fn (): string => __('filament-actions::edit.single.modal.heading', ['label' => $this->getRecordTitle()])); + + $this->modalSubmitActionLabel(__('filament-actions::edit.single.modal.actions.save.label')); + + $this->successNotificationTitle(__('filament-actions::edit.single.notifications.saved.title')); + + $this->icon('heroicon-m-pencil-square'); + + $this->fillForm(function (Model $record, NestedList $tree): array { + if ($translatableContentDriver = $tree->makeFilamentTranslatableContentDriver()) { + $data = $translatableContentDriver->getRecordAttributesToArray($record); + } else { + $data = $record->attributesToArray(); + } + + if ($this->mutateRecordDataUsing) { + $data = $this->evaluate($this->mutateRecordDataUsing, ['data' => $data, 'record' => $record]); + } + + return $data; + }); + + $this->action(function (): void { + $this->process(function (array $data, Model $record, NestedList $tree) { + if ($translatableContentDriver = $tree->makeFilamentTranslatableContentDriver()) { + $translatableContentDriver->updateRecord($record, $data); + } else { + $record->update($data); + } + }); + + $this->success(); + }); + } +} diff --git a/src/Actions/Modal/Action.php b/src/Actions/Modal/Action.php new file mode 100644 index 0000000..ea12b9e --- /dev/null +++ b/src/Actions/Modal/Action.php @@ -0,0 +1,15 @@ +mutateRecordDataUsing = $callback; + + return $this; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->label(__('filament-actions::view.single.label')); + + $this->modalHeading(fn (): string => __('filament-actions::view.single.modal.heading', ['label' => $this->getRecordTitle()])); + + $this->modalSubmitAction(false); + $this->modalCancelAction(fn (StaticAction $action) => $action->label(__('filament-actions::view.single.modal.actions.close.label'))); + + $this->color('gray'); + + $this->icon('heroicon-m-eye'); + + $this->disabledForm(); + + $this->fillForm(function (Model $record, NestedList $tree): array { + if ($translatableContentDriver = $tree->makeFilamentTranslatableContentDriver()) { + $data = $translatableContentDriver->getRecordAttributesToArray($record); + } else { + $data = $record->attributesToArray(); + } + + if ($this->mutateRecordDataUsing) { + $data = $this->evaluate($this->mutateRecordDataUsing, ['data' => $data, 'record' => $record]); + } + + return $data; + }); + + $this->action(static function (): void { + }); + } +} diff --git a/src/Commands/SkeletonCommand.php b/src/Commands/FilamentNestedListCommand.php similarity index 57% rename from src/Commands/SkeletonCommand.php rename to src/Commands/FilamentNestedListCommand.php index 3e5f628..2f394f7 100644 --- a/src/Commands/SkeletonCommand.php +++ b/src/Commands/FilamentNestedListCommand.php @@ -1,12 +1,12 @@ livewire($livewire); + } + + public static function make(HasNestedList $livewire): static + { + $result = app(static::class, ['livewire' => $livewire]); + + $result->configure(); + + return $result; + } + + public function maxDepth(int $maxDepth): static + { + $this->maxDepth = $maxDepth; + + return $this; + } + + public function actions(array $actions): static + { + $this->actions = $actions; + + return $this; + } + + public function getMaxDepth(): int + { + return $this->maxDepth; + } + + public function getActions(): array + { + return $this->actions; + } + + public function getModel(): string + { + return $this->getLivewire()->getModel(); + } + + public function getRecordKey(?Model $record): ?string + { + if (! $record) { + return null; + } + + return $record->getAttributeValue($record->getKeyName()); + } + + public function getParentKey(?Model $record): ?string + { + if (! $record) { + return null; + } + + return $record->getAttributeValue((method_exists($record, 'determineParentKey') ? $record->determineParentColumnName() : Utils::parentColumnName())); + } + + public function getMountedActionForm(): ?ComponentContainer + { + return $this->getLivewire()->getMountedTreeActionForm(); + } +} diff --git a/src/Concern/Actions/HasNestedList.php b/src/Concern/Actions/HasNestedList.php new file mode 100644 index 0000000..7658711 --- /dev/null +++ b/src/Concern/Actions/HasNestedList.php @@ -0,0 +1,10 @@ +livewire = $livewire; + + return $this; + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return $this->getLivewire()->makeFilamentTranslatableContentDriver(); + } + + public function getLivewire(): HasNestedList + { + return $this->livewire; + } +} diff --git a/src/Concern/BelongsToNestedList.php b/src/Concern/BelongsToNestedList.php new file mode 100644 index 0000000..37e7b68 --- /dev/null +++ b/src/Concern/BelongsToNestedList.php @@ -0,0 +1,28 @@ +tree = $tree; + + return $this; + } + + public function getLivewire(): HasNestedList + { + return $this->getTree()->getLivewire(); + } + + public function getTree(): NestedList + { + return $this->tree; + } +} diff --git a/src/Concern/HasActions.php b/src/Concern/HasActions.php new file mode 100644 index 0000000..02bd4ea --- /dev/null +++ b/src/Concern/HasActions.php @@ -0,0 +1,393 @@ + | null + */ + public ?array $mountedTreeAction = []; + + /** + * @var array> | null + */ + public ?array $mountedTreeActionData = []; + + public int|string|null $mountedTreeActionRecord = null; + + protected array $cachedTreeActions; + + protected ?Model $cachedMountedTreeActionRecord = null; + + protected int|string|null $cachedMountedTreeActionRecordKey = null; + + public function cacheTreeActions(): void + { + $this->cachedTreeActions = []; + + $actions = Action::configureUsing( + Closure::fromCallable([$this, 'configureTreeAction']), + fn (): array => $this->getTreeActions(), + ); + + foreach ($actions as $index => $action) { + if ($action instanceof ActionGroup) { + foreach ($action->getActions() as $groupedAction) { + $groupedAction->tree($this->getCachedTree()); + } + + $this->cachedTreeActions[$index] = $action; + + continue; + } + + $action->tree($this->getCachedTree()); + + $this->cachedTreeActions[$action->getName()] = $action; + } + } + + /** + * Action for each record + */ + protected function getTreeActions(): array + { + return []; + } + + public function mountTreeAction(string $name, ?string $record = null) + { + $this->mountedTreeAction[] = $name; + $this->mountedTreeActionData[] = []; + + if (count($this->mountedTreeAction) === 1) { + $this->mountedTreeActionRecord($record); + } + + $action = $this->getMountedTreeAction(); + + if (! $action) { + $this->unmountTreeAction(); + + return null; + } + + if (filled($record) && ($action->getRecord() === null)) { + return; + } + + if ($action->isDisabled()) { + return; + } + + $this->cacheMountedTreeActionForm(); + + try { + $hasForm = $this->mountedTreeActionHasForm(); + + if ($hasForm) { + $action->callBeforeFormFilled(); + } + + $action->mount([ + 'form' => $this->getMountedTreeActionForm(), + ]); + + if ($hasForm) { + $action->callAfterFormFilled(); + } + } catch (Halt $exception) { + return null; + } catch (Cancel $exception) { + $this->unmountTreeAction(shouldCancelParentActions: false); + + return null; + } + + if (! $this->mountedTreeActionShouldOpenModal()) { + return $this->callMountedTreeAction(); + } + + $this->resetErrorBag(); + + $this->openTreeActionModal(); + + return null; + } + + public function mountedTreeActionRecord($record): void + { + $this->mountedTreeActionRecord = $record; + } + + public function getMountedTreeAction(): ?Action + { + if (! count($this->mountedTreeAction ?? [])) { + return null; + } + + return $this->getCachedTreeAction($this->mountedTreeAction) ?? $this->getCachedTreeEmptyStateAction($this->mountedTreeAction); + } + + /** + * @param string | array $name + */ + public function getCachedTreeAction(string|array $name): ?Action + { + if (is_string($name) && str($name)->contains('.')) { + $name = explode('.', $name); + } + + if (is_array($name)) { + $firstName = array_shift($name); + + $name = $firstName; + } + + return $this->findTreeAction($name)?->record($this->getMountedTreeActionRecord()); + } + + protected function findTreeAction(string $name): ?Action + { + $actions = $this->getCachedTreeActions(); + + $action = $actions[$name] ?? null; + + if ($action) { + return $action; + } + + foreach ($actions as $action) { + if (! $action instanceof ActionGroup) { + continue; + } + + $groupedAction = $action->getActions()[$name] ?? null; + + if (! $groupedAction) { + continue; + } + + return $groupedAction; + } + + return null; + } + + public function getCachedTreeActions(): array + { + return $this->cachedTreeActions; + } + + public function getMountedTreeActionRecord(): ?Model + { + $recordKey = $this->getMountedTreeActionRecordKey(); + + if ($this->cachedMountedTreeActionRecord && ($this->cachedMountedTreeActionRecordKey === $recordKey)) { + return $this->cachedMountedTreeActionRecord; + } + + $this->cachedMountedTreeActionRecordKey = $recordKey; + + return $this->cachedMountedTreeActionRecord = $this->getTreeRecord($recordKey); + } + + public function getMountedTreeActionRecordKey(): int|string|null + { + return $this->mountedTreeActionRecord; + } + + public function unmountTreeAction(bool $shouldCancelParentActions = true): void + { + $action = $this->getMountedTreeAction(); + + if (! ($shouldCancelParentActions && $action)) { + $this->popMountedTreeAction(); + } elseif ($action->shouldCancelAllParentActions()) { + $this->resetMountedTreeActionProperties(); + } else { + $parentActionToCancelTo = $action->getParentActionToCancelTo(); + + while (true) { + $recentlyClosedParentAction = $this->popMountedTreeAction(); + + if ( + blank($parentActionToCancelTo) || + ($recentlyClosedParentAction === $parentActionToCancelTo) + ) { + break; + } + } + } + + if (! count($this->mountedTreeAction)) { + $this->closeTreeActionModal(); + + $action?->record(null); + $this->mountedTreeActionRecord(null); + + return; + } + + $this->cacheMountedTreeActionForm(); + + $this->resetErrorBag(); + + $this->openTreeActionModal(); + } + + protected function popMountedTreeAction(): ?string + { + try { + return array_pop($this->mountedTreeAction); + } finally { + array_pop($this->mountedTreeActionData); + } + } + + protected function resetMountedTreeActionProperties(): void + { + $this->mountedTreeAction = []; + $this->mountedTreeActionData = []; + } + + protected function closeTreeActionModal(): void + { + $this->dispatch('close-modal', id: "{$this->getId()}-tree-action"); + } + + protected function cacheMountedTreeActionForm(): void + { + $this->cacheForm( + 'mountedTreeActionForm', + fn () => $this->getMountedTreeActionForm(), + ); + } + + public function getMountedTreeActionForm() + { + $action = $this->getMountedTreeAction(); + + if (! $action) { + return null; + } + + if ((! $this->isCachingForms) && $this->hasCachedForm('mountedTreeActionForm')) { + return $this->getCachedForm('mountedTreeActionForm'); + } + + return $action->getForm( + $this->makeForm() + ->model($this->getMountedTreeActionRecord() ?? $this->getTreeQuery()->getModel()::class) + ->statePath('mountedTreeActionData.'.array_key_last($this->mountedTreeActionData)) + ->operation(implode('.', $this->mountedTreeAction)), + ); + } + + protected function openTreeActionModal(): void + { + $this->dispatch('open-modal', id: "{$this->getId()}-tree-action"); + } + + public function mountedTreeActionHasForm(): bool + { + return (bool) count($this->getMountedTreeActionForm()?->getComponents() ?? []); + } + + public function mountedTreeActionShouldOpenModal(): bool + { + return $this->getMountedTreeAction()->shouldOpenModal( + checkForFormUsing: $this->mountedTableActionHasForm(...), + ); + // $action = $this->getMountedTreeAction(); + + // if ($action->shouldOpenModal()) { + // return false; + // } + + // return $action->getModalDescription() || + // $action->getModalContent() || + // $action->getModalContentFooter() || + // $action->getInfolist() || + // $this->mountedTreeActionHasForm(); + } + + public function callMountedTreeAction(?string $arguments = null) + { + $action = $this->getMountedTreeAction(); + + if (! $action) { + return null; + } + + if (filled($this->mountedTreeActionRecord) && ($action->getRecord() === null)) { + return null; + } + + if ($action->isDisabled()) { + return null; + } + + $action->arguments($arguments ? json_decode($arguments, associative: true) : []); + + $form = $this->getMountedTreeActionForm(); + + $result = null; + + try { + if ($this->mountedTreeActionHasForm()) { + $action->callBeforeFormValidated(); + + $action->formData($form->getState()); + + $action->callAfterFormValidated(); + } + + $action->callBefore(); + + $result = $action->call([ + 'form' => $form, + ]); + + $result = $action->callAfter() ?? $result; + } catch (Halt $exception) { + return null; + } catch (Cancel $exception) { + } + + $action->resetArguments(); + $action->resetFormData(); + + $this->unmountTreeAction(); + + return $result; + } + + protected function configureTreeAction(Action $action): void + { + } + + protected function getHasActionsForms(): array + { + return [ + 'mountedTreeActionData' => $this->getMountedTreeActionForm(), + ]; + } + + protected function getTreeActionsPosition(): ?string + { + return null; + } +} diff --git a/src/Concern/HasEmptyState.php b/src/Concern/HasEmptyState.php new file mode 100644 index 0000000..a6a6a4e --- /dev/null +++ b/src/Concern/HasEmptyState.php @@ -0,0 +1,63 @@ + $this->getTreeEmptyStateActions(), + ); + + $this->cachedTreeEmptyStateActions = []; + + foreach ($actions as $action) { + $action->tree($this->getCachedTree()); + + $this->cachedTreeEmptyStateActions[$action->getName()] = $action; + } + } + + protected function getTreeEmptyStateActions(): array + { + return []; + } + + public function getCachedTreeEmptyStateAction(string $name): ?Action + { + return $this->getCachedTreeEmptyStateActions()[$name] ?? null; + } + + public function getCachedTreeEmptyStateActions(): array + { + return $this->cachedTreeEmptyStateActions; + } + + protected function getTreeEmptyState(): ?View + { + return null; + } + + protected function getTreeEmptyStateDescription(): ?string + { + return null; + } + + protected function getTreeEmptyStateHeading(): ?string + { + return null; + } + + protected function getTreeEmptyStateIcon(): ?string + { + return null; + } +} diff --git a/src/Concern/HasHeading.php b/src/Concern/HasHeading.php new file mode 100644 index 0000000..d0b540d --- /dev/null +++ b/src/Concern/HasHeading.php @@ -0,0 +1,34 @@ +treeTitle = $treeTitle; + + return $this; + } + + public function enableTreeTitle(bool $condition): static + { + $this->enableTreeTitle = $condition; + + return $this; + } + + public function getTreeTitle(): ?string + { + return $this->treeTitle; + } + + public function displayTreeTitle(): bool + { + return $this->enableTreeTitle; + } +} diff --git a/src/Concern/HasRecords.php b/src/Concern/HasRecords.php new file mode 100644 index 0000000..14ce556 --- /dev/null +++ b/src/Concern/HasRecords.php @@ -0,0 +1,76 @@ +resolveTreeRecord($key); + } + + protected function resolveTreeRecord(?string $key): ?Model + { + if ($key === null) { + return null; + } + + return $this->getSortedQuery()->find($key); + } + + protected function getSortedQuery(): Builder + { + $query = $this->getWithRelationQuery(); + if (method_exists($this->getModel(), 'scopeOrdered')) { + return $this->getWithRelationQuery()->ordered(); + } + + return $query; + } + + protected function getWithRelationQuery(): Builder + { + $query = $this->getTreeQuery(); + if (method_exists($this->getModel(), 'children') && $this->getModel()::has('children')) { + return $query->with('children'); + } + + return $query; + } + + protected function getTreeQuery(): Builder + { + return $this->getModel()::query(); + } + + public function getRootLayerRecords(): \Illuminate\Support\Collection + { + return collect($this->getRecords() ?? []) + ->filter(function (Model $record) { + if (method_exists($record, 'isRoot')) { + return $record->isRoot(); + } + if (method_exists($record, 'determineParentColumnName')) { + return $record->getAttributeValue($record->determineParentColumnName()) === Utils::defaultParentId(); + } + + return $record->getAttributeValue('parent') === Utils::defaultParentId(); + }); + } + + public function getRecords(): ?Collection + { + if ($this->records) { + return $this->records; + } + + return $this->records = $this->getSortedQuery()->get(); + } +} diff --git a/src/Concern/HasTranslatableRecords.php b/src/Concern/HasTranslatableRecords.php new file mode 100644 index 0000000..b3476ca --- /dev/null +++ b/src/Concern/HasTranslatableRecords.php @@ -0,0 +1,61 @@ +traitGetRecords(); + if ($records) { + foreach ($records as $record) { + $this->updateModelTranslation($record); + } + } + + return $records; + } + + private function updateModelTranslation(?Model $record = null): void + { + if ($record) { + if (method_exists($record, 'setLocale') && $activeLocale = $this->getActiveLocale()) { + $record->setLocale($activeLocale); + } + + // relationships + foreach ($record->getRelations() as $relationKey => $item) { + if (is_array($item) || $item instanceof Arrayable) { + foreach ($item as $relationRecord) { + if ($relationRecord instanceof Model) { + + $this->updateModelTranslation($relationRecord); + } + } + + } elseif (! empty($item)) { + + $this->updateModelTranslation($item); + } + } + } + } + + protected function resolveTreeRecord(?string $key): ?Model + { + $record = $this->traitResolveTreeRecord($key); + + $this->updateModelTranslation($record); + + return $record; + } +} diff --git a/src/Concern/InteractWithNestedList.php b/src/Concern/InteractWithNestedList.php new file mode 100644 index 0000000..a8f0458 --- /dev/null +++ b/src/Concern/InteractWithNestedList.php @@ -0,0 +1,146 @@ +getTree(); + $this->tree = $tree->configureUsing( + Closure::fromCallable([static::class, 'tree']), + fn (): NestedList => static::tree($tree)->maxDepth(static::getMaxDepth()), + ); + + $this->cacheTreeActions(); + $this->cacheTreeEmptyStateActions(); + + $this->tree->actions(array_values($this->getCachedTreeActions())); + + if ($this->hasMounted) { + return; + } + + $this->hasMounted = true; + } + + protected function getTree(): NestedList + { + return NestedList::make($this); + } + + public function mountInteractsWithNestedList(): void + { + } + + public function getTreeRecordTitle(?Model $record = null): string + { + if (! $record) { + return ''; + } + + return $record->{(method_exists($record, 'determineTitleColumnName') ? $record->determineTitleColumnName() : 'title')}; + } + + public function getTreeRecordIcon(?Model $record = null): ?string + { + if (! $record) { + return null; + } + + return $record->{(method_exists($record, 'determineIconColumnName') ? $record->determineIconColumnName() : 'icon')}; + } + + public function getRecordKey(?Model $record): ?string + { + return $this->getCachedTree()->getRecordKey($record); + } + + protected function getCachedTree(): NestedList + { + return $this->tree; + } + + public function getParentKey(?Model $record): ?string + { + return $this->getCachedTree()->getParentKey($record); + } + + public function getNodeCollapsedState(?Model $record = null): bool + { + return false; + } + + /** + * Update the tree list. + */ + public function updateTree(?array $list = null): void + { + $needReload = false; + if ($list) { + $records = $this->getRecords()->keyBy(fn ($record) => $record->getAttributeValue($record->getKeyName())); + $defaultParentId = Utils::defaultParentId(); + $unnestedArrData = collect($list) + ->map(fn (array $data, $id) => ['data' => $data, 'model' => $records->get($data['id'])]) + ->filter(fn (array $arr) => ! is_null($arr['model'])); + foreach ($unnestedArrData as $arr) { + $model = $arr['model']; + [$newParentId, $newOrder] = [$arr['data']['parent_id'] ?? $defaultParentId, $arr['data']['order']]; + if ($model instanceof Model) { + $parentColumnName = method_exists($model, 'determineParentColumnName') ? $model->determineParentColumnName() : Utils::parentColumnName(); + $orderColumnName = method_exists($model, 'determineOrderColumnName') ? $model->determineOrderColumnName() : Utils::orderColumnName(); + $newParentId = $newParentId === $defaultParentId && method_exists($model, 'defaultParentKey') ? $model::defaultParentKey() : $newParentId; + + $model->{$parentColumnName} = $newParentId; + $model->{$orderColumnName} = $newOrder; + if ($model->isDirty([$parentColumnName, $orderColumnName])) { + $model->save(); + + $needReload = true; + } + } + } + } + if ($needReload) { + Notification::make() + ->success() + ->title(__('filament-actions::edit.single.modal.actions.save.label')) + ->send(); + } + if ($needReload) { + $this->dispatch('refreshNestedList'); + } + } + + /** + * Unnesting the tree array. + */ + private function unnestArray(array &$result, array $current, $parent): void + { + foreach ($current as $index => $item) { + $key = data_get($item, 'id'); + $result[$key] = [ + 'parent_id' => $parent, + 'order' => $index + 1, + ]; + if (isset($item['children']) && count($item['children'])) { + $this->unnestArray($result, $item['children'], $key); + } + } + } +} diff --git a/src/Concern/ModelNestedList.php b/src/Concern/ModelNestedList.php new file mode 100644 index 0000000..19cd5ee --- /dev/null +++ b/src/Concern/ModelNestedList.php @@ -0,0 +1,260 @@ +{$model->determineParentColumnName()}) || $model->{$model->determineParentColumnName()} === -1) { + $model->{$model->determineParentColumnName()} = static::defaultParentKey(); + } + if (empty($model->{$model->determineOrderColumnName()}) || $model->{$model->determineOrderColumnName()} === 0) { + $model->setHighestOrderNumber(); + } + }); + + // Delete children + static::deleting(function (Model $model) { + static::buildSortQuery() + ->where($model->determineParentColumnName(), $model->getKey()) + ->get() + ->each + ->delete(); + }); + } + + public function determineParentColumnName(): string + { + return Utils::parentColumnName(); + } + + public static function defaultParentKey() + { + return Utils::defaultParentId(); + } + + public function determineOrderColumnName(): string + { + return Utils::orderColumnName(); + } + + public function setHighestOrderNumber(): void + { + $this->{$this->determineOrderColumnName()} = $this->getHighestOrderNumber() + 1; + } + + public function getHighestOrderNumber(): int + { + return (int) $this->buildSortQuery()->where($this->determineParentColumnName(), $this->{$this->determineParentColumnName()})->max($this->determineOrderColumnName()); + } + + public static function buildSortQuery(): Builder + { + return static::query()->ordered(); + } + + /** + * Get tree nodes options. + */ + public static function treeNodes(): array + { + $result = []; + + $model = app(static::class); + + [$primaryKeyName, $titleKeyName, $parentKeyName, $childrenKeyName] = [ + $model->getKeyName(), + $model->determineTitleColumnName(), + $model->determineParentColumnName(), + static::defaultChildrenKeyName(), + ]; + + $nodes = Utils::buildNestedArray( + nodes: static::allNodes(), + parentId: static::defaultParentKey(), + primaryKeyName: $primaryKeyName, + parentKeyName: $parentKeyName, + childrenKeyName: $childrenKeyName + ); + + foreach ($nodes as $node) { + static::buildTreeNodeItem($result, $node, $primaryKeyName, $titleKeyName, $childrenKeyName); + } + + return $result; + } + + public function determineTitleColumnName(): string + { + return Utils::titleColumnName(); + } + + public static function defaultChildrenKeyName(): string + { + return Utils::defaultChildrenKeyName(); + } + + /** + * @return static[]|Collection + */ + public static function allNodes() + { + return static::buildSortQuery()->get(); + } + + private static function buildTreeNodeItem(array &$final, array $item, string $primaryKeyName, string $titleKeyName, string $childrenKeyName): void + { + if (! isset($item[$primaryKeyName])) { + throw new InvalidArgumentException("Unset '{$primaryKeyName}' primary key."); + } + $pk = data_get($item, $primaryKeyName); + $name = data_get($item, $titleKeyName); + $children = []; + + if (count($item[$childrenKeyName])) { + foreach ($item[$childrenKeyName] as $child) { + static::buildTreeNodeItem($children, $child, $primaryKeyName, $titleKeyName, $childrenKeyName); + } + } + $final[] = [ + $primaryKeyName => $pk, + $titleKeyName => $name, + $childrenKeyName => $children, + ]; + } + + /** + * Get select array options. + */ + public static function selectArray(?int $maxDepth = null): array + { + $result = []; + + $model = app(static::class); + + [$primaryKeyName, $titleKeyName, $parentKeyName, $childrenKeyName] = [ + $model->getKeyName(), + $model->determineTitleColumnName(), + $model->determineParentColumnName(), + static::defaultChildrenKeyName(), + ]; + + $nodes = Utils::buildNestedArray( + nodes: static::allNodes(), + parentId: static::defaultParentKey(), + primaryKeyName: $primaryKeyName, + parentKeyName: $parentKeyName, + childrenKeyName: $childrenKeyName + ); + + $result[static::defaultParentKey()] = __('filament-nested-list::filament-nested-list.root'); + + foreach ($nodes as $node) { + static::buildSelectArrayItem($result, $node, $primaryKeyName, $titleKeyName, $childrenKeyName, 1, $maxDepth); + } + + return $result; + } + + private static function buildSelectArrayItem(array &$final, array $item, string $primaryKeyName, string $titleKeyName, string $childrenKeyName, int $depth, ?int $maxDepth = null): void + { + if (! isset($item[$primaryKeyName])) { + throw new InvalidArgumentException("Unset '{$primaryKeyName}' primary key."); + } + + if ($maxDepth && $depth > $maxDepth) { + return; + } + + static::handleTranslatable($item); + + $key = $item[$primaryKeyName]; + $title = isset($item[$titleKeyName]) ? $item[$titleKeyName] : $item[$primaryKeyName]; + if (! is_string($title)) { + $title = (string) $title; + } + $final[$key] = Str::padLeft($title, (str($title)->length() + ($depth * 3)), '-'); + + if (count($item[$childrenKeyName])) { + foreach ($item[$childrenKeyName] as $child) { + static::buildSelectArrayItem($final, $child, $primaryKeyName, $titleKeyName, $childrenKeyName, $depth + 1, $maxDepth); + } + } + } + + public static function handleTranslatable(array &$final): void + { + static::traitHandleTranslatable($final, static::class); + } + + public function initializeModelTree() + { + if (! empty($this->getFillable())) { + $this->mergeFillable([ + $this->determineOrderColumnName(), + $this->determineParentColumnName(), + $this->determineTitleColumnName(), + ]); + } + } + + public function children() + { + return $this->hasMany(static::class, $this->determineParentColumnName())->with('children')->orderBy($this->determineOrderColumnName()); + } + + public function isRoot(): bool + { + return $this->getAttributeValue($this->determineParentColumnName()) === static::defaultParentKey(); + } + + public function getLowestOrderNumber(): int + { + return (int) $this->buildSortQuery()->where($this->determineParentColumnName(), $this->{$this->determineParentColumnName()})->min($this->determineOrderColumnName()); + } + + public function scopeOrdered(Builder $query, string $direction = 'asc') + { + return $query->orderBy($this->determineParentColumnName(), 'asc')->orderBy($this->determineOrderColumnName(), $direction); + } + + public function scopeIsRoot(Builder $query) + { + return $query->where($this->determineParentColumnName(), static::defaultParentKey()); + } + + /** + * Format all nodes as tree. + * + * @param array|Collection|null $nodes + */ + public function toTree($nodes = null): array + { + if ($nodes === null) { + $nodes = static::allNodes(); + } + + return Utils::buildNestedArray( + nodes: $nodes, + parentId: static::defaultParentKey(), + primaryKeyName: $this->getKeyName(), + parentKeyName: $this->determineParentColumnName() + ); + } +} diff --git a/src/Concern/SupportTranslation.php b/src/Concern/SupportTranslation.php new file mode 100644 index 0000000..b3e9697 --- /dev/null +++ b/src/Concern/SupportTranslation.php @@ -0,0 +1,34 @@ + $value) { + if (! ( + method_exists($modelClass, 'isTranslatableAttribute') + && method_exists($modelClass, 'setTranslations') + && method_exists($modelClass, 'getTranslationWithFallback') + )) { + continue; + } + $model = app($modelClass); + + if (! $model->isTranslatableAttribute($key)) { + continue; + } + if (is_array($value)) { + $model->setTranslations($key, $value); + $final[$key] = $model->getTranslationWithFallback($key, app()->getLocale()); + } + } + } +} diff --git a/src/Concern/TreeRecords/HasActiveLocaleSwitcher.php b/src/Concern/TreeRecords/HasActiveLocaleSwitcher.php new file mode 100644 index 0000000..e1d87b4 --- /dev/null +++ b/src/Concern/TreeRecords/HasActiveLocaleSwitcher.php @@ -0,0 +1,33 @@ +setTranslatableLocales($this->getTranslatableLocales()); + } + + public function getTranslatableLocales(): array + { + return $this->translatableLocales ?? ( + method_exists(static::class, 'getResource') + ? static::getResource()::getTranslatableLocales() + : ( + method_exists(static::class, 'getTranslatableLocales') + ? $this->getTranslatableLocales() + : [] + ) + ); + } + + public function setTranslatableLocales(array $locales): void + { + $this->translatableLocales = $locales; + } +} diff --git a/src/Concern/TreeRecords/Translatable.php b/src/Concern/TreeRecords/Translatable.php new file mode 100644 index 0000000..fd1de1c --- /dev/null +++ b/src/Concern/TreeRecords/Translatable.php @@ -0,0 +1,116 @@ +setActiveLocale(); + } + + public function hydrateTranslatable() + { + + } + + public function dehydrateTranslatable() + { + + } + + public function getActiveLocale(): ?string + { + return $this->activeLocale; + } + + protected function setActiveLocale(): void + { + $this->activeLocale = method_exists($this, 'getDefaultTranslatableLocale') + ? $this->getDefaultTranslatableLocale() + : app()->getLocale(); + } + + protected function afterConfiguredCreateAction(CreateAction $action): CreateAction + { + /** @var CreateAction */ + $action = parent::afterConfiguredCreateAction($action); + + if (method_exists($action, 'using')) { + $model = $action->getModel(); + $action->using(function (array $data) use ($model) { + if (method_exists($model, 'getTranslatableAttributes')) { + foreach (app($model)->getTranslatableAttributes() as $attr) { + $data[$attr] = array_merge( + [$this->getActiveLocale() => $data[$attr]], + $this->getActiveLocale() !== app()->getFallbackLocale() ? [app()->getFallbackLocale() => $data[$attr]] : [], + ); + } + } + + return $model::create($data); + }); + } + + return $action; + } + + protected function afterConfiguredEditAction(Actions\EditAction $action): Actions\EditAction + { + /** @var Actions\EditAction */ + $action = parent::afterConfiguredEditAction($action); + + $action->mutateRecordDataUsing(function (array $data, Model $record) { + return $this->mutateRecordData($data, $record); + }); + + if (method_exists($action, 'using')) { + $action->using(function (array $data, Model $record) use ($action) { + $data = $action->evaluate($action->getMutateFormDataBeforeSave(), ['data' => $data]); + + $record->fill($data); + if (method_exists($record, 'setTranslation') + && method_exists($record, 'getTranslatableAttributes') + ) { + foreach ($record->getTranslatableAttributes() as $attr) { + $record->setTranslation($attr, $this->getActiveLocale(), $data[$attr]); + } + } + $record->save(); + }); + } + + return $action; + } + + protected function afterConfiguredViewAction(Actions\ViewAction $action): Actions\ViewAction + { + /** @var Actions\ViewAction */ + $action = parent::afterConfiguredViewAction($action); + + $action->mutateRecordDataUsing(function (array $data, Model $record) { + return $this->mutateRecordData($data, $record); + }); + + return $action; + } + + private function mutateRecordData(array $data, Model $record): array + { + if (method_exists($record, 'getTranslatableAttributes')) { + foreach ($record->getTranslatableAttributes() as $attr) { + $data[$attr] = $record->getAttributeValue($attr); + } + } + + return $data; + } +} diff --git a/src/Contract/HasNestedList.php b/src/Contract/HasNestedList.php new file mode 100644 index 0000000..3cec163 --- /dev/null +++ b/src/Contract/HasNestedList.php @@ -0,0 +1,22 @@ +name(static::$name) + ->hasConfigFile() + ->hasViews() + ->hasAssets() + ->hasTranslations(); + } + + public function packageBooted(): void + { + FilamentAsset::register([ + Css::make('filament-nested-list-styles', __DIR__ . '/../resources/dist/filament-nested-list.css'), + Js::make('filament-nested-list-scripts', __DIR__ . '/../resources/dist/filament-nested-list.js'), + ], 'invaders-xx/filament-nested-list'); + } +} diff --git a/src/Forms/Components/NestedList.php b/src/Forms/Components/NestedList.php new file mode 100644 index 0000000..f2409b5 --- /dev/null +++ b/src/Forms/Components/NestedList.php @@ -0,0 +1,298 @@ +default([]); + + $this->afterStateHydrated(static function (NestedList $component, $state) { + if (is_array($state)) { + return; + } + + $component->state([]); + }); + + $this->dehydrateStateUsing(static function (NestedList $component, $state) { + if (! is_array($state)) { + $state = []; + } + + return $component->formatNodeState($state, $component->getOptions()); + }); + } + + public function getState() + { + $state = parent::getState(); + + if (is_array($state)) { + return $this->getNodeState($state); + } + + try { + return json_decode($state); + } catch (Exception $e) { + return []; + } + } + + public function getNodeLabel(string $uuid): ?string + { + return data_get($this->getChildComponentContainer($uuid)->getRawState(), $this->getTitleColumn() ?? 'title'); + } + + public function getTitleColumn(): ?string + { + return $this->evaluate($this->titleColumn); + } + + public function nodes(array|Arrayable $nodes): static + { + if ($nodes instanceof Arrayable) { + $this->nodes = $nodes->toArray(); + } else { + $this->nodes = $nodes; + } + + return $this; + } + + public function keyColumn(string $keyColumn): static + { + $this->keyColumn = $keyColumn; + + return $this; + } + + public function titleColumn(string $titleColumn): static + { + $this->titleColumn = $titleColumn; + + return $this; + } + + public function childrenColumn(string $childrenColumn): static + { + $this->childrenColumn = $childrenColumn; + + return $this; + } + + public function relationship(string|Closure $relationshipName, ?Closure $callback = null): static + { + $this->relationship = $relationshipName; + $this->modifyRelationshipQueryUsing = $callback; + + $this->afterStateHydrated(null); + + $this->loadStateFromRelationshipsUsing(static function (NestedList $component, $state): void { + $component->clearCachedExistingRecords(); + + $component->fillFromRelationship(); + }); + + $this->saveRelationshipsUsing(static function (NestedList $component, Model $record, $state) { + if (! is_array($state)) { + $state = []; + } + + $relationship = $component->getRelationship(); + + $existingRecords = $component->getCachedExistingRecords(); + $existingRecordKeys = $existingRecords->pluck($relationship->getRelated()->getKeyName())->toArray(); + + $recordsToDetach = collect($existingRecordKeys)->filter(fn ($keyToDetach) => ! in_array($keyToDetach, $state)); + + $recordsToAttach = collect($state)->filter(fn ($keyToAttach) => ! in_array($keyToAttach, $existingRecordKeys)); + + if ($relationship instanceof BelongsToMany) { + $relationship->detach($recordsToDetach); + $relationship->attach($recordsToAttach); + } + }); + + return $this; + } + + public function clearCachedExistingRecords(): void + { + $this->cachedExistingRecords = null; + } + + public function fillFromRelationship(): void + { + $this->state( + array_keys($this->getStateFromRelatedRecords($this->getCachedExistingRecords())), + ); + } + + public function getCachedExistingRecords(): Collection + { + if ($this->cachedExistingRecords) { + return $this->cachedExistingRecords; + } + + $relationship = $this->getRelationship(); + $relationshipQuery = $relationship->getQuery(); + + if ($relationship instanceof BelongsToMany) { + $relationshipQuery->select([ + $relationship->getTable() . '.*', + $relationshipQuery->getModel()->getTable() . '.*', + ]); + } + + if ($this->modifyRelationshipQueryUsing) { + $relationshipQuery = $this->evaluate($this->modifyRelationshipQueryUsing, [ + 'query' => $relationshipQuery, + ]) ?? $relationshipQuery; + } + + $relatedKeyName = $relationship->getRelated()->getKeyName(); + + return $this->cachedExistingRecords = $relationshipQuery->get()->mapWithKeys( + fn (Model $item): array => [strval($item[$relatedKeyName]) => $item], + ); + } + + public function getRelationship(): ?BelongsToMany + { + if (! $this->hasRelationship()) { + return null; + } + + return $this->getModelInstance()->{$this->getRelationshipName()}(); + } + + public function hasRelationship(): bool + { + return filled($this->getRelationshipName()); + } + + public function getRelationshipName(): ?string + { + return $this->evaluate($this->relationship); + } + + public function getOptions(): array + { + return $this->getNodeOptions($this->getNodes()); + } + + public function getKeyColumn(): ?string + { + return $this->evaluate($this->keyColumn); + } + + public function getChildrenColumn(): ?string + { + return $this->evaluate($this->childrenColumn); + } + + public function getNodes(): array + { + return $this->nodes ?? []; + } + + protected function getStateFromRelatedRecords(Collection $records): array + { + if (! $records->count()) { + return []; + } + + $activeLocale = $this->getLivewire()->getActiveFormLocale(); + + return $records + ->map(function (Model $record) use ($activeLocale): array { + $state = $record->attributesToArray(); + + if ($activeLocale && method_exists($record, 'getTranslatableAttributes') && method_exists($record, 'getTranslation')) { + foreach ($record->getTranslatableAttributes() as $attribute) { + $state[$attribute] = $record->getTranslation($attribute, $activeLocale); + } + } + + return $state; + }) + ->toArray(); + } + + protected function getRelatedModel(): string + { + return $this->getRelationship()->getModel()::class; + } + + private function getNodeState(array $state): array + { + $final = []; + + foreach ($state as $key => $childOrKey) { + if (is_array($childOrKey)) { + $final = array_merge($final, [$key], $this->getNodeState($childOrKey)); + } else { + $final = array_merge($final, [$childOrKey]); + } + } + + return $final; + } + + private function formatNodeState(array $state, array $options): array + { + $final = []; + + foreach ($options as $value => $item) { + if (in_array($value, $state)) { + $final[strval($value)] = $this->formatNodeState($state, data_get($item, 'children', [])); + } + } + + return $final; + } + + private function getNodeOptions(array $options): array + { + return collect($options) + ->keyBy(fn ($item) => strval(data_get($item, $this->getKeyColumn() ?? 'id'))) + ->map(fn (array $item) => [ + 'label' => data_get($item, $this->getTitleColumn() ?? 'title'), + 'children' => $this->getNodeOptions(data_get($item, $this->getChildrenColumn() ?? 'children') ?? []), + ]) + ->toArray(); + } +} diff --git a/src/Pages/NestedListPage.php b/src/Pages/NestedListPage.php new file mode 100644 index 0000000..81e75d5 --- /dev/null +++ b/src/Pages/NestedListPage.php @@ -0,0 +1,255 @@ + $this->configureCreateAction($action), + default => null, + }; + } + + protected function configureCreateAction(CreateAction $action): CreateAction + { + $action->livewire($this); + + $schema = $this->getCreateFormSchema(); + + if (empty($schema)) { + $schema = $this->getFormSchema(); + } + + $action->form($schema); + + $action->model($this->getModel()); + + $this->afterConfiguredCreateAction($action); + + return $action; + } + + protected function getCreateFormSchema(): array + { + return []; + } + + protected function getFormSchema(): array + { + return []; + } + + protected function model(string $model): static + { + static::$model = $model; + + return $this; + } + + public function getModel(): string + { + return static::$model ?? class_basename(static::class); + } + + protected function afterConfiguredCreateAction(CreateAction $action): CreateAction + { + return $action; + } + + protected function configureTreeAction(Actions\Action $action): void + { + match (true) { + $action instanceof Actions\DeleteAction => $this->configureDeleteAction($action), + $action instanceof Actions\EditAction => $this->configureEditAction($action), + $action instanceof Actions\ViewAction => $this->configureViewAction($action), + default => null, + }; + } + + protected function configureDeleteAction(Actions\DeleteAction $action): Actions\DeleteAction + { + $action->tree($this->getCachedTree()); + + $action->iconButton(); + + $this->afterConfiguredDeleteAction($action); + + return $action; + } + + public static function tree(NestedList $tree): NestedList + { + return $tree; + } + + protected function afterConfiguredDeleteAction(Actions\DeleteAction $action): Actions\DeleteAction + { + return $action; + } + + protected function configureEditAction(Actions\EditAction $action): Actions\EditAction + { + $action->tree($this->getCachedTree()); + + $action->iconButton(); + + $schema = $this->getEditFormSchema(); + + if (empty($schema)) { + $schema = $this->getFormSchema(); + } + + $action->form($schema); + + $action->model($this->getModel()); + + $action->mutateFormDataBeforeSaveUsing(fn (array $data) => $this->mutateFormDataBeforeSave($data)); + + $this->afterConfiguredEditAction($action); + + return $action; + } + + protected function getEditFormSchema(): array + { + return []; + } + + protected function mutateFormDataBeforeSave(array $data): array + { + return $data; + } + + protected function afterConfiguredEditAction(Actions\EditAction $action): Actions\EditAction + { + return $action; + } + + protected function configureViewAction(Actions\ViewAction $action): Actions\ViewAction + { + $action->tree($this->getCachedTree()); + + $action->iconButton(); + + $schema = $this->getViewFormSchema(); + + if (empty($schema)) { + $schema = $this->getFormSchema(); + } + + $action->form($this->getFormSchema()); + + $isInfoList = count(array_filter($schema, fn ($component) => $component instanceof InfolistsComponent)) > 0; + + if ($isInfoList) { + $action->infolist($schema); + } + + $action->model($this->getModel()); + + $this->afterConfiguredViewAction($action); + + return $action; + } + + protected function getViewFormSchema(): array + { + return []; + } + + protected function afterConfiguredViewAction(Actions\ViewAction $action): Actions\ViewAction + { + return $action; + } + + protected function getTreeActions(): array + { + return array_merge( + ($this->hasEditAction() ? [$this->getEditAction()] : []), + ($this->hasViewAction() ? [$this->getViewAction()] : []), + ($this->hasDeleteAction() ? [$this->getDeleteAction()] : []), + ); + } + + protected function hasEditAction(): bool + { + return true; + } + + protected function getEditAction(): Actions\EditAction + { + return Actions\EditAction::make(); + } + + protected function hasViewAction(): bool + { + return false; + } + + protected function getViewAction(): Actions\ViewAction + { + return Actions\ViewAction::make(); + } + + protected function hasDeleteAction(): bool + { + return false; + } + + protected function getDeleteAction(): Actions\DeleteAction + { + return Actions\DeleteAction::make(); + } + + protected function getActions(): array + { + return array_merge( + ($this->hasCreateAction() ? [$this->getCreateAction()] : []), + ); + } + + protected function hasCreateAction(): bool + { + return true; + } + + protected function getCreateAction(): CreateAction + { + return CreateAction::make(); + } + + protected function callHook(string $hook): void + { + if (! method_exists($this, $hook)) { + return; + } + + $this->{$hook}(); + } +} diff --git a/src/Resources/Pages/TreePage.php b/src/Resources/Pages/TreePage.php new file mode 100644 index 0000000..2f9324e --- /dev/null +++ b/src/Resources/Pages/TreePage.php @@ -0,0 +1,171 @@ + RouteFacade::get($path, static::class) + ->middleware(static::getRouteMiddleware($panel)), + ); + } + + public static function getEmailVerifiedMiddleware(Panel $panel): string + { + return static::getResource()::getEmailVerifiedMiddleware($panel); + } + + public static function getResource(): string + { + return static::$resource; + } + + public static function isEmailVerificationRequired(Panel $panel): bool + { + return static::getResource()::isEmailVerificationRequired($panel); + } + + public static function getTenantSubscribedMiddleware(Panel $panel): string + { + return static::getResource()::getTenantSubscribedMiddleware($panel); + } + + public static function isTenantSubscriptionRequired(Panel $panel): bool + { + return static::getResource()::isTenantSubscriptionRequired($panel); + } + + public static function authorizeResourceAccess(): void + { + abort_unless(static::getResource()::canViewAny(), 403); + } + + public function getBreadcrumbs(): array + { + $resource = static::getResource(); + + $breadcrumb = $this->getBreadcrumb(); + + return array_merge( + [$resource::getUrl() => $resource::getBreadcrumb()], + (filled($breadcrumb) ? [$breadcrumb] : []), + ); + } + + public function getBreadcrumb(): ?string + { + return static::$breadcrumb ?? static::getTitle(); + } + + public function getTitle(): string + { + return static::$title ?? Str::headline(static::getResource()::getPluralModelLabel()); + } + + public function getPluralModelLabel(): string + { + return static::getResource()::getPluralModelLabel(); + } + + public function getModel(): string + { + return static::getResource()::getModel(); + } + + public function getTableRecordTitle(Model $record): string + { + return static::getResource()::getRecordTitle($record); + } + + public function getTableModelLabel(): string + { + return $this->getModelLabel(); + } + + public function getModelLabel(): string + { + return static::getResource()::getModelLabel(); + } + + public function getTablePluralModelLabel(): string + { + return $this->getPluralModelLabel(); + } + + protected function getFormSchema(): array + { + return static::getResource()::form(Form::make($this))->getComponents(); + } + + protected function configureCreateAction(CreateAction $action): CreateAction + { + return parent::configureCreateAction($action) + ->authorize(static::getResource()::canCreate()) + ->modelLabel($this->getModelLabel()); + } + + protected function configureDeleteAction(DeleteAction $action): DeleteAction + { + return parent::configureDeleteAction($action) + ->authorize(fn (Model $record): bool => static::getResource()::canDelete($record)); + } + + protected function configureViewAction(ViewAction $action): ViewAction + { + return parent::configureViewAction($action) + ->authorize(fn (Model $record): bool => static::getResource()::canView($record)); + } + + protected function configureEditAction(EditAction $action): EditAction + { + return parent::configureEditAction($action) + ->authorize(fn (Model $record): bool => static::getResource()::canEdit($record)); + } + + protected function hasDeleteAction(): bool + { + return true; + } + + protected function hasEditAction(): bool + { + return true; + } + + protected function hasViewAction(): bool + { + return false; + } + + protected function callHook(string $hook): void + { + if (! method_exists($this, $hook)) { + return; + } + + $this->{$hook}(); + } +} diff --git a/src/Skeleton.php b/src/Skeleton.php deleted file mode 100755 index 66fab60..0000000 --- a/src/Skeleton.php +++ /dev/null @@ -1,7 +0,0 @@ -name('skeleton') - ->hasConfigFile() - ->hasViews() - ->hasMigration('create_skeleton_table') - ->hasCommand(SkeletonCommand::class); - } -} diff --git a/src/Support/Utils.php b/src/Support/Utils.php new file mode 100644 index 0000000..d03cf6a --- /dev/null +++ b/src/Support/Utils.php @@ -0,0 +1,85 @@ +groupBy(fn ($node) => $node[$parentKeyName])->sortKeys(); + foreach ($nodeGroups as $pk => $nodeGroup) { + $pk = is_numeric($pk) ? intval($pk) : $pk; + if ( + ($pk === $parentId) + // Allow parentId is nullable or negative number + // https://github.com/solutionforest/filament-tree/issues/28 + || (($pk === '' || $pk <= 0) && $parentId <= 0) + ) { + foreach ($nodeGroup as $node) { + $node = collect($node)->toArray(); + + $branch[] = array_merge($node, [ + // children + $childrenKeyName => static::buildNestedArray( + nodes: $nodes, + // children's parent id + parentId: $node[$primaryKeyName], + primaryKeyName: $primaryKeyName, + parentKeyName: $parentKeyName, + childrenKeyName: $childrenKeyName + ), + ]); + } + } + } + + return $branch; + } + + public static function parentColumnName(): string + { + return config('filament-nested-list.column_name.parent', 'parent_id'); + } + + public static function defaultChildrenKeyName(): string + { + return (string) config('filament-nested-list.default_children_key', 'children'); + } +} diff --git a/src/Widgets/NestedList.php b/src/Widgets/NestedList.php new file mode 100644 index 0000000..5fdfa23 --- /dev/null +++ b/src/Widgets/NestedList.php @@ -0,0 +1,207 @@ + '$refresh', + ]; + + protected int|string|array $columnSpan = 'full'; + + public static function getMaxDepth(): int + { + return static::$maxDepth; + } + + public function makeTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + protected function getFormModel(): Model|string|null + { + return $this->getModel(); + } + + public function getModel(): string + { + return static::$model ?? class_basename(static::class); + } + + protected function getTreeActions(): array + { + return array_merge( + ($this->hasEditAction() ? [$this->getEditAction()] : []), + ($this->hasViewAction() ? [$this->getViewAction()] : []), + ($this->hasDeleteAction() ? [$this->getDeleteAction()] : []), + ); + } + + protected function hasEditAction(): bool + { + return false; + } + + protected function getEditAction(): EditAction + { + return EditAction::make(); + } + + protected function hasViewAction(): bool + { + return false; + } + + protected function getViewAction(): ViewAction + { + return ViewAction::make(); + } + + protected function hasDeleteAction(): bool + { + return false; + } + + protected function getDeleteAction(): DeleteAction + { + return DeleteAction::make(); + } + + protected function configureTreeAction(Action $action): void + { + match (true) { + $action instanceof DeleteAction => $this->configureDeleteAction($action), + $action instanceof EditAction => $this->configureEditAction($action), + $action instanceof ViewAction => $this->configureViewAction($action), + default => null, + }; + } + + protected function configureDeleteAction(DeleteAction $action): DeleteAction + { + $action->tree($this->getCachedTree()); + + $action->iconButton(); + + $this->afterConfiguredDeleteAction($action); + + return $action; + } + + public static function tree(TreeComponent $tree): TreeComponent + { + return $tree; + } + + protected function afterConfiguredDeleteAction(DeleteAction $action): DeleteAction + { + return $action; + } + + protected function configureEditAction(EditAction $action): EditAction + { + $action->tree($this->getCachedTree()); + + $action->iconButton(); + + $schema = $this->getEditFormSchema(); + + if (empty($schema)) { + $schema = $this->getFormSchema(); + } + + $action->form($schema); + + $action->model($this->getModel()); + + $this->afterConfiguredEditAction($action); + + return $action; + } + + protected function getEditFormSchema(): array + { + return []; + } + + protected function getFormSchema(): array + { + return []; + } + + protected function afterConfiguredEditAction(EditAction $action): EditAction + { + return $action; + } + + protected function configureViewAction(ViewAction $action): ViewAction + { + $action->tree($this->getCachedTree()); + + $action->iconButton(); + + $schema = $this->getViewFormSchema(); + + if (empty($schema)) { + $schema = $this->getFormSchema(); + } + + $action->form($this->getFormSchema()); + + $isInfoList = count(array_filter($schema, fn ($component) => $component instanceof InfolistsComponent)) > 0; + + if ($isInfoList) { + $action->infolist($schema); + } + + $action->model($this->getModel()); + + $this->afterConfiguredViewAction($action); + + return $action; + } + + protected function getViewFormSchema(): array + { + return []; + } + + protected function afterConfiguredViewAction(ViewAction $action): ViewAction + { + return $action; + } + + protected function callHook(string $hook): void + { + if (! method_exists($this, $hook)) { + return; + } + + $this->{$hook}(); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 7fe1500..b68c900 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,5 @@ in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index d04fb0c..9a84e22 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,36 +1,36 @@ set('database.default', 'testing'); + + /* + $migration = include __DIR__.'/../database/migrations/create_filament-nested-list_table.php.stub'; + $migration->up(); + */ + } + protected function setUp(): void { parent::setUp(); Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'VendorName\\Skeleton\\Database\\Factories\\'.class_basename($modelName).'Factory' + fn (string $modelName) => 'InvadersXX\\FilamentNestedList\\Database\\Factories\\'.class_basename($modelName).'Factory' ); } protected function getPackageProviders($app) { return [ - SkeletonServiceProvider::class, + FilamentNestedListServiceProvider::class, ]; } - - public function getEnvironmentSetUp($app) - { - config()->set('database.default', 'testing'); - - /* - $migration = include __DIR__.'/../database/migrations/create_skeleton_table.php.stub'; - $migration->up(); - */ - } }