diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..32bae48 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +If you discover any security related issues, please email steve@tappnetwork.com instead of using the issue tracker. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 290527b..3ceb9a1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,14 +1,3 @@ # Details Details of the feature / fix this PR addresses - -# Steps to test changes - -1. visit /login -2. fill out form with incorrect password -3. submit -4. see error feedback - -# Screenshots - -Any visual changes must have screenshots diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index cc8c94c..a4ebb3e 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -14,7 +14,7 @@ jobs: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2.4.0 + uses: dependabot/fetch-metadata@v2.5.0 with: 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 index 1ec8dbf..bea1f92 100644 --- a/.github/workflows/fix-php-code-style-issues.yml +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -15,14 +15,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.head_ref }} - name: Fix PHP code style issues - uses: aglipanci/laravel-pint-action@2.5 + uses: aglipanci/laravel-pint-action@2.6 - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index d5db2f1..f4620a5 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8b2b714..4cd7ec2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -17,22 +17,22 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [8.4, 8.3, 8.2] - laravel: [12.*, 11.*, 10.*] - stability: [prefer-lowest, prefer-stable] + php: [8.4, 8.3] + laravel: [12.*, 11.*] + stability: [prefer-stable] include: - laravel: 12.* testbench: 10.* + pest: ^4.0 - laravel: 11.* testbench: 9.* - - laravel: 10.* - testbench: 8.* + pest: ^3.0 name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -48,7 +48,11 @@ jobs: - name: Install dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer require "orchestra/testbench:${{ matrix.testbench }}" --dev --no-interaction --no-update + composer require "pestphp/pest:${{ matrix.pest }}" --dev --no-interaction --no-update + composer require "pestphp/pest-plugin-arch:${{ matrix.pest }}" --dev --no-interaction --no-update + composer require "pestphp/pest-plugin-laravel:${{ matrix.pest }}" --dev --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: List Installed Dependencies diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index 39de30d..796a758 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -14,9 +14,9 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - ref: main + ref: 4.x - name: Update Changelog uses: stefanzweifel/changelog-updater-action@v1 @@ -25,8 +25,8 @@ jobs: release-notes: ${{ github.event.release.body }} - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v7 with: - branch: main + branch: 4.x commit_message: Update CHANGELOG file_pattern: CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 26dca4e..cc59667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,79 @@ All notable changes to `Filament-Form-Builder` will be documented in this file. +## v4.1.6 - 2026-03-02 + +### What's Changed + +* Allow form description to be rich text / HTML by @scottgrayson in https://github.com/TappNetwork/Filament-Form-Builder/pull/62 + +**Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v4.1.5...v4.1.6 + +## v4.1.5 - 2026-03-02 + +### What's Changed + +* Bump minimatch from 9.0.5 to 9.0.9 by @dependabot[bot] in https://github.com/TappNetwork/Filament-Form-Builder/pull/61 +* Add private entries support for form entry visibility by @scottgrayson in https://github.com/TappNetwork/Filament-Form-Builder/pull/59 + +**Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v4.1.4...v4.1.5 + +## v4.1.4 - 2026-02-20 + +removes file upload from the rich text editor as this is not supported functionality and throws an exception. + +## v4.1.3 - 2026-02-13 + +### What's Changed + +* Table actions in ActionGroup at row start by @scottgrayson in https://github.com/TappNetwork/Filament-Form-Builder/pull/56 + +**Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v4.1.2...v4.1.3 + +## v4.1.2 - 2026-02-06 + +### What's Changed + +* Restrict copy action to users who can create forms by @scottgrayson in https://github.com/TappNetwork/Filament-Form-Builder/pull/55 + +**Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v4.1.1...v4.1.2 + +## v4.1.1 - 2026-01-28 + +### What's Changed + +* Change field label to text by @andreia in https://github.com/TappNetwork/Filament-Form-Builder/pull/54 + +**Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v4.1.0...v4.1.1 + +## v4.1.0 - 2026-01-23 + +### What's Changed + +* Bump aglipanci/laravel-pint-action from 2.5 to 2.6 by @dependabot[bot] in https://github.com/TappNetwork/Filament-Form-Builder/pull/49 +* Bump actions/checkout from 4 to 6 by @dependabot[bot] in https://github.com/TappNetwork/Filament-Form-Builder/pull/48 +* Bump stefanzweifel/git-auto-commit-action from 5 to 7 by @dependabot[bot] in https://github.com/TappNetwork/Filament-Form-Builder/pull/51 +* Bump dependabot/fetch-metadata from 2.4.0 to 2.5.0 by @dependabot[bot] in https://github.com/TappNetwork/Filament-Form-Builder/pull/50 +* Add guest panel support for form pages by @scottgrayson in https://github.com/TappNetwork/Filament-Form-Builder/pull/53 + +**Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v4.0.9...v4.1.0 + +## v4.0.9 - 2026-01-21 + +### What's Changed + +* Add Filament 5 support by @andreia in https://github.com/TappNetwork/Filament-Form-Builder/pull/47 + +**Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v4.0.8...v4.0.9 + +## v4.0.8 - 2026-01-07 + +### What's Changed + +* Multi-tenancy support by @andreia in https://github.com/TappNetwork/Filament-Form-Builder/pull/37 + +**Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v4.0.6...v4.0.8 + ## v1.51 - 2025-07-08 **Full Changelog**: https://github.com/TappNetwork/Filament-Form-Builder/compare/v1.43...v1.51 @@ -115,6 +188,15 @@ Fixes a typo in locking action visibility and adds a locked column to form resou + + + + + + + + + diff --git a/README.md b/README.md index ebaf08d..1e5e1d0 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,32 @@ ![GitHub Code Style Action Status](https://github.com/TappNetwork/Filament-Form-Builder/actions/workflows/fix-php-code-style-issues.yml/badge.svg) [![Total Downloads](https://img.shields.io/packagist/dt/tapp/filament-form-builder.svg?style=flat-square)](https://packagist.org/packages/tapp/filament-form-builder) -A Filament plugin and package that allows the creation of forms via the admin panel for collecting user data on the front end. Forms are composed of filament field components and support all Laravel validation rules. Form responses can be rendered on the front end of exported to .csv. +A Filament plugin and package that allows the creation of forms via the admin panel for collecting user data on the front end. Forms are composed of filament field components and support all Laravel validation rules. Form responses can be rendered on the front end or exported to .csv. ## Requirements - PHP 8.2+ - Laravel 11.0+ -- [Filament 3.0+](https://github.com/laravel-filament/filament) +- [Filament 4.x / 5.x](https://github.com/laravel-filament/filament) ## Dependencies - [maatwebsite/excel](https://github.com/SpartnerNL/Laravel-Excel) - [spatie/eloquent-sortable](https://github.com/spatie/eloquent-sortable) +## Version Compatibility + +Filament | Filament Form Builder | Documentation +:--------|:-------------------|:-------------- +4.x/5.x | 4.x | Current +3.x | 1.x | [Check the docs](https://github.com/TappNetwork/Filament-Form-Builder/tree/1.x) + ### Installing the Filament Forms Package Install the plugin via Composer: -This package is not yet on packagist. Add the repository to your composer.json +This package is not yet on Packagist. Add the repository to your composer.json + ```json { "repositories": [ @@ -35,15 +43,24 @@ This package is not yet on packagist. Add the repository to your composer.json ``` ```bash -composer require tapp/filament-form-builder +composer require tapp/filament-form-builder:"^4.0" ``` -public and run migrations with +You can publish the migrations with: ```bash php artisan vendor:publish --tag="filament-form-builder-migrations" ``` +> [!WARNING] +> If you are using multi-tenancy please see the "Multi-Tenancy Support" instructions below **before** publishing and running migrations. + +You can run the migrations with: + +```bash +php artisan migrate +``` + #### Optional: Publish the package's views, translations, and config You can publish the view file with: @@ -58,13 +75,18 @@ You can publish the config file with: php artisan vendor:publish --tag="filament-form-builder-config" ``` -### Adding the plugin to a panel +### Adding the plugins to panels + +The package provides three plugins for different panel types: + +#### 1. Admin Panel Plugin (`FilamentFormBuilderPlugin`) -Add this plugin to a panel on `plugins()` method (e.g. in `app/Providers/Filament/AdminPanelProvider.php`). +Add this plugin to your **admin panel** to manage forms, fields, and entries. This plugin registers the `FilamentFormResource` which provides CRUD operations for forms. ```php use Tapp\FilamentFormBuilder\FilamentFormBuilderPlugin; +// In app/Providers/Filament/AdminPanelProvider.php public function panel(Panel $panel): Panel { return $panel @@ -76,6 +98,111 @@ public function panel(Panel $panel): Panel } ``` +#### 2. Guest Panel Plugin (`FilamentFormBuilderGuestPlugin`) + +Add this plugin to your **guest panel** (for unauthenticated users) to display forms and form entries. This plugin registers the `ShowForm` and `ShowEntry` pages for public access. + +```php +use Tapp\FilamentFormBuilder\FilamentFormBuilderGuestPlugin; + +// In app/Providers/Filament/GuestPanelProvider.php +public function panel(Panel $panel): Panel +{ + return $panel + // ... + ->plugins([ + FilamentFormBuilderGuestPlugin::make(), + //... + ]); +} +``` + +#### 3. Frontend/App Panel Plugin (`FilamentFormBuilderFrontendPlugin`) + +Add this plugin to your **app/frontend panel** (for authenticated users) to display forms and form entries. This plugin registers the `ShowForm` and `ShowEntry` pages for authenticated access. + +```php +use Tapp\FilamentFormBuilder\FilamentFormBuilderFrontendPlugin; + +// In app/Providers/Filament/AppPanelProvider.php +public function panel(Panel $panel): Panel +{ + return $panel + // ... + ->plugins([ + FilamentFormBuilderFrontendPlugin::make(), + //... + ]); +} +``` + +### Public Form Access + +Forms can be accessed via the following routes (configured in `config/filament-form-builder.php`): + +- **Form View**: `/forms/{form}` - Displays a form for submission +- **Entry View**: `/entries/{entry}` - Displays a submitted form entry + +The routes automatically use the appropriate panel (guest or app) based on authentication status. Forms with `permit_guest_entries` enabled can be viewed by unauthenticated users in the guest panel, while authenticated users will be redirected to the app panel. + +### Configuration + +You can customize the package behavior by publishing and editing the config file: + +```bash +php artisan vendor:publish --tag="filament-form-builder-config" +``` + +Key configuration options include: + +- **Panel IDs**: Configure which panel IDs are used for guest and app panels (`guest-panel-id`, `app-panel-id`) +- **Login Route**: Set the login route for redirecting unauthenticated users (`login-route`) +- **Custom Page Classes**: Override the default `ShowForm` and `ShowEntry` pages for guest and app panels +- **Custom Middleware**: Override the default `SetFormPanel` middleware for panel context switching +- **Route URIs**: Customize the form and entry route paths (`filament-form-uri`, `filament-form-user-uri`) + +See `config/filament-form-builder.php` for all available configuration options. + +### Private entries + +You can restrict who can view or export form entries on a per-form basis. When **Private entries** is enabled for a form, the package uses your application’s policy to decide visibility. + +#### App setup (required for private entries) + +1. **Register a policy** for `Tapp\FilamentFormBuilder\Models\FilamentForm` (e.g. `FilamentFormPolicy`). + +2. **Implement `viewEntries`** on that policy with this **exact method name** and signature. Put all your logic here (e.g. “Admin only”); no gate is required: + + ```php + // e.g. app/Policies/FilamentFormPolicy.php + public function viewEntries(User $user, FilamentForm $form): bool + { + if (! (bool) $form->private_entries) { + return true; + } + return $user->hasRole('Admin'); // or your own rules + } + ``` + + The package calls `$user->can('viewEntries', $ownerRecord)` when: + + - Deciding whether to show the **Entries** relation manager for a form (edit page). + - Deciding whether to show the **Export Selected** bulk action. + + If your policy does not define `viewEntries`, the package does not restrict the Entries tab or export (all users who can view the form see them). + +3. **Entry-level visibility**: In your `FilamentFormUser` policy `view()` method, when the entry’s form has `private_entries`, delegate to the same policy so individual entry view links are restricted: + + ```php + if ($entry->filamentForm && (bool) $entry->filamentForm->private_entries) { + return $user->can('viewEntries', $entry->filamentForm); + } + ``` + +4. **Form edit UI**: The **Private entries** toggle on the form is disabled when the form is private and the current user fails `viewEntries`, so they cannot turn the setting off to gain access. + +No gate and no extended relation manager are required—the package’s relation manager and resource use the policy when `viewEntries` is present. + ### Configuring Tailwind: Add this to your tailwind.config.js content section: @@ -93,8 +220,81 @@ You can disable the redirect when including the Form/Show component inside of an @livewire('tapp.filament-form-builder.livewire.filament-form.show', ['form' => $test->form, 'blockRedirect' => true]) ``` -### Events -#### Livewire +## Multi-Tenancy Support + +This plugin includes comprehensive support for multi-tenancy, allowing you to scope forms, form fields, and entries to specific tenants (e.g., Teams, Organizations, Companies). + +### ⚠️ Important: Configure Before Migration + +**You MUST enable and configure tenancy BEFORE running migrations!** The migrations check the tenancy configuration to determine whether to add tenant columns to the database tables. Enabling tenancy after running migrations will require manual database modifications. + +### Configuration + +Update your `config/filament-form-builder.php` configuration file: + +```php +'tenancy' => [ + // Enable tenancy support + 'enabled' => true, + + // The Tenant model class + 'model' => \App\Models\Team::class, + + // Optional: Override the tenant relationship name + // (defaults to snake_case of tenant model class name: Team -> 'team') + 'relationship_name' => null, + + // Optional: Override the tenant foreign key column name + // (defaults to relationship_name + '_id': 'team' -> 'team_id') + 'column' => null, +], +``` + +### Setup Steps + +1. **Configure tenancy** in `config/filament-form-builder.php` (set `enabled` to `true` and specify your tenant model) +2. **Publish migrations**: `php artisan vendor:publish --tag="filament-form-builder-migrations"` +3. **Run migrations**: `php artisan migrate` +4. **Configure your Filament Panel** with tenancy: + +```php +use Filament\Panel; +use App\Models\Team; +use Tapp\FilamentFormBuilder\FilamentFormBuilderPlugin; + +public function panel(Panel $panel): Panel +{ + return $panel + ->tenant(Team::class) + ->plugins([ + FilamentFormBuilderPlugin::make(), + ]); +} +``` + +### How It Works + +When tenancy is enabled: + +- **Automatic Scoping**: All queries within Filament panels are automatically scoped to the current tenant +- **URL Structure**: Forms are accessed via tenant-specific URLs: `/admin/{tenant-slug}/filament-forms` +- **Data Isolation**: Each tenant can only access their own forms, fields, and entries +- **Cascade Deletion**: Deleting a tenant automatically removes all associated form data + +### Disabling Tenancy + +To disable tenancy, set `enabled` to `false` in your configuration: + +```php +'tenancy' => [ + 'enabled' => false, + 'model' => null, +], +``` + +## Events + +### Livewire The FilamentForm/Show component emits an 'entrySaved' event when a form entry is saved. You can handle this event in a parent component to as follows. ``` class ParentComponent extends Component @@ -109,7 +309,7 @@ class ParentComponent extends Component ``` -#### Laravel +### Laravel The component also emits a Laravel event that you can listen to in your event service provider ```php // In your EventServiceProvider.php diff --git a/composer.json b/composer.json index 861199d..c24f3d2 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,5 @@ { "name": "tapp/filament-form-builder", - "version": "dev-main", "description": "User facing form builder using Filament components", "keywords": [ "tapp network", @@ -21,9 +20,9 @@ } ], "require": { - "php": "^8.2", - "filament/filament": "^3.2", - "illuminate/contracts": "^10.0||^11.0||^12.0", + "php": "^8.3", + "filament/filament": "^5.0|^4.0", + "illuminate/contracts": "^11.0||^12.0", "maatwebsite/excel": "^3.1", "spatie/eloquent-sortable": "^4.3", "spatie/laravel-medialibrary": "^11.12", @@ -31,15 +30,14 @@ }, "require-dev": { "laravel/pint": "^1.14", - "nunomaduro/collision": "^8.1.1||^7.10.0", - "larastan/larastan": "^2.9||^3.0", - "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", - "pestphp/pest": "^3.0||^2.34", - "pestphp/pest-plugin-arch": "^3.0||^2.7", - "pestphp/pest-plugin-laravel": "^3.0||^2.3", - "phpstan/extension-installer": "^1.3||^2.0", - "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", - "phpstan/phpstan-phpunit": "^1.3||^2.0", + "larastan/larastan": "^3.0|^2.9", + "nunomaduro/collision": "^8.1.1|^7.10.0", + "orchestra/testbench": "^10.0.0|^9.0.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-arch": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1|^2.0", "spatie/laravel-ray": "^1.35" }, "autoload": { diff --git a/config/filament-form-builder.php b/config/filament-form-builder.php index 2ea07cc..ca46fb9 100644 --- a/config/filament-form-builder.php +++ b/config/filament-form-builder.php @@ -32,4 +32,99 @@ 'admin-panel-filament-form-field-name-plural' => 'Fields', 'preview-route' => 'filament-form-builder.show', + + /* + * Panel IDs Configuration + * Configure the panel IDs used for guest and app panels. + */ + 'guest-panel-id' => 'guest', + 'app-panel-id' => 'app', + + /* + * Login Route Configuration + * The route name for the login page. Used when redirecting unauthenticated users. + */ + 'login-route' => 'filament.app.auth.login', + + /* + * Guest Panel Configuration + * The page class to use for displaying forms in the guest panel. + * Set to null to use the package default, or provide your own custom page class. + * Example: \App\Filament\Guest\Pages\ShowForm::class + */ + 'guest-panel-form-page-class' => \Tapp\FilamentFormBuilder\Filament\Pages\ShowForm::class, + + /* + * App Panel Configuration + * The page class to use for displaying forms in the app panel. + * Set to null to use the package default, or provide your own custom page class. + * Example: \App\Filament\App\Pages\ShowForm::class + */ + 'app-panel-form-page-class' => \Tapp\FilamentFormBuilder\Filament\Pages\ShowForm::class, + + /* + * Set Form Panel Middleware Configuration + * The middleware class to use for setting the panel context based on authentication. + * Set to null to use the package default, or provide your own custom middleware class. + * Example: \App\Http\Middleware\SetFormPanel::class + */ + 'set-form-panel-middleware-class' => \Tapp\FilamentFormBuilder\Http\Middleware\SetFormPanel::class, + + /* + * Guest Panel Entry Configuration + * The page class to use for displaying form entries in the guest panel. + * Set to null to use the package default, or provide your own custom page class. + * Example: \App\Filament\Guest\Pages\ShowEntry::class + */ + 'guest-panel-entry-page-class' => \Tapp\FilamentFormBuilder\Filament\Pages\ShowEntry::class, + + /* + * App Panel Entry Configuration + * The page class to use for displaying form entries in the app panel. + * Set to null to use the package default, or provide your own custom page class. + * Example: \App\Filament\App\Pages\ShowEntry::class + */ + 'app-panel-entry-page-class' => \Tapp\FilamentFormBuilder\Filament\Pages\ShowEntry::class, + + /* + |-------------------------------------------------------------------------- + | Tenancy Configuration + |-------------------------------------------------------------------------- + | + | Configure multi-tenancy settings. + | + */ + 'tenancy' => [ + // Enable tenancy support + 'enabled' => false, + + // The Tenant model class (e.g., App\Models\Team::class, App\Models\Organization::class) + 'model' => null, + + // The tenant relationship name (defaults to snake_case of tenant model class name) + // For example: Team::class -> 'team', Organization::class -> 'organization' + // This should match what you configure in your Filament Panel: + // ->tenantOwnershipRelationshipName('team') + 'relationship_name' => null, + + // The tenant column name (defaults to snake_case of tenant model class name + '_id') + // You can override this if needed + 'column' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Notification Emails Configuration + |-------------------------------------------------------------------------- + | + | Configure the notification emails field in the form builder. + | + | 'user_model': The User model class to use for the select field. + | Set to null to use TagsInput for manual email entry. + | Default: 'App\Models\User' + | + | Example: 'user_model' => null, // Use TagsInput instead + | + */ + 'user_model' => 'App\Models\User', ]; diff --git a/database/migrations/add_notification_emails_to_filament_forms_table.php.stub b/database/migrations/add_notification_emails_to_filament_forms_table.php.stub new file mode 100644 index 0000000..f3dd9e7 --- /dev/null +++ b/database/migrations/add_notification_emails_to_filament_forms_table.php.stub @@ -0,0 +1,17 @@ +json('notification_emails')->nullable()->after('description'); + }); + } +}; diff --git a/database/migrations/add_private_entries_to_filament_forms_table.php.stub b/database/migrations/add_private_entries_to_filament_forms_table.php.stub new file mode 100644 index 0000000..f59e718 --- /dev/null +++ b/database/migrations/add_private_entries_to_filament_forms_table.php.stub @@ -0,0 +1,17 @@ +boolean('private_entries')->default(false)->after('permit_guest_entries'); + }); + } +}; diff --git a/database/migrations/change_label_to_text_in_filament_form_fields.php.stub b/database/migrations/change_label_to_text_in_filament_form_fields.php.stub new file mode 100644 index 0000000..9dfa9e4 --- /dev/null +++ b/database/migrations/change_label_to_text_in_filament_form_fields.php.stub @@ -0,0 +1,30 @@ +text('label')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('filament_form_fields', function (Blueprint $table): void { + $table->string('label')->change(); + }); + } +}; diff --git a/database/migrations/create_dynamic_filament_form_tables.php.stub b/database/migrations/create_dynamic_filament_form_tables.php.stub index 60010ad..95667c6 100644 --- a/database/migrations/create_dynamic_filament_form_tables.php.stub +++ b/database/migrations/create_dynamic_filament_form_tables.php.stub @@ -13,6 +13,18 @@ return new class extends Migration { Schema::create('filament_forms', function (Blueprint $table) { $table->id(); + + // Add tenant foreign key if tenancy is enabled + if (config('filament-form-builder.tenancy.enabled')) { + $tenantModel = config('filament-form-builder.tenancy.model'); + if (! $tenantModel) { + throw new \Exception('Tenant model must be configured when tenancy is enabled.'); + } + $table->foreignIdFor($tenantModel) + ->constrained() + ->cascadeOnDelete(); + } + $table->timestamps(); $table->string('name'); $table->text('description')->nullable(); @@ -23,6 +35,18 @@ return new class extends Migration Schema::create('filament_form_fields', function (Blueprint $table) { $table->id(); + + // Add tenant foreign key if tenancy is enabled + if (config('filament-form-builder.tenancy.enabled')) { + $tenantModel = config('filament-form-builder.tenancy.model'); + if (! $tenantModel) { + throw new \Exception('Tenant model must be configured when tenancy is enabled.'); + } + $table->foreignIdFor($tenantModel) + ->constrained() + ->cascadeOnDelete(); + } + $table->timestamps(); $table->foreignId('filament_form_id')->constrained()->cascadeOnDelete(); $table->integer('order'); @@ -36,6 +60,18 @@ return new class extends Migration Schema::create('filament_form_user', function (Blueprint $table) { $table->id(); + + // Add tenant foreign key if tenancy is enabled + if (config('filament-form-builder.tenancy.enabled')) { + $tenantModel = config('filament-form-builder.tenancy.model'); + if (! $tenantModel) { + throw new \Exception('Tenant model must be configured when tenancy is enabled.'); + } + $table->foreignIdFor($tenantModel) + ->constrained() + ->cascadeOnDelete(); + } + $table->timestamps(); $table->foreignId('filament_form_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); diff --git a/dist/filament-form-builder.css b/dist/filament-form-builder.css index 27e0889..60fd82e 100644 --- a/dist/filament-form-builder.css +++ b/dist/filament-form-builder.css @@ -122,10 +122,20 @@ display: flex } +.filament-form-builder .min-w-\[320px\] { + min-width: 320px +} + .filament-form-builder .min-w-\[400px\] { min-width: 400px } +@media (min-width: 640px) { + .filament-form-builder .sm\:min-w-\[400px\] { + min-width: 400px + } +} + .filament-form-builder .max-w-\[600px\] { max-width: 600px } @@ -159,12 +169,24 @@ background-color: rgb(52 211 153 / var(--tw-bg-opacity)) } +.filament-form-builder .p-4 { + padding: 1rem +} + .filament-form-builder .p-16 { padding: 4rem } -.filament-form-builder .p-4 { - padding: 1rem +@media (min-width: 640px) { + .filament-form-builder .sm\:p-8 { + padding: 2rem + } +} + +@media (min-width: 768px) { + .filament-form-builder .md\:p-16 { + padding: 4rem + } } .filament-form-builder .px-2 { diff --git a/package-lock.json b/package-lock.json index 3eabba8..3ea4a31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -183,7 +183,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -198,10 +199,11 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -612,12 +614,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8010608..9c74a51 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -24,12 +24,6 @@ parameters: count: 1 path: src/Exports/FilamentFormUsersExport.php - - - message: '#^Property Tapp\\FilamentFormBuilder\\Filament\\Forms\\Components\\Heading\:\:\$view \(view\-string\) does not accept default value of type string\.$#' - identifier: property.defaultValue - count: 1 - path: src/Filament/Forms/Components/Heading.php - - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$locked\.$#' identifier: property.notFound diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9e6e53d..d981a91 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,17 +1,10 @@ - + tests - - - - - - - diff --git a/resources/views/livewire/filament-form-user/entry.blade.php b/resources/views/livewire/filament-form-user/entry.blade.php deleted file mode 100644 index 36e7478..0000000 --- a/resources/views/livewire/filament-form-user/entry.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -
-
-
-

Form Submission

- -
- @livewire('tapp.filament-form-builder.livewire.filament-form-user.show', ['entry' => $entry], key('entry-'.$entry->id)) -
-
-
-
diff --git a/resources/views/livewire/filament-form-user/show.blade.php b/resources/views/livewire/filament-form-user/show.blade.php index 9c2e7f8..94eda8c 100644 --- a/resources/views/livewire/filament-form-user/show.blade.php +++ b/resources/views/livewire/filament-form-user/show.blade.php @@ -1,5 +1,5 @@ -
-
+
+
{{ $this->entryInfoList }}
diff --git a/resources/views/livewire/filament-form/form.blade.php b/resources/views/livewire/filament-form/form.blade.php index 860441a..9f2c8d8 100644 --- a/resources/views/livewire/filament-form/form.blade.php +++ b/resources/views/livewire/filament-form/form.blade.php @@ -1,5 +1,5 @@
-
+
@livewire('tapp.filament-form-builder.livewire.filament-form.show', ['form' => $form], key('form-'.$form->id)) diff --git a/resources/views/livewire/filament-form/show.blade.php b/resources/views/livewire/filament-form/show.blade.php index aa74b7f..1b8c961 100644 --- a/resources/views/livewire/filament-form/show.blade.php +++ b/resources/views/livewire/filament-form/show.blade.php @@ -1,11 +1,14 @@ -
-
+
+

{{ $this->filamentForm->name }}

-

- {{ $this->filamentForm->description }} -

+ @if ($this->filamentForm->description) +
+ {{-- Description is admin-controlled rich text (HTML) --}} + {!! $this->filamentForm->description !!} +
+ @endif
@csrf {{ $this->form }} diff --git a/resources/views/mail/submission-notification.blade.php b/resources/views/mail/submission-notification.blade.php new file mode 100644 index 0000000..60c0382 --- /dev/null +++ b/resources/views/mail/submission-notification.blade.php @@ -0,0 +1,20 @@ + +# New Form Submission + +A new submission has been received for **{{ $form->name }}**. + +**Submitted by:** {{ $entry->user?->name ?? 'Guest' }} + +**Submitted at:** {{ $entry->created_at->format('F j, Y g:i A') }} + + +View Form & Entries + + +Thanks,
+{{ config('app.name') }} +
+ + + + diff --git a/resources/views/pages/show-entry-app.blade.php b/resources/views/pages/show-entry-app.blade.php new file mode 100644 index 0000000..c0e7eea --- /dev/null +++ b/resources/views/pages/show-entry-app.blade.php @@ -0,0 +1,3 @@ + + @livewire('tapp.filament-form-builder.livewire.filament-form-user.show', ['entry' => $entry], key('entry-'.$entry->id)) + diff --git a/resources/views/pages/show-entry-guest.blade.php b/resources/views/pages/show-entry-guest.blade.php new file mode 100644 index 0000000..6efcf89 --- /dev/null +++ b/resources/views/pages/show-entry-guest.blade.php @@ -0,0 +1,3 @@ + + @livewire('tapp.filament-form-builder.livewire.filament-form-user.show', ['entry' => $this->entry], key('entry-'.$this->entry->id)) + diff --git a/resources/views/pages/show-form-app.blade.php b/resources/views/pages/show-form-app.blade.php new file mode 100644 index 0000000..bbb2827 --- /dev/null +++ b/resources/views/pages/show-form-app.blade.php @@ -0,0 +1,3 @@ + + @livewire('tapp.filament-form-builder.livewire.filament-form.show', ['form' => $form], key('form-'.$form->id)) + diff --git a/resources/views/pages/show-form-guest.blade.php b/resources/views/pages/show-form-guest.blade.php new file mode 100644 index 0000000..4777548 --- /dev/null +++ b/resources/views/pages/show-form-guest.blade.php @@ -0,0 +1,3 @@ + + @livewire('tapp.filament-form-builder.livewire.filament-form.show', ['form' => $this->form], key('form-'.$this->form->id)) + diff --git a/routes/routes.php b/routes/routes.php deleted file mode 100644 index e25336e..0000000 --- a/routes/routes.php +++ /dev/null @@ -1,14 +0,0 @@ -middleware('web') - ->name('filament-form-users.show'); - -Route::get(config('filament-form-builder.filament-form-uri').'/{form}', FilamentForm::class) - ->middleware(['web', CheckFormGuestAccess::class]) - ->name('filament-form-builder.show'); diff --git a/src/Enums/FilamentFieldTypeEnum.php b/src/Enums/FilamentFieldTypeEnum.php index c63e44d..e4d53fb 100644 --- a/src/Enums/FilamentFieldTypeEnum.php +++ b/src/Enums/FilamentFieldTypeEnum.php @@ -13,6 +13,7 @@ enum FilamentFieldTypeEnum implements HasLabel case RICH_EDITOR; case TOGGLE; case CHECKBOX; + case CHECKBOX_LIST; case RADIO; case DATE_TIME_PICKER; case DATE_PICKER; @@ -49,6 +50,7 @@ public function fieldName(): string self::RICH_EDITOR => 'Rich Editor', self::TOGGLE => 'Toggle', self::CHECKBOX => 'Checkbox', + self::CHECKBOX_LIST => 'Checkbox List', self::RADIO => 'Radio', self::DATE_TIME_PICKER => 'DateTime Picker', self::DATE_PICKER => 'Date Picker', @@ -71,6 +73,7 @@ public function className(): string self::RICH_EDITOR => 'Filament\Forms\Components\RichEditor', self::TOGGLE => 'Filament\Forms\Components\Toggle', self::CHECKBOX => 'Filament\Forms\Components\Checkbox', + self::CHECKBOX_LIST => 'Filament\Forms\Components\CheckboxList', self::RADIO => 'Filament\Forms\Components\Radio', self::DATE_TIME_PICKER => 'Filament\Forms\Components\DateTimePicker', self::DATE_PICKER => 'Filament\Forms\Components\DatePicker', @@ -93,6 +96,7 @@ public function hasOptions(): bool self::RICH_EDITOR => false, self::TOGGLE => false, self::CHECKBOX => false, + self::CHECKBOX_LIST => true, self::RADIO => true, self::DATE_TIME_PICKER => false, self::DATE_PICKER => false, @@ -115,6 +119,7 @@ public function isBool(): bool self::RICH_EDITOR => false, self::TOGGLE => true, self::CHECKBOX => true, + self::CHECKBOX_LIST => false, self::RADIO => false, self::DATE_TIME_PICKER => false, self::DATE_PICKER => false, diff --git a/src/Filament/Pages/ShowEntry.php b/src/Filament/Pages/ShowEntry.php new file mode 100644 index 0000000..a887520 --- /dev/null +++ b/src/Filament/Pages/ShowEntry.php @@ -0,0 +1,95 @@ +check() && $this->getPanel()->getId() === config('filament-form-builder.app-panel-id', 'app')) { + return 'filament-form-builder::pages.show-entry-app'; + } + + return 'filament-form-builder::pages.show-entry-guest'; + } + + public static function getRouteName(?Panel $panel = null): string + { + return 'filament-form-users.show'; + } + + public static function shouldRegisterNavigation(): bool + { + return false; + } + + public function getPanel(): Panel + { + // Use the current panel set by middleware (app for authenticated, guest for unauthenticated) + $guestPanelId = config('filament-form-builder.guest-panel-id', 'guest'); + $appPanelId = config('filament-form-builder.app-panel-id', 'app'); + + $currentPanel = Filament::getCurrentPanel(); + + if ($currentPanel) { + return $currentPanel; + } + + // Fallback to guest panel if no current panel + return Filament::getPanel($guestPanelId); + } + + public function mount(FilamentFormUser $entry): void + { + $loginRoute = config('filament-form-builder.login-route', 'filament.app.auth.login'); + + // For guest entries, require a valid signed URL + if ($entry->user_id === null) { + if (! request()->hasValidSignature()) { + abort(403, 'This link has expired or is invalid.'); + } + } else { + // For authenticated user entries: use policy if registered, otherwise only allow submitter + if (auth()->check()) { + $user = auth()->user(); + $policy = policy($entry); + if ($policy && method_exists($policy, 'view')) { + if (! $user->can('view', $entry)) { + abort(403, 'You do not have permission to view this form submission.'); + } + } elseif ($entry->user_id !== $user->id) { + abort(403, 'You can only view your own form submissions.'); + } + } else { + // Not authenticated and entry has a user - redirect to login + $this->redirect(route($loginRoute, [ + 'redirect' => request()->fullUrl(), + ]), navigate: false); + + return; + } + } + + $this->entry = $entry->load('user', 'filamentForm'); + } + + public function getTitle(): string + { + return 'Form Submission'; + } +} diff --git a/src/Filament/Pages/ShowForm.php b/src/Filament/Pages/ShowForm.php new file mode 100644 index 0000000..915ea6a --- /dev/null +++ b/src/Filament/Pages/ShowForm.php @@ -0,0 +1,78 @@ +check() && $this->getPanel()->getId() === config('filament-form-builder.app-panel-id', 'app')) { + return 'filament-form-builder::pages.show-form-app'; + } + + return 'filament-form-builder::pages.show-form-guest'; + } + + public static function getRouteName(?Panel $panel = null): string + { + return 'filament-form-builder.show'; + } + + public static function shouldRegisterNavigation(): bool + { + return false; + } + + public function getPanel(): Panel + { + // Use the current panel set by middleware (app for authenticated, guest for unauthenticated) + $guestPanelId = config('filament-form-builder.guest-panel-id', 'guest'); + $appPanelId = config('filament-form-builder.app-panel-id', 'app'); + + $currentPanel = Filament::getCurrentPanel(); + + if ($currentPanel) { + return $currentPanel; + } + + // Fallback to guest panel if no current panel + return Filament::getPanel($guestPanelId); + } + + public function mount(FilamentForm $form): void + { + // If form doesn't permit guest entries and user is not authenticated, redirect to login + if (! auth()->check() && ! $form->permit_guest_entries) { + $loginRoute = config('filament-form-builder.login-route', 'filament.app.auth.login'); + + $this->redirect(route($loginRoute, [ + 'redirect' => request()->fullUrl(), + ]), navigate: false); + + return; + } + + // Show form (middleware sets the appropriate panel based on authentication) + $this->form = $form->load('filamentFormFields'); + } + + public function getTitle(): string + { + return $this->form->name ?? 'Form'; + } +} diff --git a/src/Filament/Resources/FilamentFormResource.php b/src/Filament/Resources/FilamentFormResource.php index 48f70a3..5d2d783 100644 --- a/src/Filament/Resources/FilamentFormResource.php +++ b/src/Filament/Resources/FilamentFormResource.php @@ -2,18 +2,26 @@ namespace Tapp\FilamentFormBuilder\Filament\Resources; -use Filament\Forms; +use Filament\Actions\Action; +use Filament\Actions\ActionGroup; +use Filament\Actions\BulkActionGroup; +use Filament\Actions\DeleteBulkAction; +use Filament\Actions\EditAction; +use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TagsInput; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Forms\Form; use Filament\Notifications\Notification; use Filament\Resources\Resource; -use Filament\Tables\Actions\Action; -use Filament\Tables\Actions\BulkActionGroup; -use Filament\Tables\Actions\DeleteBulkAction; -use Filament\Tables\Actions\EditAction; +use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Enums\RecordActionsPosition; use Filament\Tables\Table; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Redirect; use Tapp\FilamentFormBuilder\Filament\Resources\FilamentFormResource\Pages\CreateFilamentForm; use Tapp\FilamentFormBuilder\Filament\Resources\FilamentFormResource\Pages\EditFilamentForm; @@ -29,6 +37,26 @@ class FilamentFormResource extends Resource protected static ?int $navigationSort = 99; + /** + * Check if this resource should be scoped to a tenant. + */ + public static function isScopedToTenant(): bool + { + return config('filament-form-builder.tenancy.enabled', false); + } + + /** + * Get the tenant ownership relationship name. + */ + public static function getTenantOwnershipRelationshipName(): string + { + if (! config('filament-form-builder.tenancy.enabled')) { + return 'tenant'; + } + + return FilamentForm::getTenantRelationshipName(); + } + public static function getBreadcrumb(): string { return config('filament-form-builder.admin-panel-resource-name-plural'); @@ -54,19 +82,32 @@ public static function getNavigationSort(): ?int return config('filament-form-builder.admin-panel-sort-order'); } - public static function form(Form $form): Form + public static function form(Schema $schema): Schema { - return $form - ->schema([ - Forms\Components\TextInput::make('name') + return $schema + ->components([ + TextInput::make('name') ->required() ->maxLength(255), + TextInput::make('redirect_url') + ->hint('(optional) complete this field to provide a custom redirect url on form completion. Use a fully qualified URL including "https://" to redirect to an external link, otherwise url will be relative to this sites domain'), Toggle::make('permit_guest_entries') ->hint('Permit non registered users to submit this form'), - Forms\Components\TextInput::make('redirect_url') - ->hint('(optional) complete this field to provide a custom redirect url on form completion. Use a fully qualified URL including "https://" to redirect to an external link, otherwise url will be relative to this sites domain'), - Forms\Components\Textarea::make('description') + Toggle::make('private_entries') + ->hint('When enabled, entries for this form can be restricted to certain users (e.g. via a gate in your application).') + ->disabled(fn (?FilamentForm $record): bool => static::userCannotChangePrivateEntries($record)) + ->dehydrateStateUsing(fn ($state, ?FilamentForm $record): bool => static::userCannotChangePrivateEntries($record) && $record + ? (bool) $record->private_entries + : (bool) $state), + RichEditor::make('description') ->columnSpanFull(), + Section::make('Notifications') + ->description('Configure email notifications for form submissions') + ->schema([ + static::getNotificationEmailsField(), + ]) + ->collapsible() + ->collapsed(), ]); } @@ -95,6 +136,9 @@ public static function table(Table $table): Table return (bool) $record->permit_guest_entries; }) ->boolean(), + IconColumn::make('private_entries') + ->sortable() + ->boolean(), IconColumn::make('locked') ->sortable() ->boolean(), @@ -102,44 +146,50 @@ public static function table(Table $table): Table ->filters([ // ]) - ->actions([ - EditAction::make(), - Action::make('preview') - ->visible(fn () => (bool) config('filament-form-builder.preview-route')) - ->url(fn ($record) => route(config('filament-form-builder.preview-route'), ['form' => $record->id])) - ->openUrlInNewTab(), - Action::make('copy') - ->action(function ($record) { - $formCopy = FilamentForm::create([ - 'name' => $record->name.' - (Copy)', - 'permit_guest_entries' => $record->permit_guest_entries, - 'redirect_url' => $record->redirect_url, - 'description' => $record->description, - ]); - - $record->filamentFormFields->each(function ($field) use ($formCopy) { - FilamentFormField::create([ - 'filament_form_id' => $formCopy->id, - 'label' => $field->label, - 'type' => $field->type, - 'required' => $field->required, - 'order' => $field->order, - 'hint' => $field->hint, - 'options' => $field->options, - 'rules' => $field->rules, + ->recordActions([ + ActionGroup::make([ + EditAction::make(), + Action::make('preview') + ->visible(fn () => (bool) config('filament-form-builder.preview-route')) + ->url(fn ($record) => route(config('filament-form-builder.preview-route'), ['form' => $record->id])) + ->openUrlInNewTab(), + Action::make('copy') + ->visible(fn (): bool => static::canCreate()) + ->authorize(fn (): bool => static::canCreate()) + ->action(function ($record) { + $formCopy = FilamentForm::create([ + 'name' => $record->name.' - (Copy)', + 'permit_guest_entries' => $record->permit_guest_entries, + 'private_entries' => $record->private_entries, + 'redirect_url' => $record->redirect_url, + 'description' => $record->description, + 'notification_emails' => $record->notification_emails, ]); - }); - Notification::make() - ->title('Form copied successfully') - ->body('Please change the name of the form to something unique and remove the "(Copy)" suffix') - ->success() - ->send(); + $record->filamentFormFields->each(function ($field) use ($formCopy) { + FilamentFormField::create([ + 'filament_form_id' => $formCopy->id, + 'label' => $field->label, + 'type' => $field->type, + 'required' => $field->required, + 'order' => $field->order, + 'hint' => $field->hint, + 'options' => $field->options, + 'rules' => $field->rules, + ]); + }); - return Redirect::to('/admin/filament-forms/'.$formCopy->id.'/edit'); - }), - ]) - ->bulkActions([ + Notification::make() + ->title('Form copied successfully') + ->body('Please change the name of the form to something unique and remove the "(Copy)" suffix') + ->success() + ->send(); + + return Redirect::to('/admin/filament-forms/'.$formCopy->id.'/edit'); + }), + ]), + ], position: RecordActionsPosition::BeforeColumns) + ->toolbarActions([ BulkActionGroup::make([ DeleteBulkAction::make(), ]), @@ -163,4 +213,63 @@ public static function getPages(): array 'edit' => EditFilamentForm::route('/{record}/edit'), ]; } + + /** + * True when the current user must not be allowed to change the private_entries toggle. + * Used when the form is private and the app's viewEntries policy denies the user. + */ + protected static function userCannotChangePrivateEntries(?FilamentForm $record): bool + { + if (! $record || ! $record->exists || ! (bool) $record->private_entries) { + return false; + } + + $user = Auth::user(); + if (! $user) { + return true; + } + + $policy = policy($record); + if ($policy && method_exists($policy, 'viewEntries')) { + return ! $user->can('viewEntries', $record); + } + + return false; + } + + protected static function getNotificationEmailsField(): Component + { + $userModel = config('filament-form-builder.user_model'); + + // If user model is configured, use Select with user search + if ($userModel && class_exists($userModel)) { + return Select::make('notification_emails') + ->label('Notification Recipients') + ->helperText('Select users who should receive notifications when this form is submitted.') + ->multiple() + ->searchable() + ->getSearchResultsUsing(function ($search) use ($userModel) { + return $userModel::query() + ->where(function ($query) use ($search) { + $query->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }) + ->limit(50) + ->get() + ->mapWithKeys(fn ($user) => [$user->email => $user->name.' ('.$user->email.')']); + }) + ->getOptionLabelsUsing(function (array $values) use ($userModel): array { + return $userModel::whereIn('email', $values) + ->get() + ->mapWithKeys(fn ($user) => [$user->email => $user->name.' ('.$user->email.')']) + ->toArray(); + }); + } + + // Default: TagsInput for manual email entry + return TagsInput::make('notification_emails') + ->label('Notification Email Addresses') + ->helperText('Enter email addresses that should receive notifications when this form is submitted. Press Enter after each email.') + ->placeholder('email@example.com'); + } } diff --git a/src/Filament/Resources/FilamentFormResource/Pages/EditFilamentForm.php b/src/Filament/Resources/FilamentFormResource/Pages/EditFilamentForm.php index 733aff0..35d1da2 100644 --- a/src/Filament/Resources/FilamentFormResource/Pages/EditFilamentForm.php +++ b/src/Filament/Resources/FilamentFormResource/Pages/EditFilamentForm.php @@ -2,7 +2,8 @@ namespace Tapp\FilamentFormBuilder\Filament\Resources\FilamentFormResource\Pages; -use Filament\Actions; +use Filament\Actions\Action; +use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; use Tapp\FilamentFormBuilder\Filament\Resources\FilamentFormResource; @@ -18,8 +19,8 @@ public function getTitle(): string protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), - Actions\Action::make('preview') + DeleteAction::make(), + Action::make('preview') ->visible(fn () => (bool) config('filament-form-builder.preview-route')) ->url(fn ($record) => route(config('filament-form-builder.preview-route'), ['form' => $record->id])) ->openUrlInNewTab(), diff --git a/src/Filament/Resources/FilamentFormResource/Pages/ListFilamentForms.php b/src/Filament/Resources/FilamentFormResource/Pages/ListFilamentForms.php index 3a52eed..561094e 100644 --- a/src/Filament/Resources/FilamentFormResource/Pages/ListFilamentForms.php +++ b/src/Filament/Resources/FilamentFormResource/Pages/ListFilamentForms.php @@ -2,7 +2,7 @@ namespace Tapp\FilamentFormBuilder\Filament\Resources\FilamentFormResource\Pages; -use Filament\Actions; +use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; use Tapp\FilamentFormBuilder\Filament\Resources\FilamentFormResource; @@ -18,7 +18,7 @@ public function getTitle(): string protected function getHeaderActions(): array { return [ - Actions\CreateAction::make() + CreateAction::make() ->label('Create '.config('filament-form-builder.admin-panel-resource-name')), ]; } diff --git a/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormFieldsRelationManager.php b/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormFieldsRelationManager.php index e00b136..2864edd 100644 --- a/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormFieldsRelationManager.php +++ b/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormFieldsRelationManager.php @@ -2,19 +2,25 @@ namespace Tapp\FilamentFormBuilder\Filament\Resources\FilamentFormResource\RelationManagers; +use Filament\Actions\Action; +use Filament\Actions\ActionGroup; +use Filament\Actions\BulkActionGroup; +use Filament\Actions\CreateAction; +use Filament\Actions\DeleteAction; +use Filament\Actions\DeleteBulkAction; +use Filament\Actions\EditAction; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Forms\Form; -use Filament\Forms\Get; use Filament\Resources\RelationManagers\RelationManager; -use Filament\Tables; -use Filament\Tables\Actions\Action; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Enums\RecordActionsPosition; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Model; use Tapp\FilamentFormBuilder\Enums\FilamentFieldTypeEnum; @@ -28,10 +34,10 @@ public static function getTitle(Model $ownerRecord, string $pageClass): string return __(config('filament-form-builder.admin-panel-filament-form-field-name-plural')); } - public function form(Form $form): Form + public function form(Schema $schema): Schema { - return $form - ->schema([ + return $schema + ->components([ Select::make('type') ->options(function () { return collect(FilamentFieldTypeEnum::cases()) @@ -39,11 +45,17 @@ public function form(Form $form): Form ->sortBy(fn ($label, $key) => $label) ->toArray(); }) + ->columnSpan(function ($state) { + if (! empty($state) && FilamentFieldTypeEnum::fromString($state)->hasOptions()) { + return 1; + } + + return 2; + }) ->required() ->live(), - TextInput::make('label') + Textarea::make('label') ->required() - ->maxLength(255) ->label(function (Get $get) { return $get('type') === FilamentFieldTypeEnum::HEADING->name ? 'Heading' : 'Label'; }), @@ -61,13 +73,13 @@ public function form(Form $form): Form ->label(function (Get $get) { return $get('type') === FilamentFieldTypeEnum::HEADING->name ? 'Subheading' : 'Hint'; }), - TagsInput::make('rules') - ->placeholder('Add rules') - ->hint('view list of available rules here, https://laravel.com/docs/11.x/validation#available-validation-rules') - ->visible(function (Get $get) { - return $get('type') !== FilamentFieldTypeEnum::REPEATER->name - && $get('type') !== FilamentFieldTypeEnum::HEADING->name; - }), + // TagsInput::make('rules') + // ->placeholder('Add rules') + // ->hint('view list of available rules here, https://laravel.com/docs/11.x/validation#available-validation-rules') + // ->visible(function (Get $get) { + // return $get('type') !== FilamentFieldTypeEnum::REPEATER->name + // && $get('type') !== FilamentFieldTypeEnum::HEADING->name; + // }), TextInput::make('order') ->default(function () { return $this->getOwnerRecord()->filamentFormFields()->count() + 1; @@ -81,9 +93,8 @@ public function form(Form $form): Form Repeater::make('schema') ->label('Fields') ->schema([ - TextInput::make('label') - ->required() - ->maxLength(255), + Textarea::make('label') + ->required(), Select::make('type') ->options(function () { $options = collect(FilamentFieldTypeEnum::cases()) @@ -106,9 +117,9 @@ public function form(Form $form): Form return false; }), Textarea::make('hint'), - TagsInput::make('rules') - ->placeholder('Add rules') - ->hint('view list of available rules here, https://laravel.com/docs/11.x/validation#available-validation-rules'), + // TagsInput::make('rules') + // ->placeholder('Add rules') + // ->hint('view list of available rules here, https://laravel.com/docs/11.x/validation#available-validation-rules'), Toggle::make('required'), ]) ->columns(2) @@ -145,7 +156,7 @@ public function table(Table $table): Table ->boolean(), ]) ->headerActions([ - Tables\Actions\CreateAction::make() + CreateAction::make() ->visible(function () use ($form) { return ! $form->locked; }) @@ -173,19 +184,21 @@ public function table(Table $table): Table ]); }), ]) - ->actions([ - Tables\Actions\EditAction::make() - ->visible(function () use ($form) { - return ! $form->locked; - }), - Tables\Actions\DeleteAction::make() - ->visible(function () use ($form) { - return ! $form->locked; - }), - ]) - ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make() + ->recordActions([ + ActionGroup::make([ + EditAction::make() + ->visible(function () use ($form) { + return ! $form->locked; + }), + DeleteAction::make() + ->visible(function () use ($form) { + return ! $form->locked; + }), + ]), + ], position: RecordActionsPosition::BeforeColumns) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make() ->visible(function () use ($form) { return ! $form->locked; }), diff --git a/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormUsersRelationManager.php b/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormUsersRelationManager.php index b83d4ce..7906c68 100644 --- a/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormUsersRelationManager.php +++ b/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormUsersRelationManager.php @@ -2,16 +2,22 @@ namespace Tapp\FilamentFormBuilder\Filament\Resources\FilamentFormResource\RelationManagers; -use Filament\Forms; -use Filament\Forms\Form; +use Filament\Actions\ActionGroup; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; +use Filament\Actions\DeleteAction; +use Filament\Actions\DeleteBulkAction; +use Filament\Forms\Components\TextInput; use Filament\Resources\RelationManagers\RelationManager; -use Filament\Tables; -use Filament\Tables\Actions\BulkAction; +use Filament\Schemas\Schema; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Enums\RecordActionsPosition; use Filament\Tables\Filters\Filter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Auth; use Maatwebsite\Excel\Facades\Excel; use Tapp\FilamentFormBuilder\Exports\FilamentFormUsersExport; @@ -19,6 +25,39 @@ class FilamentFormUsersRelationManager extends RelationManager { protected static string $relationship = 'filamentFormUsers'; + /** + * Apps may restrict visibility of the Entries relation manager per form by defining + * a viewEntries($user, $form) method on the FilamentForm (owner) policy. When present, + * that policy is used; otherwise the default (related model viewAny) applies. + */ + public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool + { + if (! parent::canViewForRecord($ownerRecord, $pageClass)) { + return false; + } + + $user = Auth::user(); + if (! $user) { + return false; + } + + return self::userCanViewEntriesForOwner($user, $ownerRecord); + } + + /** + * Whether the given user can view/export entries for the given owner form. + * Uses the owner model's viewEntries policy when present. + */ + protected static function userCanViewEntriesForOwner(\Illuminate\Contracts\Auth\Authenticatable&\Illuminate\Contracts\Auth\Access\Authorizable $user, Model $owner): bool + { + $policy = policy($owner); + if ($policy && method_exists($policy, 'viewEntries')) { + return $user->can('viewEntries', $owner); + } + + return true; + } + public static function getTitle(Model $ownerRecord, string $pageClass): string { return __(config('filament-form-builder.admin-panel-filament-form-user-name-plural')); @@ -29,11 +68,11 @@ public static function getLabel(): string return 'Custom Posts Title'; } - public function form(Form $form): Form + public function form(Schema $schema): Schema { - return $form - ->schema([ - Forms\Components\TextInput::make('filamentFormUser.user.name') + return $schema + ->components([ + TextInput::make('filamentFormUser.user.name') ->required() ->maxLength(255), ]); @@ -45,12 +84,12 @@ public function table(Table $table): Table ->recordTitleAttribute('user.name') ->heading(config('filament-form-builder.admin-panel-filament-form-user-name-plural')) ->columns([ - Tables\Columns\TextColumn::make('user.name') + TextColumn::make('user.name') ->sortable() ->searchable(), - Tables\Columns\TextColumn::make('created_at') + TextColumn::make('created_at') ->sortable(), - Tables\Columns\TextColumn::make('updated_at') + TextColumn::make('updated_at') ->sortable(), ]) ->recordUrl(fn ($record) => route(config('filament-form-builder.filament-form-user-show-route'), $record)) @@ -62,20 +101,38 @@ public function table(Table $table): Table ]) ->headerActions([ ]) - ->actions([ - Tables\Actions\DeleteAction::make(), - ]) - ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + ->recordActions([ + ActionGroup::make([ + DeleteAction::make(), + ]), + ], position: RecordActionsPosition::BeforeColumns) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), BulkAction::make('Export Selected') ->action(fn (Collection $records) => Excel::download( new FilamentFormUsersExport($records), urlencode($this->getOwnerRecord()->name).'_form_entry_export'.now()->format('Y-m-dhis').'.csv') ) ->icon('heroicon-o-document-chart-bar') - ->deselectRecordsAfterCompletion(), + ->deselectRecordsAfterCompletion() + ->visible(fn (): bool => $this->canViewEntriesForOwner()), ]), ]); } + + /** + * Whether the current user can view/export entries for the owner form. + * Uses the owner model's viewEntries policy when present. + */ + protected function canViewEntriesForOwner(): bool + { + $user = Auth::user(); + if (! $user) { + return false; + } + + /** @var \Illuminate\Contracts\Auth\Authenticatable&\Illuminate\Contracts\Auth\Access\Authorizable $user */ + return self::userCanViewEntriesForOwner($user, $this->getOwnerRecord()); + } } diff --git a/src/FilamentFormBuilderFrontendPlugin.php b/src/FilamentFormBuilderFrontendPlugin.php new file mode 100644 index 0000000..0ccee88 --- /dev/null +++ b/src/FilamentFormBuilderFrontendPlugin.php @@ -0,0 +1,45 @@ +pages([ + $formPageClass, + $entryPageClass, + ]); + } + + public function boot(Panel $panel): void + { + // Entry route is registered in FilamentFormBuilderServiceProvider with SetFormPanel middleware + } +} diff --git a/src/FilamentFormBuilderGuestPlugin.php b/src/FilamentFormBuilderGuestPlugin.php new file mode 100644 index 0000000..c753792 --- /dev/null +++ b/src/FilamentFormBuilderGuestPlugin.php @@ -0,0 +1,46 @@ +pages([ + $formPageClass, + $entryPageClass, + ]); + } + + public function boot(Panel $panel): void + { + // Entry route is registered in FilamentFormBuilderServiceProvider with SetFormPanel middleware + } +} diff --git a/src/FilamentFormBuilderServiceProvider.php b/src/FilamentFormBuilderServiceProvider.php index 18ccf22..c626890 100644 --- a/src/FilamentFormBuilderServiceProvider.php +++ b/src/FilamentFormBuilderServiceProvider.php @@ -2,13 +2,15 @@ namespace Tapp\FilamentFormBuilder; +use Illuminate\Support\Facades\Route; use Livewire\Livewire; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; use Tapp\FilamentFormBuilder\Livewire\FilamentForm\Form as FilamentForm; use Tapp\FilamentFormBuilder\Livewire\FilamentForm\Show as FilamentFormShow; -use Tapp\FilamentFormBuilder\Livewire\FilamentFormUser\Entry as FilamentFormUserEntry; use Tapp\FilamentFormBuilder\Livewire\FilamentFormUser\Show as FilamentFormUserShow; +use Tapp\FilamentFormBuilder\Models\FilamentFormUser; +use Tapp\FilamentFormBuilder\Observers\FilamentFormUserObserver; class FilamentFormBuilderServiceProvider extends PackageServiceProvider { @@ -23,8 +25,10 @@ public function configurePackage(Package $package): void $package->name('filament-form-builder') ->hasMigration('create_dynamic_filament_form_tables') ->hasMigration('add_schema_to_filament_form_fields') + ->hasMigration('add_notification_emails_to_filament_forms_table') + ->hasMigration('change_label_to_text_in_filament_form_fields') + ->hasMigration('add_private_entries_to_filament_forms_table') ->hasConfigFile('filament-form-builder') - ->hasRoute('routes') ->hasViews('filament-form-builder'); } @@ -38,7 +42,38 @@ public function boot() // Register the new layout components Livewire::component('tapp.filament-form-builder.livewire.filament-form.form', FilamentForm::class); - Livewire::component('tapp.filament-form-builder.livewire.filament-form-user.entry', FilamentFormUserEntry::class); + + // Register observer for form submission notifications + FilamentFormUser::observe(FilamentFormUserObserver::class); + + // Register the form route globally so it's available when the model accesses it + // The SetFormPanel middleware ensures the correct panel context is set based on authentication + $middlewareClass = config('filament-form-builder.set-form-panel-middleware-class'); + + // Use package default middleware if not configured + if (! $middlewareClass) { + $middlewareClass = \Tapp\FilamentFormBuilder\Http\Middleware\SetFormPanel::class; + } + + $middleware = ['web', $middlewareClass]; + + // Get the page classes (use package defaults if not configured) + $formPageClass = config('filament-form-builder.guest-panel-form-page-class') + ?? \Tapp\FilamentFormBuilder\Filament\Pages\ShowForm::class; + $entryPageClass = config('filament-form-builder.guest-panel-entry-page-class') + ?? \Tapp\FilamentFormBuilder\Filament\Pages\ShowEntry::class; + + Route::middleware($middleware)->group(function () use ($formPageClass, $entryPageClass) { + Route::get( + config('filament-form-builder.filament-form-uri').'/{form}', + $formPageClass + )->name('filament-form-builder.show'); + + Route::get( + config('filament-form-builder.filament-form-user-uri').'/{entry}', + $entryPageClass + )->name('filament-form-users.show'); + }); } public function packageBooted(): void diff --git a/src/Http/Middleware/SetFormPanel.php b/src/Http/Middleware/SetFormPanel.php new file mode 100644 index 0000000..8df64e3 --- /dev/null +++ b/src/Http/Middleware/SetFormPanel.php @@ -0,0 +1,37 @@ +check() ? $appPanelId : $guestPanelId; + } + + // Use SetUpPanel middleware to properly initialize the panel + // This ensures the panel's layout and middleware are applied correctly + $setUpPanel = new SetUpPanel; + + return $setUpPanel->handle($request, function ($request) use ($next) { + return $next($request); + }, $panel); + } +} diff --git a/src/Livewire/FilamentForm/Form.php b/src/Livewire/FilamentForm/Form.php index 8b2e7a1..265c0c7 100644 --- a/src/Livewire/FilamentForm/Form.php +++ b/src/Livewire/FilamentForm/Form.php @@ -2,9 +2,11 @@ namespace Tapp\FilamentFormBuilder\Livewire\FilamentForm; +use Livewire\Attributes\Layout; use Livewire\Component; use Tapp\FilamentFormBuilder\Models\FilamentForm; +#[Layout('components.layouts.app')] class Form extends Component { public FilamentForm $form; diff --git a/src/Livewire/FilamentForm/Show.php b/src/Livewire/FilamentForm/Show.php index e5203f9..9427d87 100644 --- a/src/Livewire/FilamentForm/Show.php +++ b/src/Livewire/FilamentForm/Show.php @@ -5,17 +5,20 @@ use Filament\Forms\Components\Field; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; -use Filament\Forms\Form; +use Filament\Schemas\Schema; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\HtmlString; use Livewire\Component; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\WithFileUploads; use Tapp\FilamentFormBuilder\Enums\FilamentFieldTypeEnum; use Tapp\FilamentFormBuilder\Events\EntrySaved; use Tapp\FilamentFormBuilder\Models\FilamentForm; +use Tapp\FilamentFormBuilder\Models\FilamentFormField; use Tapp\FilamentFormBuilder\Models\FilamentFormUser; /** - * @property Form $form + * @property Schema $form */ class Show extends Component implements HasForms { @@ -41,18 +44,18 @@ public function mount(FilamentForm $form, bool $blockRedirect = false, bool $pre $this->blockRedirect = $blockRedirect; } - public function form(Form $form): Form + public function form(Schema $schema): Schema { - return $form - ->schema($this->getSchema()) + return $schema + ->components($this->getFormSchema()) ->statePath('data'); } - public function getSchema(): array + public function getFormSchema(): array { $schema = []; - /** @var \Tapp\FilamentFormBuilder\Models\FilamentFormField $fieldData */ + /** @var FilamentFormField $fieldData */ foreach ($this->filamentForm->filamentFormFields as $fieldData) { $filamentField = $fieldData->type->className()::make($fieldData->id); @@ -61,10 +64,15 @@ public function getSchema(): array if ($fieldData->type === FilamentFieldTypeEnum::SELECT_MULTIPLE) { $filamentField = $filamentField ->multiple() - // !!! remove this before deploy ->live() ->required() ->default([]); + } elseif ($fieldData->type === FilamentFieldTypeEnum::CHECKBOX) { + $filamentField = $filamentField + ->default(false); + } elseif ($fieldData->type === FilamentFieldTypeEnum::CHECKBOX_LIST) { + $filamentField = $filamentField + ->default([]); } elseif ($fieldData->type === FilamentFieldTypeEnum::REPEATER) { $filamentField = $filamentField ->schema(function () use ($fieldData) { @@ -74,7 +82,7 @@ public function getSchema(): array $subFieldComponent = FilamentFieldTypeEnum::fromString($subField['type'])->className()::make($subFieldId); if (isset($subField['label'])) { - $subFieldComponent = $subFieldComponent->label($subField['label']); + $subFieldComponent = $subFieldComponent->label(new HtmlString($subField['label'])); } if (isset($subField['required']) && $subField['required']) { @@ -102,6 +110,10 @@ public function getSchema(): array ->live(); } + if ($fieldData->type === FilamentFieldTypeEnum::RICH_EDITOR) { + $filamentField = $filamentField->disableToolbarButtons(['attachFiles']); + } + array_push($schema, $filamentField); } @@ -112,7 +124,7 @@ public function parseField(Field $filamentField, array $fieldData): Field { if (isset($fieldData['label'])) { $filamentField = $filamentField - ->label($fieldData['label']); + ->label(new HtmlString($fieldData['label'])); } if (isset($fieldData['required']) && $fieldData['required']) { @@ -122,7 +134,7 @@ public function parseField(Field $filamentField, array $fieldData): Field if (isset($fieldData['options'])) { $filamentField = $filamentField - ->options(array_combine($fieldData['options'], $fieldData['options'])); + ->options($fieldData['options']); } if (isset($fieldData['hint'])) { @@ -149,7 +161,7 @@ public function create() $entry = []; foreach ($formState as $key => $value) { - /** @var \Tapp\FilamentFormBuilder\Models\FilamentFormField|null $field */ + /** @var FilamentFormField|null $field */ $field = $this->filamentForm ->filamentFormFields ->find($key); @@ -222,14 +234,14 @@ public function create() // Handle file uploads foreach ($this->filamentForm->filamentFormFields as $field) { - /** @var \Tapp\FilamentFormBuilder\Models\FilamentFormField $field */ + /** @var FilamentFormField $field */ if ($field->type === FilamentFieldTypeEnum::FILE_UPLOAD) { $fileKey = $field->id; $fileData = $this->data[$fileKey] ?? null; if ($fileData && is_array($fileData)) { $temporaryFile = collect($fileData)->first(); - if ($temporaryFile instanceof \Livewire\Features\SupportFileUploads\TemporaryUploadedFile) { + if ($temporaryFile instanceof TemporaryUploadedFile) { // Remove existing media with the same field_id $entryModel->getMedia() ->filter(fn ($media) => $media->getCustomProperty('field_id') === $field->id) @@ -258,12 +270,24 @@ public function create() if ($this->filamentForm->redirect_url) { return redirect($this->filamentForm->redirect_url); } else { + // For guest submissions, use a signed temporary URL + // For authenticated users, use a regular route (policy will handle authorization) + if ($entryModel->user_id === null) { + return redirect()->to( + \Illuminate\Support\Facades\URL::temporarySignedRoute( + config('filament-form-builder.filament-form-user-show-route'), + now()->addDays(7), // Link expires in 7 days + ['entry' => $entryModel->id] + ) + ); + } + return redirect() ->route(config('filament-form-builder.filament-form-user-show-route'), $entryModel); } } - public function parseValue(\Tapp\FilamentFormBuilder\Models\FilamentFormField $field, string|array|null $value): string|array + public function parseValue(FilamentFormField $field, string|array|null $value): string|array { if ($value === null && ! $field->type->isBool()) { return ''; @@ -276,9 +300,9 @@ public function parseValue(\Tapp\FilamentFormBuilder\Models\FilamentFormField $f $valueData = ''; if ($field->type->hasOptions() && is_array($value)) { - $valueData = implode(', ', $value); + $valueData = $this->extractMultiSelectValue($value, $field->options); } elseif ($field->type->hasOptions() && ! is_array($value)) { - $valueData = $value; + $valueData = $field->options[$value]; } elseif ($field->type->isBool()) { $valueData = (bool) $value ? 'true' : 'false'; } else { @@ -288,6 +312,15 @@ public function parseValue(\Tapp\FilamentFormBuilder\Models\FilamentFormField $f return $valueData; } + public static function extractMultiSelectValue(array $value, array $options): string + { + $valuesArray = array_map(function ($value) use ($options) { + return $options[$value]; + }, $value); + + return implode(', ', $valuesArray); + } + public function render() { /** @phpstan-ignore-next-line */ diff --git a/src/Livewire/FilamentFormUser/Entry.php b/src/Livewire/FilamentFormUser/Entry.php deleted file mode 100644 index b1e4f99..0000000 --- a/src/Livewire/FilamentFormUser/Entry.php +++ /dev/null @@ -1,22 +0,0 @@ -entry = $entry; - } - - public function render() - { - /** @phpstan-ignore-next-line */ - return view('filament-form-builder::livewire.filament-form-user.entry'); - } -} diff --git a/src/Livewire/FilamentFormUser/Show.php b/src/Livewire/FilamentFormUser/Show.php index 994736f..e46e3f3 100644 --- a/src/Livewire/FilamentFormUser/Show.php +++ b/src/Livewire/FilamentFormUser/Show.php @@ -2,19 +2,23 @@ namespace Tapp\FilamentFormBuilder\Livewire\FilamentFormUser; +use Filament\Actions\Action; +use Filament\Actions\Concerns\InteractsWithActions; +use Filament\Actions\Contracts\HasActions; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; -use Filament\Infolists\Components\Actions\Action as InfolistAction; use Filament\Infolists\Components\KeyValueEntry; +use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Concerns\InteractsWithInfolists; use Filament\Infolists\Contracts\HasInfolists; -use Filament\Infolists\Infolist; +use Filament\Schemas\Schema; use Livewire\Component; use Tapp\FilamentFormBuilder\Models\FilamentFormUser; -class Show extends Component implements HasForms, HasInfolists +class Show extends Component implements HasActions, HasForms, HasInfolists { + use InteractsWithActions; use InteractsWithForms; use InteractsWithInfolists; @@ -25,12 +29,14 @@ public function mount(FilamentFormUser $entry): void $this->entry = $entry->load('user', 'filamentForm'); } - public function entryInfoList(Infolist $infolist): Infolist + public function entryInfoList(Schema $schema): Schema { - return $infolist + return $schema ->record($this->entry) ->schema([ - TextEntry::make('user.name'), + TextEntry::make('user.name') + ->label('Name') + ->visible(fn () => $this->entry->user_id !== null), TextEntry::make('filamentForm.name') ->label('Form Name'), TextEntry::make('created_at') @@ -40,7 +46,7 @@ public function entryInfoList(Infolist $infolist): Infolist ->label('Form Entry') ->keyLabel('Question') ->valueLabel('Answer'), - \Filament\Infolists\Components\RepeatableEntry::make('media') + RepeatableEntry::make('media') ->label('Uploaded Files') ->schema([ TextEntry::make('custom_properties.field_label') @@ -48,7 +54,7 @@ public function entryInfoList(Infolist $infolist): Infolist TextEntry::make('custom_properties.original_name') ->label('File Name') ->suffixAction( - InfolistAction::make('download') + Action::make('download') ->icon('heroicon-o-arrow-down-tray') ->action(function ($record) { return response()->download( diff --git a/src/Mail/FormSubmissionNotification.php b/src/Mail/FormSubmissionNotification.php new file mode 100644 index 0000000..9743195 --- /dev/null +++ b/src/Mail/FormSubmissionNotification.php @@ -0,0 +1,49 @@ +form->name, + ); + } + + public function content(): Content + { + return new Content( + markdown: 'filament-form-builder::mail.submission-notification', + ); + } + + /** + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/src/Models/FilamentForm.php b/src/Models/FilamentForm.php index f726e24..36b541f 100644 --- a/src/Models/FilamentForm.php +++ b/src/Models/FilamentForm.php @@ -7,21 +7,29 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; +use Tapp\FilamentFormBuilder\Models\Traits\BelongsToTenant; /** * @property int $id + * @property string $name + * @property string|null $description * @property string|null $redirect_url * @property bool $permit_guest_entries + * @property bool $private_entries + * @property array|null $notification_emails * @property-read string $form_link */ class FilamentForm extends Model { + use BelongsToTenant; use HasFactory; protected $guarded = []; protected $casts = [ 'permit_guest_entries' => 'boolean', + 'private_entries' => 'boolean', + 'notification_emails' => 'array', ]; public function users(): BelongsToMany diff --git a/src/Models/FilamentFormField.php b/src/Models/FilamentFormField.php index e2b0dbf..474c23c 100644 --- a/src/Models/FilamentFormField.php +++ b/src/Models/FilamentFormField.php @@ -8,6 +8,7 @@ use Spatie\EloquentSortable\Sortable; use Spatie\EloquentSortable\SortableTrait; use Tapp\FilamentFormBuilder\Enums\FilamentFieldTypeEnum; +use Tapp\FilamentFormBuilder\Models\Traits\BelongsToTenant; /** * @property int $id @@ -20,6 +21,7 @@ */ class FilamentFormField extends Model implements Sortable { + use BelongsToTenant; use HasFactory; use SortableTrait; diff --git a/src/Models/FilamentFormUser.php b/src/Models/FilamentFormUser.php index 9045af8..02e2156 100644 --- a/src/Models/FilamentFormUser.php +++ b/src/Models/FilamentFormUser.php @@ -8,14 +8,18 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; +use Tapp\FilamentFormBuilder\Models\Traits\BelongsToTenant; /** * @property array $entry * @property array|null $firstEntry + * @property int|null $user_id * @property-read array $key_value_entry + * @property-read FilamentForm $filamentForm */ class FilamentFormUser extends Model implements HasMedia { + use BelongsToTenant; use HasFactory; use InteractsWithMedia; diff --git a/src/Models/Traits/BelongsToTenant.php b/src/Models/Traits/BelongsToTenant.php new file mode 100644 index 0000000..fac602d --- /dev/null +++ b/src/Models/Traits/BelongsToTenant.php @@ -0,0 +1,122 @@ +belongsTo(config('filament-form-builder.tenancy.model'), static::getTenantColumnName()); + } + ); + + // Automatically set tenant_id when creating a new model + static::creating(function ($model) { + $tenantColumnName = static::getTenantColumnName(); + + // Skip if tenant foreign key is already set (e.g., by Filament's observer) + if (! empty($model->{$tenantColumnName})) { + return; + } + + $tenantRelationshipName = static::getTenantRelationshipName(); + + // Try to get tenant from Filament context (Filament's standard method) + // This handles top-level resources created outside Filament's Resource observers + if (class_exists(\Filament\Facades\Filament::class)) { + $tenant = \Filament\Facades\Filament::getTenant(); + if ($tenant) { + $model->{$tenantRelationshipName}()->associate($tenant); + + return; + } + } + + if (method_exists($model, 'filamentForm') && isset($model->filament_form_id)) { + $parentFormId = $model->filament_form_id; + $parentFormRelated = $model->filamentForm()->getRelated(); + + $parentFormClass = $parentFormRelated::class; + $parentForm = $parentFormClass::find($parentFormId); + + if ($parentForm) { + $parentTenant = $parentForm->{$tenantRelationshipName}; + if ($parentTenant) { + $model->{$tenantRelationshipName}()->associate($parentTenant); + } + } + } + }); + } + + /** + * Get the tenant relationship name. + */ + public static function getTenantRelationshipName(): string + { + // Use configured relationship name if provided + if ($relationshipName = config('filament-form-builder.tenancy.relationship_name')) { + return $relationshipName; + } + + // Auto-detect from tenant model class name + $tenantModel = config('filament-form-builder.tenancy.model'); + + if (! $tenantModel) { + if (config('filament-form-builder.tenancy.enabled')) { + throw new \Exception('Tenant model not configured in filament-form-builder.tenancy.model'); + } + + return 'tenant'; // Return a default value when tenancy is disabled + } + + return Str::snake(class_basename($tenantModel)); + } + + /** + * Get the tenant column name. + */ + public static function getTenantColumnName(): string + { + // Use configured column name if provided + if ($columnName = config('filament-form-builder.tenancy.column')) { + return $columnName; + } + + // Auto-detect from tenant model class name + return static::getTenantRelationshipName().'_id'; + } + + /** + * Get the tenant relationship instance. + * This provides a typed method for IDEs and static analysis. + */ + public function tenant(): ?BelongsTo + { + if (! config('filament-form-builder.tenancy.enabled')) { + return null; + } + + $tenantModel = config('filament-form-builder.tenancy.model'); + + if (! $tenantModel) { + throw new \Exception('Tenant model not configured in filament-form-builder.tenancy.model'); + } + + return $this->belongsTo($tenantModel, static::getTenantColumnName()); + } +} diff --git a/src/Observers/FilamentFormUserObserver.php b/src/Observers/FilamentFormUserObserver.php new file mode 100644 index 0000000..ac153ea --- /dev/null +++ b/src/Observers/FilamentFormUserObserver.php @@ -0,0 +1,55 @@ +sendNotifications($filamentFormUser); + } + + public function updated(FilamentFormUser $filamentFormUser): void + { + // Log that the updated event fired + \Log::info('FilamentFormUserObserver::updated() fired for entry ID: '.$filamentFormUser->id); + + $this->sendNotifications($filamentFormUser); + } + + protected function sendNotifications(FilamentFormUser $filamentFormUser): void + { + /** @var \Tapp\FilamentFormBuilder\Models\FilamentForm|null $form */ + $form = $filamentFormUser->filamentForm; + + // Check if notification emails are configured for this form + if (! $form || ! $form->notification_emails) { + \Log::info('No notification emails configured for form ID: '.($form->id ?? 'null')); + + return; + } + + // Filter out empty email addresses and send notifications + $emails = array_filter($form->notification_emails); + + if (empty($emails)) { + \Log::info('Notification emails array is empty after filtering'); + + return; + } + + \Log::info('Sending notifications for entry ID: '.$filamentFormUser->id.' to: '.json_encode($emails)); + + foreach ($emails as $email) { + Mail::to($email)->queue(new FormSubmissionNotification($form, $filamentFormUser)); + } + + \Log::info('Queued '.count($emails).' notification emails for entry ID: '.$filamentFormUser->id); + } +} diff --git a/tests/ShowRichEditorTest.php b/tests/ShowRichEditorTest.php new file mode 100644 index 0000000..d3af61a --- /dev/null +++ b/tests/ShowRichEditorTest.php @@ -0,0 +1,87 @@ +setRawAttributes([ + 'id' => 1, + 'filament_form_id' => 1, + 'type' => 'RICH_EDITOR', + 'label' => 'Content', + 'required' => false, + 'hint' => null, + 'rules' => null, + 'options' => null, + 'schema' => null, + 'step' => null, + 'order' => 1, + ]); + + $form = new FilamentForm; + $form->setRawAttributes([ + 'id' => 1, + 'name' => 'Test Form', + 'description' => null, + 'redirect_url' => null, + 'permit_guest_entries' => false, + 'is_wizard' => false, + ]); + $form->setRelation('filamentFormFields', Collection::make([$field])); + + $show = new Show; + $show->filamentForm = $form; + + $schema = $show->getFormSchema(); + + expect($schema)->toHaveCount(1); + + $richEditor = $schema[0]; + expect($richEditor)->toBeInstanceOf(RichEditor::class); + + // In Filament v5, disableToolbarButtons() queues a modification rather than + // immediately filtering the array. Inspect the queue via reflection to confirm + // that attachFiles has been disabled without needing a mounted container. + $modifications = (new \ReflectionProperty($richEditor, 'toolbarButtonsModifications'))->getValue($richEditor); + expect($modifications)->toContain(['type' => 'disable', 'buttons' => ['attachFiles']]); +}); + +it('does not affect non-rich-editor fields', function () { + $field = new FilamentFormField; + $field->setRawAttributes([ + 'id' => 2, + 'filament_form_id' => 1, + 'type' => 'TEXT', + 'label' => 'Name', + 'required' => false, + 'hint' => null, + 'rules' => null, + 'options' => null, + 'schema' => null, + 'step' => null, + 'order' => 1, + ]); + + $form = new FilamentForm; + $form->setRawAttributes([ + 'id' => 1, + 'name' => 'Test Form', + 'description' => null, + 'redirect_url' => null, + 'permit_guest_entries' => false, + 'is_wizard' => false, + ]); + $form->setRelation('filamentFormFields', Collection::make([$field])); + + $show = new Show; + $show->filamentForm = $form; + + $schema = $show->getFormSchema(); + + expect($schema)->toHaveCount(1); + expect($schema[0])->not->toBeInstanceOf(RichEditor::class); +});