├── docs ├── .nojekyll ├── index.html └── README.md ├── workbench ├── resources │ └── views │ │ └── .gitkeep ├── app │ ├── Providers │ │ ├── WorkbenchServiceProvider.php │ │ └── NovaServiceProvider.php │ ├── Models │ │ └── User.php │ └── Nova │ │ ├── Resource.php │ │ └── User.php ├── routes │ ├── web.php │ ├── api.php │ └── console.php └── database │ ├── seeders │ └── DatabaseSeeder.php │ ├── migrations │ └── 2024_12_24_062058_add_content_field_to_users_table.php │ └── factories │ └── UserFactory.php ├── .github ├── FUNDING.yml └── workflows │ ├── dusk.yml │ ├── ci.yml │ └── psalm.yml ├── title.png ├── confirm_remove.png ├── presentation.gif ├── example_layouts.png ├── limit_added_layouts.png ├── add_something_amazing.png ├── dist ├── mix-manifest.json ├── js │ └── field.js.LICENSE.txt └── css │ └── field.css ├── .gitignore ├── resources ├── js │ ├── components │ │ ├── IndexField.vue │ │ ├── DetailGroup.vue │ │ ├── FullWidthField.vue │ │ ├── DeleteGroupModal.vue │ │ ├── DetailField.vue │ │ ├── SearchMenu.vue │ │ ├── OriginalDropMenu.vue │ │ ├── FormGroup.vue │ │ └── FormField.vue │ ├── field.js │ └── group.js └── sass │ ├── colors.scss │ ├── multiselect.scss │ └── field.scss ├── src ├── Stubs │ ├── Cast.stub │ ├── Preset.stub │ ├── Layout.stub │ └── Resolver.stub ├── Layouts │ ├── Preset.php │ ├── LayoutInterface.php │ ├── Collection.php │ └── Layout.php ├── FileAdder │ ├── FileAdderFactory.php │ └── FileAdder.php ├── Value │ ├── FlexibleCast.php │ ├── ResolverInterface.php │ └── Resolver.php ├── Http │ ├── Middleware │ │ └── InterceptFlexibleAttributes.php │ ├── TransformsFlexibleErrors.php │ ├── ParsesFlexibleAttributes.php │ ├── ScopedRequest.php │ └── FlexibleAttribute.php ├── FieldServiceProvider.php ├── Commands │ ├── CreateCast.php │ ├── CreatePreset.php │ ├── CreateResolver.php │ └── CreateLayout.php ├── Concerns │ ├── HasFlexible.php │ └── HasMediaLibrary.php └── Flexible.php ├── webpack.mix.js ├── tests └── Unit │ └── Layouts │ ├── CollectionTest.php │ └── LayoutTest.php ├── psalm.xml ├── testbench.yaml ├── psalm-baseline.xml ├── package.json ├── LICENSE ├── composer.json └── README.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /workbench/resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: whitecube 2 | -------------------------------------------------------------------------------- /title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whitecube/nova-flexible-content/HEAD/title.png -------------------------------------------------------------------------------- /confirm_remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whitecube/nova-flexible-content/HEAD/confirm_remove.png -------------------------------------------------------------------------------- /presentation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whitecube/nova-flexible-content/HEAD/presentation.gif -------------------------------------------------------------------------------- /example_layouts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whitecube/nova-flexible-content/HEAD/example_layouts.png -------------------------------------------------------------------------------- /limit_added_layouts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whitecube/nova-flexible-content/HEAD/limit_added_layouts.png -------------------------------------------------------------------------------- /add_something_amazing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whitecube/nova-flexible-content/HEAD/add_something_amazing.png -------------------------------------------------------------------------------- /dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/field.js": "/js/field.js", 3 | "/css/field.css": "/css/field.css" 4 | } 5 | -------------------------------------------------------------------------------- /dist/js/field.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /**! 2 | * Sortable 1.15.6 3 | * @author RubaXa 4 | * @author owenm 5 | * @license MIT 6 | */ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /node_modules 4 | composer.phar 5 | composer.lock 6 | phpunit.xml 7 | .phpunit.result.cache 8 | .DS_Store 9 | Thumbs.db 10 | yarn.lock 11 | -------------------------------------------------------------------------------- /resources/js/components/IndexField.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/Stubs/Cast.stub: -------------------------------------------------------------------------------- 1 | div > label[for$="nova-flexible-content"]) { 12 | overflow: visible; 13 | } 14 | -------------------------------------------------------------------------------- /workbench/app/Providers/WorkbenchServiceProvider.php: -------------------------------------------------------------------------------- 1 | assertNotNull($collection->find('bar')); 16 | $this->assertNull($collection->find('a-name')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /workbench/routes/web.php: -------------------------------------------------------------------------------- 1 | create([ 18 | 'name' => 'Laravel Nova', 19 | 'email' => 'nova@laravel.com', 20 | ]); 21 | 22 | UserFactory::new()->times(10)->create(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Stubs/Preset.stub: -------------------------------------------------------------------------------- 1 | addLayout(...) 19 | // $field->button(...) 20 | // $field->resolver(...) 21 | // ... and so on. 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Layouts/LayoutInterface.php: -------------------------------------------------------------------------------- 1 | get('/user', function (Request $request) { 18 | // return $request->user(); 19 | // }); 20 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /resources/sass/multiselect.scss: -------------------------------------------------------------------------------- 1 | .flexible-search-menu-multiselect { 2 | --ms-font-size: 0.875rem; 3 | --ms-option-font-size: 0.875rem; 4 | --ms-ring-color: rgb(var(--colors-primary-100)); 5 | --ms-border-color: rgb(var(--colors-gray-300)); 6 | 7 | html.dark & { 8 | --ms-ring-color: rgb(var(--colors-gray-700)); 9 | --ms-border-color: rgb(var(--colors-gray-700)); 10 | --ms-dropdown-border-color: var(--ms-border-color); 11 | --ms-bg: rgb(var(--colors-gray-900)); 12 | --ms-dropdown-bg: var(--ms-bg); 13 | --ms-option-bg-pointed: rgb(var(--colors-gray-700)); 14 | --ms-option-color-pointed: rgb(var(--colors-gray-400)); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /workbench/routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 19 | // })->purpose('Display an inspiring quote'); 20 | -------------------------------------------------------------------------------- /src/Stubs/Layout.stub: -------------------------------------------------------------------------------- 1 | json('content')->nullable(); 16 | }); 17 | } 18 | 19 | /** 20 | * Reverse the migrations. 21 | */ 22 | public function down(): void 23 | { 24 | Schema::table('users', function (Blueprint $table) { 25 | $table->dropColumn('content'); 26 | }); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /resources/sass/field.scss: -------------------------------------------------------------------------------- 1 | // Nova Tool CSS 2 | $multiselect-primary-color: var(--primary); 3 | $multiselect-primary-color-dark: var(--primary-dark); 4 | $mulitselect-secondary-hover-color: #bacad6; 5 | $mulitselect-secondary-hover-color-10: #b3b9bf; 6 | $mulitselect-secondary-hover-color-25: #3c4b5f; 7 | $multiselect-radius: .5rem; 8 | $multiselect-placeholder-color: #7c858e; 9 | 10 | @import "./multiselect"; 11 | @import "./colors"; 12 | 13 | .nova-flexible-content-sortable-ghost { 14 | opacity: 0.5; 15 | } 16 | 17 | .nova-flexible-content-sortable-drag { 18 | background-color: rgba(var(--colors-gray-100),var(--tw-bg-opacity)); 19 | border-radius: 10px; 20 | } 21 | 22 | .dark .nova-flexible-content-sortable-drag { 23 | background-color: rgba(var(--colors-gray-900),var(--tw-bg-opacity)); 24 | } 25 | -------------------------------------------------------------------------------- /src/Layouts/Collection.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class Collection extends BaseCollection 14 | { 15 | /** 16 | * Find a layout based on its name 17 | * 18 | * @param string $name 19 | * @return \Whitecube\NovaFlexibleContent\Layouts\Layout|null 20 | * 21 | * @psalm-return TLayout|null 22 | */ 23 | public function find($name) 24 | { 25 | return $this->first(function ($layout) use ($name) { 26 | return $layout->name() === $name; 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /psalm-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OriginalFileAdder 6 | 7 | 8 | 9 | 10 | OriginalFileAdderFactory 11 | 12 | 13 | 14 | 15 | $model 16 | \Whitecube\NovaPage\Pages\Template 17 | 18 | 19 | 20 | 21 | Collection 22 | 23 | 24 | 25 | 26 | HasAttributes 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "mix", 6 | "watch": "mix watch", 7 | "watch-poll": "mix watch -- --watch-options-poll=1000", 8 | "hot": "mix watch --hot", 9 | "prod": "npm run production", 10 | "production": "mix --production", 11 | "check-format": "prettier --list-different 'resources/**/*.{css,js,vue}'", 12 | "format": "prettier --write 'resources/js/**/*.{css,js,vue}'" 13 | }, 14 | "devDependencies": { 15 | "@vueform/multiselect": "^2.3.3", 16 | "laravel-nova-devtool": "file:vendor/laravel/nova-devtool", 17 | "lodash": "^4.17.21", 18 | "resolve-url-loader": "^5.0.0", 19 | "sass": "^1.32.8", 20 | "sass-loader": "10.*", 21 | "sortablejs": "^1.15.0", 22 | "vuex": "^4.1.0" 23 | }, 24 | "dependencies": { 25 | "prettier": "^3.4.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/FileAdder/FileAdderFactory.php: -------------------------------------------------------------------------------- 1 | setSubject($subject) 21 | ->setFile($file) 22 | ->setMediaCollectionSuffix($suffix); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /resources/js/field.js: -------------------------------------------------------------------------------- 1 | Nova.booting((app) => { 2 | // app.component('index-nova-flexible-content', require('./components/IndexField').default) 3 | app.component( 4 | "detail-nova-flexible-content", 5 | require("./components/DetailField.vue").default, 6 | ); 7 | app.component( 8 | "detail-nova-flexible-content-group", 9 | require("./components/DetailGroup.vue").default, 10 | ); 11 | app.component( 12 | "form-nova-flexible-content", 13 | require("./components/FormField.vue").default, 14 | ); 15 | app.component( 16 | "form-nova-flexible-content-group", 17 | require("./components/FormGroup.vue").default, 18 | ); 19 | app.component( 20 | "flexible-drop-menu", 21 | require("./components/OriginalDropMenu.vue").default, 22 | ); 23 | app.component( 24 | "flexible-search-menu", 25 | require("./components/SearchMenu.vue").default, 26 | ); 27 | app.component( 28 | "delete-flexible-content-group-modal", 29 | require("./components/DeleteGroupModal.vue").default, 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /src/Value/FlexibleCast.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public function get($model, string $key, $value, array $attributes) 26 | { 27 | $this->model = $model; 28 | 29 | return $this->cast($value, $this->getLayoutMapping()); 30 | } 31 | 32 | public function set($model, string $key, $value, array $attributes) 33 | { 34 | return $value; 35 | } 36 | 37 | protected function getLayoutMapping() 38 | { 39 | return $this->layouts; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /dist/css/field.css: -------------------------------------------------------------------------------- 1 | .flexible-search-menu-multiselect{--ms-font-size:0.875rem;--ms-option-font-size:0.875rem;--ms-ring-color:rgb(var(--colors-primary-100));--ms-border-color:rgb(var(--colors-gray-300))}html.dark .flexible-search-menu-multiselect{--ms-ring-color:rgb(var(--colors-gray-700));--ms-border-color:rgb(var(--colors-gray-700));--ms-dropdown-border-color:var(--ms-border-color);--ms-bg:rgb(var(--colors-gray-900));--ms-dropdown-bg:var(--ms-bg);--ms-option-bg-pointed:rgb(var(--colors-gray-700));--ms-option-color-pointed:rgb(var(--colors-gray-400))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgba(var(--colors-gray-50),var(--tw-bg-opacity))}.dark .dark\:hover\:bg-gray-900:hover{--tw-bg-opacity:1;background-color:rgba(var(--colors-gray-900),var(--tw-bg-opacity))}div.relative.overflow-hidden:has(div>div>label[for$=nova-flexible-content]){overflow:visible}.nova-flexible-content-sortable-ghost{opacity:.5}.nova-flexible-content-sortable-drag{background-color:rgba(var(--colors-gray-100),var(--tw-bg-opacity));border-radius:10px}.dark .nova-flexible-content-sortable-drag{background-color:rgba(var(--colors-gray-900),var(--tw-bg-opacity))} 2 | -------------------------------------------------------------------------------- /src/Stubs/Resolver.stub: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function get($resource, $attribute, $layouts) 18 | { 19 | 20 | } 21 | 22 | /** 23 | * Save the Flexible field's content somewhere the get method will be able to access it. 24 | * 25 | * @param mixed $resource 26 | * @param string $attribute Attribute name set for a Flexible field. 27 | * @param \Illuminate\Support\Collection $groups 28 | * @return mixed 29 | */ 30 | public function set($resource, $attribute, $groups) 31 | { 32 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Whitecube 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Nova Flexible Content - A Laravel Nova package by Whitecube 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 |
18 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Value/ResolverInterface.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function get($resource, $attribute, $layouts); 17 | 18 | /** 19 | * Save the Flexible field's content somewhere the get method will be able to access it. 20 | * @see https://whitecube.github.io/nova-flexible-content/#/?id=filling-the-field 21 | * 22 | * @param mixed $resource 23 | * @param string $attribute Attribute name set for a Flexible field. 24 | * @param \Illuminate\Support\Collection $groups 25 | * @return mixed 26 | */ 27 | public function set($resource, $attribute, $groups); 28 | } 29 | -------------------------------------------------------------------------------- /workbench/app/Models/User.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | protected $fillable = [ 21 | 'name', 22 | 'email', 23 | 'password', 24 | ]; 25 | 26 | /** 27 | * The attributes that should be hidden for serialization. 28 | * 29 | * @var array 30 | */ 31 | protected $hidden = [ 32 | 'password', 33 | 'remember_token', 34 | ]; 35 | 36 | /** 37 | * The attributes that should be cast. 38 | * 39 | * @var array 40 | */ 41 | protected $casts = [ 42 | 'email_verified_at' => 'datetime', 43 | 'password' => 'hashed', 44 | 'content' => 'json', 45 | ]; 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | tests: 8 | name: PHPUnit 9 | runs-on: ubuntu-20.04 10 | 11 | strategy: 12 | matrix: 13 | php: [8.1, 8.2, 8.3, 8.4] 14 | prefer: ['--prefer-lowest', '--prefer-stable'] 15 | 16 | steps: 17 | - uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: ${{ matrix.php }} 20 | 21 | - uses: actions/checkout@v2 22 | 23 | - name: Get Composer Cache Directory 24 | id: composer-cache 25 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 26 | - uses: actions/cache@v2 27 | with: 28 | path: ${{ steps.composer-cache.outputs.dir }} 29 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 30 | restore-keys: ${{ runner.os }}-composer- 31 | 32 | - name: Install PHP dependencies 33 | run: | 34 | composer config --ansi -- http-basic.nova.laravel.com ${{ secrets.NOVA_USERNAME }} ${{ secrets.NOVA_LICENSE_KEY }} 35 | composer update ${{ matrix.prefer }} --prefer-dist --no-interaction --no-suggest --ansi 36 | 37 | - name: PHPUnit 38 | run: composer test 39 | -------------------------------------------------------------------------------- /tests/Unit/Layouts/LayoutTest.php: -------------------------------------------------------------------------------- 1 | duplicateAndHydrate('keylikegenerated', [ 23 | 'created_at' => '2023-01-01 00:00:00', 24 | 'name' => 'Test' 25 | ]); 26 | 27 | $this->assertInstanceOf(Layout::class, $duplicate); 28 | $this->assertEquals('keylikegenerated', $duplicate->key()); 29 | $this->assertEquals('Test Layout', $duplicate->title()); 30 | $this->assertEquals('test', $duplicate->name()); 31 | 32 | // The text field should resolve the value 33 | $textField = $duplicate->fields()[1]; 34 | $textField->resolve(null); 35 | $this->assertEquals('static-name', $textField->value); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/FileAdder/FileAdder.php: -------------------------------------------------------------------------------- 1 | media_collection_suffix = $suffix; 21 | 22 | return $this; 23 | } 24 | 25 | public function toMediaCollection(string $collectionName = 'default', string $diskName = ''): Media 26 | { 27 | if ($this->media_collection_suffix) { 28 | $collectionName .= $this->media_collection_suffix; 29 | } 30 | 31 | return parent::toMediaCollection($collectionName, $diskName); 32 | } 33 | 34 | public function determineDiskName(string $diskName, string $collectionName): string 35 | { 36 | if ($this->media_collection_suffix) { 37 | $collectionName = str_replace($this->media_collection_suffix, '', $collectionName); 38 | } 39 | 40 | return parent::determineDiskName($diskName, $collectionName); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Http/Middleware/InterceptFlexibleAttributes.php: -------------------------------------------------------------------------------- 1 | requestHasParsableFlexibleInputs($request)) { 27 | return $next($request); 28 | } 29 | 30 | $request->merge($this->getParsedFlexibleInputs($request)); 31 | $request->request->remove(FlexibleAttribute::REGISTER); 32 | 33 | $response = $next($request); 34 | 35 | if (! $this->shouldTransformFlexibleErrors($response)) { 36 | return $response; 37 | } 38 | 39 | return $this->transformFlexibleErrors($response); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/js/components/DetailGroup.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 48 | -------------------------------------------------------------------------------- /workbench/app/Nova/Resource.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class UserFactory extends Factory 14 | { 15 | /** 16 | * The current password being used by the factory. 17 | */ 18 | protected static ?string $password; 19 | 20 | /** 21 | * The name of the factory's corresponding model. 22 | * 23 | * @var string 24 | */ 25 | protected $model = User::class; 26 | 27 | /** 28 | * Define the model's default state. 29 | * 30 | * @return array 31 | */ 32 | public function definition(): array 33 | { 34 | return [ 35 | 'name' => fake()->name(), 36 | 'email' => fake()->unique()->safeEmail(), 37 | 'email_verified_at' => now(), 38 | 'password' => static::$password ??= Hash::make('password'), 39 | 'remember_token' => Str::random(10), 40 | ]; 41 | } 42 | 43 | /** 44 | * Indicate that the model's email address should be unverified. 45 | */ 46 | public function unverified(): static 47 | { 48 | return $this->state(fn (array $attributes) => [ 49 | 'email_verified_at' => null, 50 | ]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/psalm.yml: -------------------------------------------------------------------------------- 1 | name: Psalm 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths: 7 | - '**.php' 8 | - 'composer*' 9 | - 'psalm*' 10 | 11 | jobs: 12 | psalm: 13 | name: Psalm 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 6 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | ref: ${{ github.head_ref }} 20 | 21 | # mtime needs to be restored for Psalm cache to work correctly 22 | - name: Restore mtimes 23 | uses: chetan/git-restore-mtime-action@v1 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: 8.1 29 | coverage: none 30 | 31 | - name: Install composer dependencies 32 | run: | 33 | composer config --ansi -- http-basic.nova.laravel.com ${{ secrets.NOVA_USERNAME }} ${{ secrets.NOVA_LICENSE_KEY }} 34 | composer install --no-interaction --no-progress --no-scripts 35 | 36 | # the way cache keys are set up will always cause a cache miss 37 | # but will restore the cache generated during the previous run based on partial match 38 | - name: Retrieve Psalm’s cache 39 | uses: actions/cache@v3 40 | with: 41 | path: ./cache/psalm 42 | key: ${{ runner.os }}-psalm-cache-${{ hashFiles('psalm.xml', 'psalm-baseline.xml', './composer.json') }} 43 | 44 | - name: Run Psalm 45 | run: ./vendor/bin/psalm --find-unused-psalm-suppress --output-format=github 46 | -------------------------------------------------------------------------------- /resources/js/components/FullWidthField.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 60 | -------------------------------------------------------------------------------- /resources/js/components/DeleteGroupModal.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 67 | -------------------------------------------------------------------------------- /src/FieldServiceProvider.php: -------------------------------------------------------------------------------- 1 | addMiddleware(); 24 | 25 | Nova::serving(function (ServingNova $event) { 26 | Nova::mix('nova-flexible-content', __DIR__.'/../dist/'); 27 | }); 28 | } 29 | 30 | /** 31 | * Register any application services. 32 | * 33 | * @return void 34 | */ 35 | public function register() 36 | { 37 | if (! $this->app->runningInConsole()) { 38 | return; 39 | } 40 | 41 | $this->commands([ 42 | CreateCast::class, 43 | CreateLayout::class, 44 | CreatePreset::class, 45 | CreateResolver::class, 46 | ]); 47 | } 48 | 49 | /** 50 | * Adds required middleware for Nova requests. 51 | * 52 | * @return void 53 | */ 54 | public function addMiddleware() 55 | { 56 | $router = $this->app['router']; 57 | 58 | if ($router->hasMiddlewareGroup('nova')) { 59 | $router->pushMiddlewareToGroup('nova', InterceptFlexibleAttributes::class); 60 | 61 | return; 62 | } 63 | 64 | if (! $this->app->configurationIsCached()) { 65 | config()->set('nova.middleware', array_merge( 66 | config('nova.middleware', []), 67 | [InterceptFlexibleAttributes::class] 68 | )); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Value/Resolver.php: -------------------------------------------------------------------------------- 1 | $attribute = $groups->map(function ($group) { 20 | return [ 21 | 'layout' => $group->name(), 22 | 'key' => $group->key(), 23 | 'attributes' => $group->getAttributes(), 24 | ]; 25 | }); 26 | } 27 | 28 | /** 29 | * Resolve the Flexible field's content. 30 | * 31 | * @param mixed $resource 32 | * @param string $attribute 33 | * @param \Whitecube\NovaFlexibleContent\Layouts\Collection $layouts 34 | * @return \Illuminate\Support\Collection 35 | */ 36 | public function get($resource, $attribute, $layouts) 37 | { 38 | $value = $this->extractValueFromResource($resource, $attribute); 39 | 40 | return collect($value)->map(function ($item) use ($layouts) { 41 | $layout = $layouts->find($item->layout); 42 | 43 | if (! $layout) { 44 | return null; 45 | } 46 | 47 | return $layout->duplicateAndHydrate($item->key, (array) $item->attributes); 48 | })->filter()->values(); 49 | } 50 | 51 | /** 52 | * Find the attribute's value in the given resource 53 | * 54 | * @param mixed $resource 55 | * @param string $attribute 56 | * @return array 57 | */ 58 | protected function extractValueFromResource($resource, $attribute) 59 | { 60 | $value = data_get($resource, str_replace('->', '.', $attribute)) ?? []; 61 | 62 | if ($value instanceof Collection) { 63 | $value = $value->toArray(); 64 | } elseif (is_string($value)) { 65 | $value = json_decode($value) ?? []; 66 | } 67 | 68 | // Fail silently in case data is invalid 69 | if (! is_array($value)) { 70 | return []; 71 | } 72 | 73 | return array_map(function ($item) { 74 | return is_array($item) ? (object) $item : $item; 75 | }, $value); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whitecube/nova-flexible-content", 3 | "description": "Flexible Content & Repeater Fields for Laravel Nova.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "nova", 8 | "field", 9 | "flexible", 10 | "repeat", 11 | "group", 12 | "layout" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Toon Van den Bos", 17 | "email": "toon@whitecube.be" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.1", 22 | "ext-json": "*", 23 | "laravel/nova": "^5.0" 24 | }, 25 | "require-dev": { 26 | "laravel/nova-devtool": "^1.1.5", 27 | "laravel/pint": "^1.2", 28 | "phpunit/phpunit": "^9.6", 29 | "psalm/plugin-laravel": "^2.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Whitecube\\NovaFlexibleContent\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Tests\\": "tests/", 39 | "Workbench\\App\\": "workbench/app/", 40 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 41 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 42 | } 43 | }, 44 | "repositories": [ 45 | { 46 | "type": "composer", 47 | "url": "https://nova.laravel.com" 48 | } 49 | ], 50 | "extra": { 51 | "laravel": { 52 | "providers": [ 53 | "Whitecube\\NovaFlexibleContent\\FieldServiceProvider" 54 | ] 55 | } 56 | }, 57 | "config": { 58 | "sort-packages": true 59 | }, 60 | "minimum-stability": "dev", 61 | "prefer-stable": true, 62 | "scripts": { 63 | "psalm": "psalm --find-unused-psalm-suppress --output-format=phpstorm", 64 | "psalm-update-baseline": "psalm --set-baseline=psalm-baseline.xml", 65 | "test": "phpunit --colors=always tests", 66 | "fix-style": "./vendor/bin/pint", 67 | "post-autoload-dump": [ 68 | "@clear", 69 | "@prepare" 70 | ], 71 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 72 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 73 | "build": "@php vendor/bin/testbench workbench:build --ansi", 74 | "serve": [ 75 | "Composer\\Config::disableProcessTimeout", 76 | "@build", 77 | "@php vendor/bin/testbench serve --ansi" 78 | ], 79 | "lint": [ 80 | "@php vendor/bin/pint --ansi" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /resources/js/components/DetailField.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 99 | -------------------------------------------------------------------------------- /workbench/app/Providers/NovaServiceProvider.php: -------------------------------------------------------------------------------- 1 | features([ 29 | Features::updatePasswords(), 30 | // Features::emailVerification(), 31 | // Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true]), 32 | ]) 33 | ->register(); 34 | } 35 | 36 | /** 37 | * Register the Nova routes. 38 | */ 39 | protected function routes(): void 40 | { 41 | Nova::routes() 42 | ->withAuthenticationRoutes(default: true) 43 | ->withPasswordResetRoutes() 44 | ->withEmailVerificationRoutes() 45 | ->register(); 46 | } 47 | 48 | /** 49 | * Register the Nova gate. 50 | * 51 | * This gate determines who can access Nova in non-local environments. 52 | */ 53 | protected function gate(): void 54 | { 55 | Gate::define('viewNova', function ($user) { 56 | return true; 57 | }); 58 | } 59 | 60 | /** 61 | * Get the dashboards that should be listed in the Nova sidebar. 62 | * 63 | * @return array 64 | */ 65 | protected function dashboards(): array 66 | { 67 | return [ 68 | new \Laravel\Nova\Dashboards\Main, 69 | ]; 70 | } 71 | 72 | /** 73 | * Get the tools that should be listed in the Nova sidebar. 74 | * 75 | * @return array 76 | */ 77 | public function tools(): array 78 | { 79 | return []; 80 | } 81 | 82 | /** 83 | * Register the application's Nova resources. 84 | */ 85 | protected function resources(): void 86 | { 87 | Nova::resourcesInWorkbench(); 88 | } 89 | 90 | /** 91 | * Register any application services. 92 | */ 93 | public function register(): void 94 | { 95 | parent::register(); 96 | 97 | // 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /resources/js/group.js: -------------------------------------------------------------------------------- 1 | export default class Group { 2 | constructor(name, title, fields, field, key, collapsed = true) { 3 | this.name = name; 4 | this.title = title; 5 | this.fields = fields; 6 | this.key = key || this.getTemporaryUniqueKey(field.attribute); 7 | this.collapsed = collapsed; 8 | this.readonly = field.readonly; 9 | 10 | this.renameFields(); 11 | } 12 | 13 | /** 14 | * Retrieve the layout's filled FormData 15 | */ 16 | values() { 17 | let formData = new FormData(); 18 | 19 | for (var i = 0; i < this.fields.length; i++) { 20 | this.fields[i].fill(formData); 21 | } 22 | 23 | return formData; 24 | } 25 | 26 | /** 27 | * Retrieve the layout's filled object 28 | */ 29 | serialize() { 30 | let data = { 31 | layout: this.name, 32 | key: this.key, 33 | attributes: {}, 34 | files: {}, 35 | }; 36 | 37 | for (var item of this.values()) { 38 | if (item[0].indexOf("___upload-") == 0) { 39 | // Previously nested file attribute 40 | data.files[item[0]] = item[1]; 41 | continue; 42 | } 43 | 44 | if (!(item[1] instanceof File || item[1] instanceof Blob)) { 45 | // Simple input value, no need to attach files 46 | data.attributes[item[0]] = item[1]; 47 | continue; 48 | } 49 | 50 | // File object, attach its file for upload 51 | data.attributes[item[0]] = "___upload-" + item[0]; 52 | data.files["___upload-" + item[0]] = item[1]; 53 | } 54 | 55 | return data; 56 | } 57 | 58 | /** 59 | * Generate a unique string for current group 60 | */ 61 | getTemporaryUniqueKey(attribute) { 62 | return this.randomString(16); 63 | } 64 | 65 | randomString(len, charSet) { 66 | charSet = 67 | charSet || 68 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 69 | var randomString = ""; 70 | for (var i = 0; i < len - 1; i++) { 71 | var randomPoz = Math.floor(Math.random() * charSet.length); 72 | randomString += charSet.substring(randomPoz, randomPoz + 1); 73 | } 74 | return "c" + randomString; 75 | } 76 | 77 | /** 78 | * Assign a new unique field name to each field 79 | */ 80 | renameFields() { 81 | for (var i = this.fields.length - 1; i >= 0; i--) { 82 | this.fields[i].attribute = this.key + "__" + this.fields[i].attribute; 83 | this.fields[i].validationKey = this.fields[i].attribute; 84 | 85 | if (this.fields[i].dependsOn) { 86 | Object.keys(this.fields[i].dependsOn).forEach((key) => { 87 | this.fields[i].dependsOn[`${this.key}__${key}`] = 88 | this.fields[i].dependsOn[key]; 89 | delete this.fields[i].dependsOn[key]; 90 | }); 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Commands/CreateCast.php: -------------------------------------------------------------------------------- 1 | files = $files; 47 | } 48 | 49 | /** 50 | * Execute the console command. 51 | * 52 | * @return void 53 | */ 54 | public function handle() 55 | { 56 | $this->classname = $this->getClassnameArgument(); 57 | 58 | $path = $this->getPath(); 59 | 60 | $this->files->put($path, $this->buildClass()); 61 | 62 | $this->info('Created '.$path); 63 | } 64 | 65 | /** 66 | * Get the classname 67 | * 68 | * @return string 69 | */ 70 | public function getClassnameArgument() 71 | { 72 | if (! $this->argument('classname')) { 73 | return $this->ask('Please provide a class name for your layout'); 74 | } 75 | 76 | return $this->argument('classname'); 77 | } 78 | 79 | /** 80 | * Build the layout's file path 81 | * 82 | * @return string 83 | */ 84 | protected function getPath() 85 | { 86 | return $this->makeDirectory( 87 | app_path('Casts/'.$this->classname.'.php') 88 | ); 89 | } 90 | 91 | /** 92 | * Create the directories if they do not exist yet 93 | * 94 | * @param string $path 95 | * @return string 96 | */ 97 | protected function makeDirectory($path) 98 | { 99 | $directory = dirname($path); 100 | 101 | if (! $this->files->isDirectory($directory)) { 102 | $this->files->makeDirectory($directory, 0755, true, true); 103 | } 104 | 105 | return $path; 106 | } 107 | 108 | /** 109 | * Generate the file's content 110 | * 111 | * @return string 112 | */ 113 | protected function buildClass() 114 | { 115 | return str_replace([ 116 | ':classname', 117 | ], [ 118 | $this->classname, 119 | ], 120 | $this->files->get(__DIR__.'/../Stubs/Cast.stub') 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Commands/CreatePreset.php: -------------------------------------------------------------------------------- 1 | files = $files; 47 | } 48 | 49 | /** 50 | * Execute the console command. 51 | * 52 | * @return void 53 | */ 54 | public function handle() 55 | { 56 | $this->classname = $this->getClassnameArgument(); 57 | 58 | $path = $this->getPath(); 59 | 60 | $this->files->put($path, $this->buildClass()); 61 | 62 | $this->info('Created '.$path); 63 | } 64 | 65 | /** 66 | * Get the classname 67 | * 68 | * @return string 69 | */ 70 | public function getClassnameArgument() 71 | { 72 | if (! $this->argument('classname')) { 73 | return $this->ask('Please provide a class name for your preset'); 74 | } 75 | 76 | return $this->argument('classname'); 77 | } 78 | 79 | /** 80 | * Build the preset's file path 81 | * 82 | * @return string 83 | */ 84 | protected function getPath() 85 | { 86 | return $this->makeDirectory( 87 | app_path('Nova/Flexible/Presets/'.$this->classname.'.php') 88 | ); 89 | } 90 | 91 | /** 92 | * Create the directories if they do not exist yet 93 | * 94 | * @param string $path 95 | * @return string 96 | */ 97 | protected function makeDirectory($path) 98 | { 99 | $directory = dirname($path); 100 | 101 | if (! $this->files->isDirectory($directory)) { 102 | $this->files->makeDirectory($directory, 0755, true, true); 103 | } 104 | 105 | return $path; 106 | } 107 | 108 | /** 109 | * Generate the file's content 110 | * 111 | * @return string 112 | */ 113 | protected function buildClass() 114 | { 115 | return str_replace( 116 | ':classname', 117 | $this->classname, 118 | $this->files->get(__DIR__.'/../Stubs/Preset.stub') 119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Commands/CreateResolver.php: -------------------------------------------------------------------------------- 1 | files = $files; 47 | } 48 | 49 | /** 50 | * Execute the console command. 51 | * 52 | * @return void 53 | */ 54 | public function handle() 55 | { 56 | $this->classname = $this->getClassnameArgument(); 57 | 58 | $path = $this->getPath(); 59 | 60 | $this->files->put($path, $this->buildClass()); 61 | 62 | $this->info('Created '.$path); 63 | } 64 | 65 | /** 66 | * Get the classname 67 | * 68 | * @return string 69 | */ 70 | public function getClassnameArgument() 71 | { 72 | if (! $this->argument('classname')) { 73 | return $this->ask('Please provide a class name for your resolver'); 74 | } 75 | 76 | return $this->argument('classname'); 77 | } 78 | 79 | /** 80 | * Build the resolver's file path 81 | * 82 | * @return string 83 | */ 84 | protected function getPath() 85 | { 86 | return $this->makeDirectory( 87 | app_path('Nova/Flexible/Resolvers/'.$this->classname.'.php') 88 | ); 89 | } 90 | 91 | /** 92 | * Create the directories if they do not exist yet 93 | * 94 | * @param string $path 95 | * @return string 96 | */ 97 | protected function makeDirectory($path) 98 | { 99 | $directory = dirname($path); 100 | 101 | if (! $this->files->isDirectory($directory)) { 102 | $this->files->makeDirectory($directory, 0755, true, true); 103 | } 104 | 105 | return $path; 106 | } 107 | 108 | /** 109 | * Generate the file's content 110 | * 111 | * @return string 112 | */ 113 | protected function buildClass() 114 | { 115 | return str_replace( 116 | ':classname', 117 | $this->classname, 118 | $this->files->get(__DIR__.'/../Stubs/Resolver.stub') 119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Http/TransformsFlexibleErrors.php: -------------------------------------------------------------------------------- 1 | getStatusCode() === Response::HTTP_UNPROCESSABLE_ENTITY 22 | && is_a($response, JsonResponse::class); 23 | } 24 | 25 | /** 26 | * Updates given response's errors for the concerned flexible fields 27 | * 28 | * @param Response $response 29 | * @return \Symfony\Component\HttpFoundation\Response 30 | */ 31 | protected function transformFlexibleErrors(Response $response) 32 | { 33 | $response->setData( 34 | $this->updateResponseErrors($response->original) 35 | ); 36 | 37 | return $response; 38 | } 39 | 40 | /** 41 | * Run response errors parsing if necessary 42 | * 43 | * @param array $data 44 | * @return array 45 | */ 46 | protected function updateResponseErrors($data) 47 | { 48 | if (! ($data['errors'] ?? null)) { 49 | return $data; 50 | } 51 | 52 | $data['errors'] = $this->getTransformedErrors($data['errors']); 53 | 54 | return $data; 55 | } 56 | 57 | /** 58 | * Transforms the original errors array in a nested 59 | * array structure. 60 | * 61 | * @param array $errors 62 | * @return array 63 | */ 64 | protected function getTransformedErrors($errors) 65 | { 66 | $parsed = []; 67 | 68 | foreach ($errors as $key => $messages) { 69 | $attribute = Flexible::getValidationKey($key); 70 | 71 | if (! $attribute) { 72 | $parsed[$key] = $messages; 73 | 74 | continue; 75 | } 76 | 77 | $parsed[$attribute->original] = $this->transformMessages($messages, $key, $attribute); 78 | } 79 | 80 | return $parsed; 81 | } 82 | 83 | /** 84 | * Update human error messages with correct field names 85 | * 86 | * @param array $messages 87 | * @param string $key 88 | * @param \Whitecube\NovaFlexibleContent\Http\FlexibleAttribute $attribute 89 | * @return array 90 | */ 91 | protected function transformMessages($messages, $key, $attribute) 92 | { 93 | $search = str_replace('_', ' ', Str::snake($key)); 94 | $attribute = str_replace('_', ' ', Str::snake($attribute->name)); 95 | 96 | // We translate the attribute if it exists 97 | if (Lang::has('validation.attributes.'.$attribute)) { 98 | $attribute = trans('validation.attributes.'.$attribute); 99 | } 100 | 101 | return array_map(function ($message) use ($search, $attribute) { 102 | return str_replace( 103 | [$search, Str::upper($search), Str::ucfirst($search)], 104 | [$attribute, Str::upper($attribute), Str::ucfirst($attribute)], 105 | $message 106 | ); 107 | }, $messages); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /workbench/app/Nova/User.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public static $model = \Workbench\App\Models\User::class; 25 | 26 | /** 27 | * The single value that should be used to represent the resource when being displayed. 28 | * 29 | * @var string 30 | */ 31 | public static $title = 'name'; 32 | 33 | /** 34 | * The columns that should be searched. 35 | * 36 | * @var array 37 | */ 38 | public static $search = [ 39 | 'id', 'name', 'email', 40 | ]; 41 | 42 | /** 43 | * Get the fields displayed by the resource. 44 | * 45 | * @return array 46 | */ 47 | public function fields(NovaRequest $request): array 48 | { 49 | return [ 50 | ID::make()->sortable(), 51 | 52 | Text::make('Name') 53 | ->sortable() 54 | ->rules('required', 'max:255'), 55 | 56 | Text::make('Email') 57 | ->sortable() 58 | ->rules('required', 'email', 'max:254') 59 | ->creationRules('unique:users,email') 60 | ->updateRules('unique:users,email,{{resourceId}}'), 61 | 62 | Password::make('Password') 63 | ->onlyOnForms() 64 | ->creationRules($this->passwordRules()) 65 | ->updateRules($this->optionalPasswordRules()), 66 | 67 | Flexible::make('Content') 68 | ->button('Add Content Block') 69 | ->addLayout('Image on the left', 'image_left', [ 70 | Textarea::make('Content'), 71 | Image::make('Image'), 72 | ]) 73 | ->addLayout('Image on the right', 'image_right', [ 74 | Textarea::make('Content'), 75 | Image::make('Image'), 76 | ]) 77 | ]; 78 | } 79 | 80 | /** 81 | * Get the cards available for the request. 82 | * 83 | * @return array 84 | */ 85 | public function cards(NovaRequest $request): array 86 | { 87 | return []; 88 | } 89 | 90 | /** 91 | * Get the filters available for the resource. 92 | * 93 | * @return array 94 | */ 95 | public function filters(NovaRequest $request): array 96 | { 97 | return []; 98 | } 99 | 100 | /** 101 | * Get the lenses available for the resource. 102 | * 103 | * @return array 104 | */ 105 | public function lenses(NovaRequest $request): array 106 | { 107 | return []; 108 | } 109 | 110 | /** 111 | * Get the actions available for the resource. 112 | * 113 | * @return array 114 | */ 115 | public function actions(NovaRequest $request): array 116 | { 117 | return []; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Commands/CreateLayout.php: -------------------------------------------------------------------------------- 1 | files = $files; 54 | } 55 | 56 | /** 57 | * Execute the console command. 58 | * 59 | * @return void 60 | */ 61 | public function handle() 62 | { 63 | $this->classname = $this->getClassnameArgument(); 64 | $this->name = $this->getNameArgument(); 65 | 66 | $path = $this->getPath(); 67 | 68 | $this->files->put($path, $this->buildClass()); 69 | 70 | $this->info('Created '.$path); 71 | } 72 | 73 | /** 74 | * Get the classname 75 | * 76 | * @return string 77 | */ 78 | public function getClassnameArgument() 79 | { 80 | if (! $this->argument('classname')) { 81 | return $this->ask('Please provide a class name for your layout'); 82 | } 83 | 84 | return $this->argument('classname'); 85 | } 86 | 87 | /** 88 | * Get the name 89 | * 90 | * @return string 91 | */ 92 | public function getNameArgument() 93 | { 94 | if (! $this->argument('name')) { 95 | return strtolower($this->classname); 96 | } 97 | 98 | return $this->argument('name'); 99 | } 100 | 101 | /** 102 | * Build the layout's file path 103 | * 104 | * @return string 105 | */ 106 | protected function getPath() 107 | { 108 | return $this->makeDirectory( 109 | app_path('Nova/Flexible/Layouts/'.$this->classname.'.php') 110 | ); 111 | } 112 | 113 | /** 114 | * Create the directories if they do not exist yet 115 | * 116 | * @param string $path 117 | * @return string 118 | */ 119 | protected function makeDirectory($path) 120 | { 121 | $directory = dirname($path); 122 | 123 | if (! $this->files->isDirectory($directory)) { 124 | $this->files->makeDirectory($directory, 0755, true, true); 125 | } 126 | 127 | return $path; 128 | } 129 | 130 | /** 131 | * Generate the file's content 132 | * 133 | * @return string 134 | */ 135 | protected function buildClass() 136 | { 137 | return str_replace([ 138 | ':classname', 139 | ':name', 140 | ], [ 141 | $this->classname, 142 | $this->name, 143 | ], 144 | $this->files->get(__DIR__.'/../Stubs/Layout.stub') 145 | ); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /resources/js/components/SearchMenu.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 127 | 128 | 133 | -------------------------------------------------------------------------------- /resources/js/components/OriginalDropMenu.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 137 | 138 | 153 | -------------------------------------------------------------------------------- /src/Concerns/HasFlexible.php: -------------------------------------------------------------------------------- 1 | attributes, $attribute); 24 | 25 | return $this->cast($flexible, $layoutMapping); 26 | } 27 | 28 | /** 29 | * Cast a Flexible Content value 30 | * 31 | * @param mixed $value 32 | * @param array $layoutMapping 33 | * @return \Whitecube\NovaFlexibleContent\Layouts\Collection 34 | */ 35 | public function cast($value, $layoutMapping = []) 36 | { 37 | if (app()->getProvider(NovaServiceProvider::class) && ! app()->runningInConsole()) { 38 | return $value; 39 | } 40 | 41 | return $this->toFlexible($value ?: null, $layoutMapping); 42 | } 43 | 44 | /** 45 | * Parse a Flexible Content from value 46 | * 47 | * @param array|string|\Illuminate\Support\Collection|null $value 48 | * @param array $layoutMapping 49 | * @return \Whitecube\NovaFlexibleContent\Layouts\Collection 50 | */ 51 | public function toFlexible($value, $layoutMapping = []) 52 | { 53 | $flexible = $this->getFlexibleArrayFromValue($value); 54 | 55 | if (is_null($flexible)) { 56 | return new Collection(); 57 | } 58 | 59 | return new Collection( 60 | array_filter($this->getMappedFlexibleLayouts($flexible, $layoutMapping)) 61 | ); 62 | } 63 | 64 | /** 65 | * Transform incoming value into an array of usable layouts 66 | * 67 | * @param array|string|\Illuminate\Support\Collection|null $value 68 | * @return array|null 69 | */ 70 | protected function getFlexibleArrayFromValue($value) 71 | { 72 | if (is_string($value)) { 73 | $value = json_decode($value); 74 | 75 | return is_array($value) ? $value : null; 76 | } 77 | 78 | if (is_a($value, BaseCollection::class)) { 79 | return $value->toArray(); 80 | } 81 | 82 | if (is_array($value)) { 83 | return $value; 84 | } 85 | 86 | return null; 87 | } 88 | 89 | /** 90 | * Map array with Flexible Content Layouts 91 | * 92 | * @param array $flexible 93 | * @param array $layoutMapping 94 | * @return array 95 | */ 96 | protected function getMappedFlexibleLayouts(array $flexible, array $layoutMapping) 97 | { 98 | return array_map(function ($item) use ($layoutMapping) { 99 | return $this->getMappedLayout($item, $layoutMapping); 100 | }, $flexible); 101 | } 102 | 103 | /** 104 | * Transform given layout value into a usable Layout instance 105 | * 106 | * @param mixed $item 107 | * @param array $layoutMapping 108 | * @return null|\Whitecube\NovaFlexibleContent\Layouts\LayoutInterface 109 | */ 110 | protected function getMappedLayout($item, array $layoutMapping) 111 | { 112 | $name = null; 113 | $key = null; 114 | $attributes = []; 115 | 116 | if (is_string($item)) { 117 | $item = json_decode($item); 118 | } 119 | 120 | if (is_array($item)) { 121 | $name = $item['layout'] ?? null; 122 | $key = $item['key'] ?? null; 123 | $attributes = (array) ($item['attributes'] ?? []); 124 | } elseif (is_a($item, \stdClass::class) || is_a($item, Fluent::class)) { 125 | $name = $item->layout ?? null; 126 | $key = $item->key ?? null; 127 | $attributes = (array) ($item->attributes ?? []); 128 | } elseif (is_a($item, Layout::class)) { 129 | $name = $item->name(); 130 | $key = $item->key(); 131 | $attributes = $item->getAttributes(); 132 | } 133 | 134 | if (is_null($name)) { 135 | return null; 136 | } 137 | 138 | return $this->createMappedLayout($name, $key, $attributes, $layoutMapping); 139 | } 140 | 141 | /** 142 | * Transform given layout value into a usable Layout instance 143 | * 144 | * @param string $name 145 | * @param string $key 146 | * @param array $attributes 147 | * @param array $layoutMapping 148 | * @return \Whitecube\NovaFlexibleContent\Layouts\LayoutInterface 149 | */ 150 | protected function createMappedLayout($name, $key, $attributes, array $layoutMapping) 151 | { 152 | $classname = array_key_exists($name, $layoutMapping) 153 | ? $layoutMapping[$name] 154 | : Layout::class; 155 | 156 | $layout = new $classname($name, $name, [], $key, $attributes); 157 | 158 | $model = is_a($this, FlexibleCast::class) 159 | ? $this->model 160 | : $this; 161 | 162 | $layout->setModel($model); 163 | 164 | return $layout; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Concerns/HasMediaLibrary.php: -------------------------------------------------------------------------------- 1 | model; 29 | 30 | while ($model instanceof Layout) { 31 | $model = $model->getMediaModel(); 32 | } 33 | 34 | if (is_null($model) || ! ($model instanceof HasMedia)) { 35 | throw new \Exception('Origin HasMedia model not found.'); 36 | } 37 | 38 | return $model; 39 | } 40 | 41 | /** 42 | * Add a file to the medialibrary. 43 | * 44 | * @param string|\Symfony\Component\HttpFoundation\File\UploadedFile $file 45 | * @return \Spatie\MediaLibrary\MediaCollections\FileAdder 46 | */ 47 | public function addMedia($file): \Spatie\MediaLibrary\MediaCollections\FileAdder 48 | { 49 | return app(FileAdderFactory::class) 50 | ->create($this->getUnderlyingMediaModel(), $file, $this->getSuffix()) 51 | ->preservingOriginal(); 52 | } 53 | 54 | /** 55 | * This is a slightly altered version of Spatie's addMediaFromUrl, tweaked 56 | * based on the overridden addMedia method in this class. 57 | * 58 | * @param string $url 59 | * @param string|array ...$allowedMimeTypes 60 | */ 61 | public function addMediaFromUrl($url, ...$allowedMimeTypes): \Spatie\MediaLibrary\MediaCollections\FileAdder 62 | { 63 | if (! Str::startsWith($url, ['http://', 'https://'])) { 64 | throw InvalidUrl::doesNotStartWithProtocol($url); 65 | } 66 | 67 | $downloader = config( 68 | 'media-library.media_downloader', 69 | DefaultDownloader::class 70 | ); 71 | $temporaryFile = (new $downloader())->getTempFile($url); 72 | $this->guardAgainstInvalidMimeType($temporaryFile, $allowedMimeTypes); 73 | 74 | $filename = basename(parse_url($url, PHP_URL_PATH)); 75 | $filename = urldecode($filename); 76 | 77 | if ($filename === '') { 78 | $filename = 'file'; 79 | } 80 | 81 | $mediaExtension = explode('/', mime_content_type($temporaryFile)); 82 | 83 | if (! Str::contains($filename, '.')) { 84 | $filename = "{$filename}.{$mediaExtension[1]}"; 85 | } 86 | 87 | return app(FileAdderFactory::class) 88 | ->create($this->getUnderlyingMediaModel(), $temporaryFile, $this->getSuffix()) 89 | ->usingName(pathinfo($filename, PATHINFO_FILENAME)) 90 | ->usingFileName($filename); 91 | } 92 | 93 | /** 94 | * Get media collection by its collectionName. 95 | * 96 | * @param string $collectionName 97 | * @param array|callable $filters 98 | * @return \Illuminate\Support\Collection 99 | */ 100 | public function getMedia(string $collectionName = 'default', $filters = []): Collection 101 | { 102 | return app(MediaRepository::class) 103 | ->getCollection($this->getUnderlyingMediaModel(), $collectionName.$this->getSuffix(), $filters); 104 | } 105 | 106 | /** 107 | * Get the media collection name suffix. 108 | * 109 | * @return string 110 | */ 111 | public function getSuffix() 112 | { 113 | return '_'.$this->inUseKey(); 114 | } 115 | 116 | /** 117 | * Resolve fields for display using given attributes. 118 | * 119 | * @param array $attributes 120 | * @return array 121 | */ 122 | public function resolveForDisplay(array $attributes = []) 123 | { 124 | $this->fields->each(function ($field) use ($attributes) { 125 | if (is_a($field, Media::class)) { 126 | $field->resolveForDisplay($this->getUnderlyingMediaModel(), $field->attribute.$this->getSuffix()); 127 | } else { 128 | $field->resolveForDisplay($attributes); 129 | } 130 | }); 131 | 132 | return $this->getResolvedValue(); 133 | } 134 | 135 | /** 136 | * The default behaviour when removed 137 | * Should remove all related medias except if shouldDeletePreservingMedia returns true 138 | * 139 | * @param Flexible $flexible 140 | * @param Layout $layout 141 | * @return mixed 142 | */ 143 | protected function removeCallback(Flexible $flexible, $layout) 144 | { 145 | if ($this->shouldDeletePreservingMedia()) { 146 | return; 147 | } 148 | 149 | $collectionsToClear = config('media-library.media_model')::select('collection_name') 150 | ->where('collection_name', 'like', '%'.$this->getSuffix()) 151 | ->distinct() 152 | ->pluck('collection_name') 153 | ->map(function ($value) { 154 | return str_replace($this->getSuffix(), '', $value); 155 | }); 156 | 157 | foreach ($collectionsToClear as $collection) { 158 | $layout->clearMediaCollection($collection); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Http/ParsesFlexibleAttributes.php: -------------------------------------------------------------------------------- 1 | method(), ['POST', 'PUT']) && 25 | is_string($request->input(FlexibleAttribute::REGISTER)); 26 | } 27 | 28 | /** 29 | * Transform the request's flexible values 30 | * 31 | * @param \Illuminate\Http\Request $request 32 | * @return array 33 | */ 34 | protected function getParsedFlexibleInputs(Request $request) 35 | { 36 | $this->registerFlexibleFields($request->input(FlexibleAttribute::REGISTER)); 37 | 38 | return array_reduce(array_keys($request->all()), function ($carry, $attribute) use ($request) { 39 | $value = $request->input($attribute); 40 | 41 | if (! $this->isFlexibleAttribute($attribute, $value)) { 42 | return $carry; 43 | } 44 | 45 | $carry[$attribute] = $this->getParsedFlexibleValue($value); 46 | 47 | return $carry; 48 | }, []); 49 | } 50 | 51 | /** 52 | * Apply JSON decode and recursively check for nested values 53 | * 54 | * @param mixed $value 55 | * @return array 56 | */ 57 | protected function getParsedFlexibleValue($value) 58 | { 59 | if (is_string($value)) { 60 | $raw = json_decode($value, true); 61 | } else { 62 | $raw = $value; 63 | } 64 | 65 | if (! is_array($raw)) { 66 | return $value; 67 | } 68 | 69 | return array_map(function ($group) { 70 | return $this->getParsedFlexibleGroup($group); 71 | }, $raw); 72 | } 73 | 74 | /** 75 | * Cleans & prepares a filled group 76 | * 77 | * @param array $group 78 | * @return array 79 | */ 80 | protected function getParsedFlexibleGroup($group) 81 | { 82 | $clean = [ 83 | 'layout' => $group['layout'] ?? null, 84 | 'key' => $group['key'] ?? null, 85 | 'attributes' => [], 86 | ]; 87 | 88 | foreach ($group['attributes'] ?? [] as $attribute => $value) { 89 | $this->fillFlexibleAttributes($clean['attributes'], $clean['key'], $attribute, $value); 90 | } 91 | 92 | foreach ($clean['attributes'] as $attribute => $value) { 93 | if (! $this->isFlexibleAttribute($attribute, $value)) { 94 | continue; 95 | } 96 | $clean['attributes'][$attribute] = $this->getParsedFlexibleValue($value); 97 | } 98 | 99 | return $clean; 100 | } 101 | 102 | /** 103 | * Fill a flexible group's attributes with cleaned attributes & values 104 | * 105 | * @param array $attributes 106 | * @param string $group 107 | * @param string $attribute 108 | * @param string $value 109 | * @return void 110 | */ 111 | protected function fillFlexibleAttributes(&$attributes, $group, $attribute, $value) 112 | { 113 | $attribute = $this->parseAttribute($attribute, $group); 114 | 115 | if ($attribute->isFlexibleFieldsRegister()) { 116 | $this->registerFlexibleFields($value, $group); 117 | 118 | return; 119 | } 120 | 121 | $attribute->setDataIn($attributes, $value); 122 | } 123 | 124 | /** 125 | * Analyse and clean up the raw attribute 126 | * 127 | * @param string $attribute 128 | * @param string $group 129 | * @return \Whitecube\NovaFlexibleContent\Http\FlexibleAttribute 130 | */ 131 | protected function parseAttribute($attribute, $group) 132 | { 133 | return new FlexibleAttribute($attribute, $group); 134 | } 135 | 136 | /** 137 | * Add flexible attributes to the register 138 | * 139 | * @param null|string $value 140 | * @param null|string $group 141 | * @return void 142 | */ 143 | protected function registerFlexibleFields($value, $group = null) 144 | { 145 | if (! $value) { 146 | return; 147 | } 148 | 149 | if (! is_array($value)) { 150 | $value = json_decode($value); 151 | } 152 | 153 | foreach ($value as $attribute) { 154 | $this->registerFlexibleField($attribute, $group); 155 | } 156 | } 157 | 158 | /** 159 | * Add an attribute to the register 160 | * 161 | * @param mixed $attribute 162 | * @param null|string $group 163 | * @return void 164 | */ 165 | protected function registerFlexibleField($attribute, $group = null) 166 | { 167 | $attribute = $this->parseAttribute(strval($attribute), $group); 168 | 169 | $this->registered[] = $attribute; 170 | } 171 | 172 | /** 173 | * Check if given attribute is a registered and usable 174 | * flexible attribute 175 | * 176 | * @param string $attribute 177 | * @param mixed $value 178 | * @return bool 179 | */ 180 | protected function isFlexibleAttribute($attribute, $value) 181 | { 182 | if (! $this->getFlexibleAttribute($attribute)) { 183 | return false; 184 | } 185 | 186 | if (! $value || ! is_string($value)) { 187 | return false; 188 | } 189 | 190 | return true; 191 | } 192 | 193 | /** 194 | * Retrieve a registered flexible attribute 195 | * 196 | * @param string $attribute 197 | * @return \Whitecube\NovaFlexibleContent\Http\FlexibleAttribute 198 | */ 199 | protected function getFlexibleAttribute($attribute) 200 | { 201 | foreach ($this->registered as $registered) { 202 | if ($registered->name === $attribute) { 203 | return $registered; 204 | } 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Http/ScopedRequest.php: -------------------------------------------------------------------------------- 1 | scopeInto($group, $attributes); 34 | } 35 | 36 | /** 37 | * Alter the request's input for given group key & attributes 38 | * 39 | * @param string $group 40 | * @param array $attributes 41 | * @return $this 42 | */ 43 | public function scopeInto($group, $attributes) 44 | { 45 | $this->group = $group; 46 | 47 | [$input, $files] = $this->getScopeState($group, $attributes); 48 | 49 | $input['_method'] = $this->input('_method'); 50 | $input['_retrieved_at'] = $this->input('_retrieved_at'); 51 | 52 | $this->handleScopeFiles($files, $input, $group); 53 | 54 | $this->replace($input); 55 | $this->files->replace($files); 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Get the target scope configuration array 62 | * 63 | * @param string $group 64 | * @param array $attributes 65 | * @return array 66 | */ 67 | protected function getScopeState($group, $attributes) 68 | { 69 | $input = []; 70 | $files = []; 71 | 72 | foreach ($attributes as $attribute => $value) { 73 | $attribute = FlexibleAttribute::make($attribute, $group, is_array($value)); 74 | 75 | // Sub-objects could contain files that need to be kept 76 | if ($attribute->isAggregate()) { 77 | $files = array_merge($files, $this->getNestedFiles($value, $attribute->group)); 78 | $input[$attribute->name] = $value; 79 | 80 | continue; 81 | } 82 | 83 | // Register Files 84 | if ($attribute->isFlexibleFile($value)) { 85 | $files[] = $attribute->getFlexibleFileAttribute($value); 86 | 87 | continue; 88 | } 89 | 90 | // Register regular attributes 91 | $input[$attribute->name] = $value; 92 | } 93 | 94 | return [$input, $files]; 95 | } 96 | 97 | /** 98 | * Get nested file attributes from given array 99 | * 100 | * @param array $iterable 101 | * @param null|string $group 102 | * @return array 103 | */ 104 | protected function getNestedFiles($iterable, $group = null) 105 | { 106 | $files = []; 107 | $key = $this->isFlexibleStructure($iterable) ? $iterable['key'] : $group; 108 | 109 | foreach ($iterable as $original => $value) { 110 | if (is_array($value)) { 111 | $files = array_merge($files, $this->getNestedFiles($value, $key)); 112 | 113 | continue; 114 | } 115 | 116 | $attribute = FlexibleAttribute::make($original, $group); 117 | 118 | if (! $attribute->isFlexibleFile($value)) { 119 | continue; 120 | } 121 | 122 | $files[] = $attribute->getFlexibleFileAttribute($value); 123 | } 124 | 125 | return $files; 126 | } 127 | 128 | /** 129 | * Get all useful files from current files list 130 | * 131 | * @param array $files 132 | * @param array $input 133 | * @param string $group 134 | * @return void 135 | */ 136 | protected function handleScopeFiles(&$files, &$input, $group) 137 | { 138 | $attributes = collect($files)->keyBy('original'); 139 | 140 | $this->fileAttributes = $attributes->mapWithKeys(function($attribute, $key) { 141 | return [$attribute->name => $key]; 142 | }); 143 | 144 | $scope = []; 145 | 146 | foreach ($this->getFlattenedFiles() as $attribute => $file) { 147 | if (! ($target = $attributes->get($attribute))) { 148 | continue; 149 | } 150 | 151 | if (! $target->group || $target->group !== $group) { 152 | $scope[$target->original] = $file; 153 | 154 | continue; 155 | } 156 | 157 | $target->setDataIn($scope, $file); 158 | $target->unsetDataIn($input); 159 | } 160 | 161 | $files = $scope; 162 | } 163 | 164 | /** 165 | * Get the request's files as a "flat" (1 dimension) array 166 | * 167 | * @return array 168 | */ 169 | protected function getFlattenedFiles($iterable = null, FlexibleAttribute $original = null) 170 | { 171 | $files = []; 172 | 173 | foreach ($iterable ?? $this->files->all() as $key => $value) { 174 | $attribute = $original ? $original->nest($key) : FlexibleAttribute::make($key); 175 | 176 | if (! is_array($value)) { 177 | $files[$attribute->original] = $value; 178 | 179 | continue; 180 | } 181 | 182 | $files = array_merge($files, $this->getFlattenedFiles($value, $attribute)); 183 | } 184 | 185 | return $files; 186 | } 187 | 188 | /** 189 | * Check if the given array represents a flexible group 190 | * 191 | * @param array $iterable 192 | * @return bool 193 | */ 194 | protected function isFlexibleStructure($iterable) 195 | { 196 | $keys = array_keys($iterable); 197 | 198 | if (count($keys) !== 3) { 199 | return false; 200 | } 201 | 202 | return in_array('layout', $keys, true) 203 | && in_array('key', $keys, true) 204 | && in_array('attributes', $keys, true); 205 | } 206 | 207 | /** 208 | * Check if the given argument is a defined file attribute. 209 | * 210 | * @param string $name 211 | * @return bool 212 | */ 213 | public function isFileAttribute($name) 214 | { 215 | return $this->fileAttributes->has($name); 216 | } 217 | 218 | /** 219 | * Return the actual file input attribute 220 | * 221 | * @param string $name 222 | * @return string 223 | */ 224 | public function getFileAttribute($name) 225 | { 226 | return $this->fileAttributes->get($name); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /resources/js/components/FormGroup.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 234 | 235 | 298 | -------------------------------------------------------------------------------- /resources/js/components/FormField.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 312 | -------------------------------------------------------------------------------- /src/Http/FlexibleAttribute.php: -------------------------------------------------------------------------------- 1 | original = $original; 76 | $this->setUpload(); 77 | $this->setGroup($group); 78 | $this->setKey(); 79 | $this->setName(); 80 | } 81 | 82 | /** 83 | * Build an attribute from its components 84 | * 85 | * @param string $name 86 | * @param string $group 87 | * @param mixed $key 88 | * @param bool $upload 89 | * @return \Whitecube\NovaFlexibleContent\Http\FlexibleAttribute 90 | */ 91 | public static function make($name, $group = null, $key = null, $upload = false) 92 | { 93 | $original = $upload ? static::FILE_INDICATOR : ''; 94 | $original .= static::formatGroupPrefix($group) ?? ''; 95 | $original .= $name; 96 | $original .= $key ? '['.($key !== true ? $key : '').']' : ''; 97 | 98 | return new static($original, $group); 99 | } 100 | 101 | /** 102 | * Check if attribute is a flexible fields register 103 | * 104 | * @return bool 105 | */ 106 | public function isFlexibleFieldsRegister() 107 | { 108 | return $this->name === static::REGISTER; 109 | } 110 | 111 | /** 112 | * Check if attribute or given value match a probable file 113 | * 114 | * @param mixed $value 115 | * @return bool 116 | */ 117 | public function isFlexibleFile($value = null) 118 | { 119 | if (! is_null($value) && ! is_string($value)) { 120 | return false; 121 | } elseif (is_null($value)) { 122 | return $this->upload; 123 | } 124 | 125 | return strpos($value, static::FILE_INDICATOR) === 0; 126 | } 127 | 128 | /** 129 | * Return a FlexibleAttribute instance matching the target upload field 130 | * 131 | * @param mixed $value 132 | * @return \Whitecube\NovaFlexibleContent\Http\FlexibleAttribute 133 | */ 134 | public function getFlexibleFileAttribute($value) 135 | { 136 | return new static($value, $this->group); 137 | } 138 | 139 | /** 140 | * Check if attribute represents an array item 141 | * 142 | * @return bool 143 | */ 144 | public function isAggregate() 145 | { 146 | return ! is_null($this->key); 147 | } 148 | 149 | /** 150 | * Check if the found group key is used in the attribute's name 151 | * 152 | * @return bool 153 | */ 154 | public function hasGroupInName() 155 | { 156 | if (is_null($this->group)) { 157 | return false; 158 | } 159 | 160 | $position = strpos($this->original, $this->groupPrefix()); 161 | $index = $this->isFlexibleFile() ? strlen(static::FILE_INDICATOR) : 0; 162 | 163 | return $position === $index; 164 | } 165 | 166 | /** 167 | * Get the group prefix string 168 | * 169 | * @param string $group 170 | * @return null|string 171 | */ 172 | public function groupPrefix($group = null) 173 | { 174 | return static::formatGroupPrefix($group ?? $this->group); 175 | } 176 | 177 | /** 178 | * Get a group prefix string 179 | * 180 | * @param string $group 181 | * @return null|string 182 | */ 183 | public static function formatGroupPrefix($group) 184 | { 185 | if (! $group) { 186 | return; 187 | } 188 | 189 | return $group.static::GROUP_SEPARATOR; 190 | } 191 | 192 | /** 193 | * Set given value in given using the current attribute definition 194 | * 195 | * @param array $attributes 196 | * @param string $value 197 | * @return array 198 | */ 199 | public function setDataIn(&$attributes, $value) 200 | { 201 | $value = is_string($value) && $value === '' ? null : $value; 202 | 203 | if (! $this->isAggregate()) { 204 | $attributes[$this->name] = $value; 205 | 206 | return $attributes; 207 | } 208 | 209 | if (! isset($attributes[$this->name])) { 210 | $attributes[$this->name] = []; 211 | } elseif (! is_array($attributes[$this->name])) { 212 | $attributes[$this->name] = [$attributes[$this->name]]; 213 | } 214 | 215 | if ($this->key === true) { 216 | $attributes[$this->name][] = $value; 217 | } else { 218 | data_set($attributes[$this->name], $this->key, $value); 219 | } 220 | 221 | return $attributes; 222 | } 223 | 224 | /** 225 | * Remove current attribute from given array 226 | * 227 | * @param array $attributes 228 | * @return array 229 | */ 230 | public function unsetDataIn(&$attributes) 231 | { 232 | if (! $this->isAggregate() || ! is_array($attributes[$this->name])) { 233 | unset($attributes[$this->name]); 234 | 235 | return $attributes; 236 | } 237 | 238 | if ($this->key === true) { 239 | array_shift($attributes[$this->name]); 240 | } else { 241 | Arr::forget($attributes[$this->name], $this->key); 242 | } 243 | 244 | return $attributes; 245 | } 246 | 247 | /** 248 | * Return a new instance with appended key 249 | * 250 | * @param string $key 251 | * @return \Whitecube\NovaFlexibleContent\Http\FlexibleAttribute 252 | */ 253 | public function nest($key) 254 | { 255 | $append = implode('', array_map(function ($segment) { 256 | return '['.$segment.']'; 257 | }, explode('.', $key))); 258 | 259 | return new static($this->original.$append, $this->group); 260 | } 261 | 262 | /** 263 | * Check attribute is an "upload" attribute and define it on the object 264 | * 265 | * @return void 266 | */ 267 | protected function setUpload() 268 | { 269 | $this->upload = $this->isFlexibleFile($this->original); 270 | } 271 | 272 | /** 273 | * Check if given group identifier is included in original 274 | * attribute. If so, set it as the group property. 275 | * 276 | * @param mixed $group 277 | * @return void 278 | */ 279 | protected function setGroup($group = null) 280 | { 281 | if (! $group) { 282 | return; 283 | } 284 | 285 | $group = strval($group); 286 | 287 | if (strpos($this->original, $this->groupPrefix($group)) !== false) { 288 | $this->group = $group; 289 | } 290 | } 291 | 292 | /** 293 | * Check if the original attribute contains an aggregate syntax. 294 | * If so, extract the aggregate key and assign it to the key property. 295 | * 296 | * @return void 297 | */ 298 | protected function setKey() 299 | { 300 | preg_match('/^.+?(\[.*\])?$/', $this->original, $arrayMatches); 301 | 302 | if (! isset($arrayMatches[1])) { 303 | return; 304 | } 305 | 306 | preg_match_all('/(?:\[([^\[\]]*)\])+?/', $arrayMatches[1], $keyMatches); 307 | 308 | $key = implode('.', array_map(function ($segment) { 309 | return $this->getCleanKeySegment($segment); 310 | }, $keyMatches[1])); 311 | 312 | $this->key = strlen($key) ? $key : true; 313 | } 314 | 315 | /** 316 | * Formats a key segment (removes unwanted characters, removes 317 | * group references from). 318 | * 319 | * @param string $segment 320 | * @return string 321 | */ 322 | protected function getCleanKeySegment($segment) 323 | { 324 | $segment = trim($segment, "'\" \t\n\r\0\x0B"); 325 | 326 | if ($this->group && strpos($segment, $this->groupPrefix()) === 0) { 327 | return (new static($segment, $this->group))->name; 328 | } 329 | 330 | return $segment; 331 | } 332 | 333 | /** 334 | * Extract the attribute's final name 335 | * 336 | * @return void 337 | */ 338 | protected function setName() 339 | { 340 | $name = trim($this->original); 341 | 342 | if ($this->isFlexibleFile()) { 343 | $position = strpos($name, static::FILE_INDICATOR) + strlen(static::FILE_INDICATOR); 344 | $name = substr($name, $position); 345 | } 346 | 347 | if ($this->hasGroupInName()) { 348 | $position = strpos($name, $this->group) + strlen($this->groupPrefix()); 349 | $name = substr($name, $position); 350 | } 351 | 352 | if ($this->isAggregate()) { 353 | $position = strpos($name, '['); 354 | $name = substr($name, 0, $position); 355 | } 356 | 357 | $this->name = $name; 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Laravel Nova Flexible Content](https://github.com/whitecube/nova-flexible-content/raw/master/title.png) 2 | 3 | ![](https://img.shields.io/github/release/whitecube/nova-flexible-content.svg?style=flat) 4 | [![](https://img.shields.io/packagist/dt/whitecube/nova-flexible-content.svg?colorB=green&style=flat)](https://packagist.org/packages/whitecube/nova-flexible-content) 5 | [![](https://img.shields.io/github/license/whitecube/nova-flexible-content.svg?style=flat)](https://github.com/whitecube/nova-flexible-content/blob/master/LICENSE) 6 | 7 | An easy & complete Flexible Field for Laravel Nova, perfect for repeated and flexible field groups. 8 | 9 | ![Laravel Nova Flexible Content in action](https://user-images.githubusercontent.com/9298484/164532562-6e4e4179-8a53-470c-97c8-237e9a2c2ebb.gif) 10 | 11 | ## We are looking for someone to help us maintain this package! 12 | 13 | We'd love to accept someone who uses this package a lot to help us review and merge incoming PRs. Shoot us a message at hello@whitecube.be if you're willing to help! 14 | 15 | ## Quick start 16 | 17 | Here's a very condensed guide to get you started asap. 18 | See the full docs at [https://whitecube.github.io/nova-flexible-content](https://whitecube.github.io/nova-flexible-content) 19 | 20 | ### Install 21 | 22 | ``` 23 | composer require whitecube/nova-flexible-content 24 | ``` 25 | 26 | ### Usage 27 | 28 | A flexible field allows easy management of repeatable and orderable groups of fields. As opposed to the few existing solutions for Laravel Nova, this one does not have constraints on which fields you are allowed to use within these groups. That means you can use all Laravel Nova field types, and also any community-made fields. 29 | 30 | #### Adding layouts 31 | 32 | A layout represents a group of fields that can be repeated inside the Flexible field. You can add as many layouts as you wish. If only one layout is defined the field will behave like a simple Repeater and by adding more layouts you'll obtain a Flexible Content. Both concepts are similar to [their cousins in Wordpress' ACF Plugin](https://www.advancedcustomfields.com/add-ons/). 33 | 34 | Layouts can be added using the following method on your Flexible fields: 35 | ```php 36 | addLayout(string $title, string $name, array $fields) 37 | ``` 38 | 39 | The `$name` parameter is used to store the chosen layout in the field's value. Choose it wisely, you'll probably use it to identify the layouts in your application. 40 | 41 | ```php 42 | use Whitecube\NovaFlexibleContent\Flexible; 43 | 44 | /** 45 | * Get the fields displayed by the resource. 46 | * 47 | * @param \Illuminate\Http\Request $request 48 | * @return array 49 | */ 50 | public function fields(Request $request) 51 | { 52 | return [ 53 | // ... 54 | 55 | Flexible::make('Content') 56 | ->addLayout('Simple content section', 'wysiwyg', [ 57 | Text::make('Title'), 58 | Markdown::make('Content') 59 | ]) 60 | ->addLayout('Video section', 'video', [ 61 | Text::make('Title'), 62 | Image::make('Video Thumbnail', 'thumbnail'), 63 | Text::make('Video ID (YouTube)', 'video'), 64 | Text::make('Video Caption', 'caption') 65 | ]) 66 | ]; 67 | } 68 | ``` 69 | ![Example of Flexible layouts](https://user-images.githubusercontent.com/9298484/164533823-1b1b4934-75b8-49f2-92a0-a54812ccf463.png) 70 | 71 | 72 | #### Customizing the button label 73 | 74 | You can change the default "Add layout" button's text like so: 75 | 76 | ```php 77 | Flexible::make('Content') 78 | ->button('Add something amazing!'); 79 | ``` 80 | 81 | ![Add something amazing](https://user-images.githubusercontent.com/9298484/164544726-2a2b1ce5-aa19-489c-abee-b877e7e8d445.png) 82 | 83 | 84 | ### Using Flexible values in views 85 | 86 | If you are using Laravel 6 and under, or don't want to use casts, please [use an accessor on your model with the HasFlexible trait](https://whitecube.github.io/nova-flexible-content/#/?id=with-the-hasflexible-trait). 87 | 88 | Laravel 7 brings custom casts to the table, and flexible content fields are the perfect use case for them. The field stores its values as a single JSON string, meaning this string needs to be parsed before it can be used in your application. This can be done trivially by using the `FlexibleCast` class in this package: 89 | 90 | ```php 91 | namespace App; 92 | 93 | use Illuminate\Database\Eloquent\Model; 94 | use Whitecube\NovaFlexibleContent\Value\FlexibleCast; 95 | 96 | class MyModel extends Model 97 | { 98 | protected $casts = [ 99 | 'flexible-content' => FlexibleCast::class 100 | ]; 101 | } 102 | ``` 103 | 104 | By default, the `FlexibleCast` class will collect basic `Layout` instances. If you want to map the layouts into [Custom Layout instances](https://github.com/whitecube/nova-flexible-content#custom-layout-classes), it is also possible. First, create a custom flexible cast by running `php artisan flexible:cast MyFlexibleCast`. This will create the file in the `App\Casts` directory. 105 | 106 | Then easily map your custom layout classes to the proper keys: 107 | 108 | ```php 109 | namespace App\Casts; 110 | 111 | class MyFlexibleCast extends FlexibleCast 112 | { 113 | protected $layouts = [ 114 | 'wysiwyg' => \App\Nova\Flexible\Layouts\WysiwygLayout::class, 115 | 'video' => \App\Nova\Flexible\Layouts\VideoLayout::class, 116 | ] 117 | } 118 | ``` 119 | 120 | If you need more control, you can [override the `getLayoutMappings` method](https://whitecube.github.io/nova-flexible-content/#/?id=having-more-control-over-the-layout-mappings) instead. 121 | 122 | #### The Layouts Collection 123 | 124 | Collections returned by the `FlexibleCast` cast and the `HasFlexible` trait extend the original `Illuminate\Support\Collection`. These custom layout collections expose a `find(string $name)` method which returns the first layout having the given layout `$name`. 125 | 126 | #### The Layout instance 127 | 128 | Layouts are some kind of _fake models_. They use Laravel's `HasAttributes` trait, which means you can define accessors & mutators for the layout's attributes. Furthermore, it's also possible to access the Layout's properties using the following methods: 129 | 130 | ##### `name()` 131 | 132 | Returns the layout's name. 133 | 134 | ##### `title()` 135 | 136 | Returns the layout's title (as shown in Nova). 137 | 138 | ##### `key()` 139 | 140 | Returns the layout's unique key (the layout's unique identifier). 141 | 142 | ## Going further 143 | 144 | When using the Flexible Content field, you'll quickly come across of some use cases where the basics described above are not enough. That's why we developed the package in an extendable way, making it possible to easily add custom behaviors and/or capabilities to Field and its output. 145 | 146 | ### Custom Layout Classes 147 | 148 | Sometimes, `addLayout` definitions can get quite long, or maybe you want them to be shared with other `Flexible` fields. The answer to this is to extract your Layout into its own class. [See the docs for more information on this](https://whitecube.github.io/nova-flexible-content/#/?id=custom-layout-classes). 149 | 150 | ### Predefined Preset Classes 151 | 152 | In addition to reusable Layout classes, you can go a step further and create `Preset` classes for your Flexible fields. These allow you to reuse your whole Flexible field anywhere you want. They also make it easier to make your Flexible fields dynamic, for example if you want to add Layouts conditionally. And last but not least, they also have the added benefit of cleaning up your Nova Resource classes, if your Flexible field has a lot of `addLayout` definitions. [See the docs for more information on this](https://whitecube.github.io/nova-flexible-content/#/?id=predefined-preset-classes). 153 | 154 | ### Custom Resolver Classes 155 | 156 | By default, the field takes advantage of a **JSON column** on your model's table. In some cases, you'd really like to use this field, but for some reason a JSON attribute is just not the way to go. For example, you could want to store the values in another table (meaning you'll be using the Flexible Content field instead of a traditional BelongsToMany or HasMany field). No worries, we've got you covered! 157 | 158 | Tell the field how to store and retrieve its content by creating your own Resolver class, which basically just contains two simple methods: `get` and `set`. [See the docs for more information on this](https://whitecube.github.io/nova-flexible-content/#/?id=custom-resolver-classes). 159 | 160 | ### Usage with nova-page 161 | 162 | Maybe you heard of one of our other packages, [nova-page](https://github.com/whitecube/nova-page), which is a Nova Tool that allows to edit static pages such as an _"About"_ page (or similar) without having to declare a model for it individually. More often than not, the Flexible Content Field comes in handy. Don't worry, both packages work well together! First create a [nova page template](https://github.com/whitecube/nova-page#creating-templates) and add a [flexible content](https://github.com/whitecube/nova-flexible-content#adding-layouts) to the template's fields. 163 | 164 | As explained in the documentation, you can [access nova-page's static content](https://github.com/whitecube/nova-page#accessing-the-data-in-your-views) in your blade views using `{{ Page::get('attribute') }}`. When requesting the flexible content like this, it returns a raw JSON string describing the flexible content, which is of course not very useful. Instead, you can simply implement the `Whitecube\NovaFlexibleContent\Concerns\HasFlexible` trait on your page Templates, which will expose the `Page::flexible('attribute')` facade method and will take care of the flexible content's transformation. 165 | 166 | ```php 167 | namespace App\Nova\Templates; 168 | 169 | // ... 170 | use Whitecube\NovaFlexibleContent\Concerns\HasFlexible; 171 | 172 | class Home extends Template 173 | { 174 | use HasFlexible; 175 | 176 | // ... 177 | } 178 | ``` 179 | 180 | ## 💖 Sponsorships 181 | 182 | If you are reliant on this package in your production applications, consider [sponsoring us](https://github.com/sponsors/whitecube)! It is the best way to help us keep doing what we love to do: making great open source software. 183 | 184 | ## Contributing 185 | 186 | Feel free to suggest changes, ask for new features or fix bugs yourself. We're sure there are still a lot of improvements that could be made, and we would be very happy to merge useful pull requests. 187 | 188 | Thanks! 189 | 190 | ### Unit tests 191 | 192 | When adding a new feature or fixing a bug, please add corresponding unit tests. The current set of tests is limited, but every unit test added will improve the quality of the package. 193 | 194 | Run PHPUnit by calling `composer test`. 195 | 196 | ## Made with ❤️ for open source 197 | 198 | At [Whitecube](https://www.whitecube.be) we use a lot of open source software as part of our daily work. 199 | So when we have an opportunity to give something back, we're super excited! 200 | 201 | We hope you will enjoy this small contribution from us and would love to [hear from you](mailto:hello@whitecube.be) if you find it useful in your projects. Follow us on [Twitter](https://twitter.com/whitecube_be) for more updates! 202 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # ![Laravel Nova Flexible Content](https://github.com/whitecube/nova-flexible-content/raw/master/title.png) 2 | 3 | An easy & complete Flexible Field for Laravel Nova, perfect for repeated and flexible field groups. 4 | 5 | ![Laravel Nova Flexible Content in action](https://user-images.githubusercontent.com/9298484/164532562-6e4e4179-8a53-470c-97c8-237e9a2c2ebb.gif) 6 | 7 | ## Quick start 8 | 9 | The Flexible field can be used in various ways and for different purposes, but in most cases you'll only need a few of its capabilities. Here's how to get started really quickly. 10 | 11 | ### Install 12 | 13 | ``` 14 | composer require whitecube/nova-flexible-content 15 | ``` 16 | 17 | ### Usage 18 | 19 | A flexible field allows easy management of repeatable and orderable groups of fields. As opposed to the few existing solutions for Laravel Nova, this one does not have constraints on which fields you are allowed to use within these groups. That means you can use all Laravel Nova field types, and also any community-made fields. 20 | 21 | #### Adding layouts 22 | 23 | A layout represents a group of fields that can be repeated inside the Flexible field. You can add as many different layouts as you wish. If only one layout is defined the field will behave like a simple Repeater and by adding more layouts you'll obtain a Flexible Content. Both concepts are similar to [their cousins in Wordpress' ACF Plugin](https://www.advancedcustomfields.com/add-ons/). 24 | 25 | Layouts can be added using the following method on your Flexible fields: 26 | ```php 27 | addLayout(string $title, string $name, array $fields) 28 | ``` 29 | 30 | The `$name` parameter is used to store the chosen layout in the field's value. Choose it wisely, you'll probably use it to identify the layouts in your application. 31 | 32 | ```php 33 | use Whitecube\NovaFlexibleContent\Flexible; 34 | 35 | /** 36 | * Get the fields displayed by the resource. 37 | * 38 | * @param \Illuminate\Http\Request $request 39 | * @return array 40 | */ 41 | public function fields(Request $request) 42 | { 43 | return [ 44 | // ... 45 | 46 | Flexible::make('Content') 47 | ->addLayout('Simple content section', 'wysiwyg', [ 48 | Text::make('Title'), 49 | Markdown::make('Content') 50 | ]) 51 | ->addLayout('Video section', 'video', [ 52 | Text::make('Title'), 53 | Image::make('Video Thumbnail', 'thumbnail'), 54 | Text::make('Video ID (YouTube)', 'video'), 55 | Text::make('Video Caption', 'caption') 56 | ]) 57 | ]; 58 | } 59 | ``` 60 | ![Example of Flexible layouts](https://user-images.githubusercontent.com/9298484/164533823-1b1b4934-75b8-49f2-92a0-a54812ccf463.png) 61 | 62 | #### Customizing the button label 63 | 64 | You can change the default "Add layout" button's text like so: 65 | 66 | ```php 67 | Flexible::make('Content') 68 | ->button('Add something amazing!'); 69 | ``` 70 | 71 | ![Add something amazing](https://user-images.githubusercontent.com/9298484/164544726-2a2b1ce5-aa19-489c-abee-b877e7e8d445.png) 72 | 73 | #### Making the field full width 74 | 75 | You can make the flexible field full width, taking up all available space on the form, and moving the label above the field by doing the following: 76 | 77 | ```php 78 | Flexible::make('Content') 79 | ->fullWidth() 80 | ``` 81 | 82 | #### Limiting layouts 83 | 84 | You can limit how many times the "Add Layout" button will appear by doing the following: 85 | 86 | ```php 87 | Flexible::make('Content')->limit(2); 88 | ``` 89 | 90 | You can specify any integer, or no integer at all; in that case it will default to 1. 91 | 92 | #### Layout removal confirmation 93 | 94 | You can choose to display a confirmation prompt before a layout is deleted by doing: 95 | 96 | ```php 97 | Flexible::make('Content')->confirmRemove(); 98 | 99 | // You can override the text as well 100 | Flexible::make('Content')->confirmRemove($label = '', $yes = 'Delete', $no = 'Cancel'); 101 | ``` 102 | 103 | ![Confirm remove](https://user-images.githubusercontent.com/9298484/164546875-cf84e57d-5eab-41bc-92aa-3ab0d7f46b4d.png) 104 | 105 | #### Layout selection menu 106 | 107 | You can customize the way your user selects a layout, you can choose between 'flexible-drop-menu' and 'flexible-search-menu' or create your own custom menu component. 108 | 109 | ```php 110 | // Default, simple list of all layouts 111 | Flexible::make('Content')->menu('flexible-drop-menu'); 112 | 113 | // searchable select field 114 | Flexible::make('Content')->menu('flexible-search-menu'); 115 | 116 | // customized searchable select field 117 | Flexible::make('Content') 118 | ->menu( 119 | 'flexible-search-menu', 120 | [ 121 | 'selectLabel' => 'Press enter to select', 122 | // the property on the layout entry 123 | 'label' => 'title', 124 | // 'top', 'bottom', 'auto' 125 | 'openDirection' => 'bottom', 126 | ] 127 | ); 128 | ``` 129 | 130 | All you're doing here is defining which Vue component needs to be used. 131 | 132 | You can take `resources/js/components/OriginalDropMenu.vue` or `resources/js/components/SearchMenu.vue` as a starting point. 133 | 134 | ## Using Flexible values in views 135 | 136 | The field stores its values as a single JSON string, meaning this string needs to be parsed before it can be used in your application. 137 | 138 | ### With casts 139 | 140 | This can be done trivially by using the `FlexibleCast` class in this package: 141 | 142 | ```php 143 | namespace App; 144 | 145 | use Illuminate\Database\Eloquent\Model; 146 | use Whitecube\NovaFlexibleContent\Value\FlexibleCast; 147 | 148 | class MyModel extends Model 149 | { 150 | protected $casts = [ 151 | 'flexible-content' => FlexibleCast::class 152 | ]; 153 | } 154 | ``` 155 | 156 | #### Writing a custom flexible cast 157 | 158 | By default, the `FlexibleCast` class will collect basic `Layout` instances. If you want to map the layouts into [Custom Layout instances](https://github.com/whitecube/nova-flexible-content#custom-layout-classes), it is also possible. First, create a custom flexible cast by running `php artisan flexible:cast MyFlexibleCast`. This will create the file in the `App\Casts` directory. 159 | 160 | Then easily map your custom layout classes to the proper keys: 161 | 162 | ```php 163 | namespace App\Casts; 164 | 165 | class MyFlexibleCast extends FlexibleCast 166 | { 167 | protected $layouts = [ 168 | 'wysiwyg' => \App\Nova\Flexible\Layouts\WysiwygLayout::class, 169 | 'video' => \App\Nova\Flexible\Layouts\VideoLayout::class, 170 | ]; 171 | } 172 | ``` 173 | 174 | #### Having more control over the layout mappings 175 | 176 | If you need to do complex things with your mappings instead of having a static array as shown above, you can override the `getLayoutMappings` method on your cast. 177 | 178 | ```php 179 | namespace App\Casts; 180 | 181 | class MyFlexibleCast extends FlexibleCast 182 | { 183 | protected function getLayoutMappings() 184 | { 185 | $mappings = []; 186 | 187 | // Conditionally add mappings however you want 188 | 189 | return $mappings; 190 | } 191 | } 192 | ``` 193 | 194 | ### With the `HasFlexible` trait 195 | 196 | By implementing the `HasFlexible` trait on your models, you can call the `flexible($attribute)` method, which will automatically transform the attribute's value into a fully parsed `Whitecube\NovaFlexibleContent\Layouts\Collection`. Feel free to apply this `flexible()` call directly in your blade views or to extract it into an attribute's mutator method as shown below: 197 | 198 | ```php 199 | namespace App; 200 | 201 | use Illuminate\Database\Eloquent\Model; 202 | use Whitecube\NovaFlexibleContent\Concerns\HasFlexible; 203 | 204 | class MyModel extends Model 205 | { 206 | use HasFlexible; 207 | 208 | public function getFlexibleContentAttribute() 209 | { 210 | return $this->flexible('flexible-content'); 211 | } 212 | } 213 | ``` 214 | 215 | By default, the `HasFlexible` trait will collect basic `Layout` instances. If you want to map the layouts into [Custom Layout instances](https://github.com/whitecube/nova-flexible-content#custom-layout-classes), it is also possible to specify the mapping rules as follows: 216 | 217 | ```php 218 | public function getFlexibleContentAttribute() 219 | { 220 | return $this->flexible('flexible-content', [ 221 | 'wysiwyg' => \App\Nova\Flexible\Layouts\WysiwygLayout::class, 222 | 'video' => \App\Nova\Flexible\Layouts\VideoLayout::class, 223 | ]); 224 | } 225 | ``` 226 | 227 | ## Layouts 228 | 229 | ### The Layouts Collection 230 | 231 | Collections returned by `FlexibleCast` or the `HasFlexible` trait extend the original `Illuminate\Support\Collection`. These custom layout collections expose a `find(string $name)` method which returns the first layout having the given layout `$name`. 232 | 233 | ### The Layout instance 234 | 235 | Layouts are some kind of _fake models_. They use Laravel's `HasAttributes` trait, which means you can define accessors & mutators for the layout's attributes. 236 | 237 | Each Layout (or custom layout extending the base Layout) already implements the `HasFlexible` trait, meaning you can directly use the `$layout->flexible('my-sub-layout')` method to parse nested flexible content values. 238 | 239 | Furthermore, it's also possible to access the Layout's properties using the following methods: 240 | 241 | ##### `name()` 242 | 243 | Returns the layout's name. 244 | 245 | ##### `title()` 246 | 247 | Returns the layout's title (as shown in Nova). 248 | 249 | ##### `key()` 250 | 251 | Returns the layout's unique key (the layout's unique identifier). 252 | 253 | 254 | ## Going further 255 | 256 | When using the Flexible Content field, you'll quickly come across of some use cases where the basics described above are not enough. That's why we developed the package in an extendable way, making it possible to easily add custom behaviors and/or capabilities to Field and its output. 257 | 258 | ## Custom Layout Classes 259 | 260 | Sometimes, `addLayout` definitions can get quite long, or maybe you want them to be shared with other `Flexible` fields. The answer to this is to extract your Layout into its own class. 261 | 262 | ```php 263 | namespace App\Nova\Flexible\Layouts; 264 | 265 | use Laravel\Nova\Fields\Text; 266 | use Laravel\Nova\Fields\Markdown; 267 | use Whitecube\NovaFlexibleContent\Layouts\Layout; 268 | 269 | class SimpleWysiwygLayout extends Layout 270 | { 271 | /** 272 | * The layout's unique identifier 273 | * 274 | * @var string 275 | */ 276 | protected $name = 'wysiwyg'; 277 | 278 | /** 279 | * The displayed title 280 | * 281 | * @var string 282 | */ 283 | protected $title = 'Simple content section'; 284 | 285 | /** 286 | * Get the fields displayed by the layout. 287 | * 288 | * @return array 289 | */ 290 | public function fields() 291 | { 292 | return [ 293 | Text::make('Title'), 294 | Markdown::make('Content') 295 | ]; 296 | } 297 | } 298 | ``` 299 | 300 | You can then refer to this class as first and single parameter of the `addLayout(string $classname)` method: 301 | 302 | ```php 303 | Flexible::make('Content') 304 | ->addLayout(\App\Nova\Flexible\Layouts\SimpleWysiwygLayout::class); 305 | ``` 306 | 307 | #### Limiting layouts per type 308 | You can limit how many times the "Add Layout" button will appear for a specific type in a custom Layout class by setting the `$limit` attribute. 309 | 310 | ```php 311 | /** 312 | * The maximum amount of this layout type that can be added 313 | */ 314 | protected $limit = 1; 315 | ``` 316 | 317 | You can specify any integer, or no integer at all; in that case it will default to 1. 318 | 319 | You can create these Layout classes easily with the following artisan command 320 | ``` 321 | php artisan flexible:layout {classname?} {name?} 322 | 323 | // Ex: php artisan flexible:layout SimpleWysiwygLayout wysiwyg 324 | ``` 325 | 326 | 327 | ## Predefined Preset Classes 328 | 329 | In addition to reusable Layout classes, you can go a step further and create `Preset` classes for your Flexible fields. These allow you to reuse your whole Flexible field anywhere you want. They also make it easier to make your Flexible fields dynamic, for example if you want to add Layouts conditionally. And last but not least, they also have the added benefit of cleaning up your Nova Resource classes, if your Flexible field has a lot of `addLayout` definitions. 330 | 331 | ```php 332 | namespace App\Nova\Flexible\Presets; 333 | 334 | use App\PageBlocks; 335 | use Whitecube\NovaFlexibleContent\Flexible; 336 | use Whitecube\NovaFlexibleContent\Layouts\Preset; 337 | 338 | class WysiwygPagePreset extends Preset 339 | { 340 | 341 | /** 342 | * The available blocks 343 | * 344 | * @var Illuminate\Support\Collection 345 | */ 346 | protected $blocks; 347 | 348 | /** 349 | * Create a new preset instance 350 | * 351 | * @return void 352 | */ 353 | public function __construct() 354 | { 355 | $this->blocks = PageBlocks::orderBy('label')->get(); 356 | } 357 | 358 | /** 359 | * Execute the preset configuration 360 | * 361 | * @return void 362 | */ 363 | public function handle(Flexible $field) 364 | { 365 | $field->button('Add new block'); 366 | $field->resolver(\App\Nova\Flexible\Resolvers\WysiwygPageResolver::class); 367 | $field->help('Go to the "Page blocks" Resource in order to add new WYSIWYG block types.'); 368 | 369 | $this->blocks->each(function($block) use ($field) { 370 | $field->addLayout($block->title, $block->id, $block->getLayoutFields()); 371 | }); 372 | } 373 | } 374 | ``` 375 | 376 | Please note that Preset classes are resolved using Laravel's Container, meaning you can type-hint any useful dependency in the Preset's `__construct()` method. 377 | 378 | Once the Preset is defined, just reference its classname in your Flexible field using the `preset` method: 379 | ```php 380 | Flexible::make('Content') 381 | ->preset(\App\Nova\Flexible\Presets\WysiwygPagePreset::class); 382 | ``` 383 | 384 | You can create these Preset classes easily with the following artisan command: 385 | ``` 386 | php artisan flexible:preset {classname?} 387 | 388 | // Ex: php artisan flexible:preset WysiwygPagePreset 389 | ``` 390 | 391 | 392 | ## Custom Resolver Classes 393 | 394 | By default, the field takes advantage of a **JSON column** on your model's table. In some cases, a JSON attribute is just not the way to go. For example, you could want to store the values in another table (meaning you'll be using the Flexible Content field instead of a traditional BelongsToMany or HasMany field). No worries, we've got you covered! 395 | 396 | First, create the new Resolver class. For convenience, this can be achieved using the following artisan command: 397 | ``` 398 | php artisan flexible:resolver {classname?} 399 | 400 | // Ex: php artisan flexible:resolver WysiwygPageResolver 401 | ``` 402 | 403 | It will place the new Resolver class in your project's `app/Nova/Flexible/Resolvers` directory. Each Resolver should implement the `Whitecube\NovaFlexibleContent\Value\ResolverInterface` contract and therefore feature at least two methods: `set` and `get`. 404 | 405 | ### Resolving the field 406 | 407 | The `get` method is used to resolve the field's content. It is responsible to retrieve the content from somewhere and return a collection of hydrated Layouts. For example, we could want to retrieve the values on a `blocks` table and transform them into Layout instance: 408 | 409 | ```php 410 | /** 411 | * get the field's value 412 | * 413 | * @param mixed $resource 414 | * @param string $attribute 415 | * @param \Whitecube\NovaFlexibleContent\Layouts\Collection $layouts 416 | * @return \Illuminate\Support\Collection 417 | */ 418 | public function get($resource, $attribute, $layouts) { 419 | $blocks = $resource->blocks()->orderBy('order')->get(); 420 | 421 | return $blocks->map(function($block) use ($layouts) { 422 | $layout = $layouts->find($block->name); 423 | 424 | if(!$layout) return; 425 | 426 | return $layout->duplicateAndHydrate($block->id, ['value' => $block->value]); 427 | })->filter(); 428 | } 429 | ``` 430 | 431 | ### Filling the field 432 | 433 | The `set` method is responsible for saving the Flexible's content somewhere the `get` method will be able to access it. In our example, it should store the data in a `blocks` table: 434 | 435 | ```php 436 | /** 437 | * Set the field's value 438 | * 439 | * @param mixed $model 440 | * @param string $attribute 441 | * @param \Illuminate\Support\Collection $groups 442 | * @return void 443 | */ 444 | public function set($model, $attribute, $groups) 445 | { 446 | $class = get_class($model); 447 | 448 | $class::saved(function ($model) use ($groups) { 449 | $blocks = $groups->map(function($group, $index) { 450 | return [ 451 | 'name' => $group->name(), 452 | 'value' => json_encode($group->getAttributes()), 453 | 'order' => $index 454 | ]; 455 | }); 456 | 457 | // This is a quick & dirty example, syncing the models is probably a better idea. 458 | $model->blocks()->delete(); 459 | $model->blocks()->createMany($blocks); 460 | }); 461 | } 462 | ``` 463 | 464 | ## Usage with ebess/advanced-nova-media-library 465 | By popular demand, we have added compatibility with the advanced-nova-media-library field. 466 | This requires a few extra steps, as follows: 467 | 468 | 1. You must use a [custom layout class](https://whitecube.github.io/nova-flexible-content/#/?id=custom-layout-classes). 469 | 2. Your custom layout class must implement `Spatie\MediaLibrary\HasMedia` and use the `Whitecube\NovaFlexibleContent\Concerns\HasMediaLibrary` trait. 470 | 3. The parent model must implement `Spatie\MediaLibrary\HasMedia` and use the `Spatie\MediaLibrary\InteractsWithMedia` trait. 471 | 472 | Quick example, consider `Post` has a flexible field with a `SliderLayout`: 473 | 474 | ```php 475 | use Spatie\MediaLibrary\HasMedia; 476 | use Illuminate\Database\Eloquent\Model; 477 | use Spatie\MediaLibrary\InteractsWithMedia; 478 | use Whitecube\NovaFlexibleContent\Concerns\HasFlexible; 479 | 480 | class Post extends Model implements HasMedia 481 | { 482 | use HasFlexible; 483 | use InteractsWithMedia; 484 | } 485 | ``` 486 | 487 | ```php 488 | use Spatie\MediaLibrary\HasMedia; 489 | use Whitecube\NovaFlexibleContent\Layouts\Layout; 490 | use Ebess\AdvancedNovaMediaLibrary\Fields\Images; 491 | use Whitecube\NovaFlexibleContent\Concerns\HasMediaLibrary; 492 | 493 | class SliderLayout extends Layout implements HasMedia 494 | { 495 | use HasMediaLibrary; 496 | 497 | protected $name = 'sliderlayout'; 498 | protected $title = 'SliderLayout'; 499 | 500 | public function fields() 501 | { 502 | return [ 503 | Images::make('Images', 'images') 504 | ]; 505 | } 506 | 507 | } 508 | ``` 509 | 510 | You can now call `getMedia('images')` on your `SliderLayout` instance. 511 | 512 | ## Contributing 513 | 514 | Feel free to suggest changes, ask for new features or fix bugs yourself. We're sure there are still a lot of improvements that could be made and we would be very happy to merge useful pull requests. 515 | 516 | Thanks! 517 | 518 | ### Unit tests 519 | 520 | When adding a new feature or fixing a bug, please add corresponding unit tests. The current set of tests is limited, but every unit test added will improve the quality of the package. 521 | 522 | Run PHPUnit by calling `composer test`. 523 | 524 | ## Made with ❤️ for open source 525 | At [Whitecube](https://www.whitecube.be) we use a lot of open source software as part of our daily work. 526 | So when we have an opportunity to give something back, we're super excited! 527 | We hope you will enjoy this small contribution from us and would love to [hear from you](mailto:hello@whitecube.be) if you find it useful in your projects. 528 | -------------------------------------------------------------------------------- /src/Flexible.php: -------------------------------------------------------------------------------- 1 | button(__('Add layout')); 76 | 77 | // The original menu as default 78 | $this->menu('flexible-drop-menu'); 79 | 80 | $this->hideFromIndex(); 81 | } 82 | 83 | /** 84 | * @param string $component The name of the component to use for the menu 85 | * @param array $data 86 | * @return $this 87 | */ 88 | public function menu($component, $data = []) 89 | { 90 | return $this->withMeta(['menu' => compact('component', 'data')]); 91 | } 92 | 93 | /** 94 | * Set the button's label 95 | * 96 | * @param string $label 97 | * @return $this 98 | */ 99 | public function button($label) 100 | { 101 | return $this->withMeta(['button' => $label]); 102 | } 103 | 104 | /** 105 | * Make the flexible content take up the full width 106 | * of the form. Labels will sit above 107 | * 108 | * @return mixed 109 | */ 110 | public function fullWidth() 111 | { 112 | return $this->withMeta(['fullWidth' => true]); 113 | } 114 | 115 | /** 116 | * Make the flexible content take up the full width 117 | * of the form. Labels will sit above 118 | * 119 | * @return mixed 120 | */ 121 | public function stacked() 122 | { 123 | return $this->fullWidth(); 124 | } 125 | 126 | /** 127 | * Prevent the 'Add Layout' button from appearing more than once 128 | * 129 | * @return $this 130 | */ 131 | public function limit($limit = 1) 132 | { 133 | return $this->withMeta(['limit' => $limit]); 134 | } 135 | 136 | /** 137 | * Confirm remove 138 | * 139 | * @return $this 140 | */ 141 | public function confirmRemove($label = '', $yes = 'Delete', $no = 'Cancel') 142 | { 143 | return $this->withMeta([ 144 | 'confirmRemove' => true, 145 | 'confirmRemoveMessage' => $label, 146 | 'confirmRemoveYes' => $yes, 147 | 'confirmRemoveNo' => $no, 148 | ]); 149 | } 150 | 151 | /** 152 | * Set the field's resolver 153 | * 154 | * @param mixed $resolver 155 | * @return $this 156 | */ 157 | public function resolver($resolver) 158 | { 159 | if (is_string($resolver) && is_a($resolver, ResolverInterface::class, true)) { 160 | $resolver = new $resolver(); 161 | } 162 | 163 | if (! ($resolver instanceof ResolverInterface)) { 164 | throw new \Exception('Resolver Class "'.get_class($resolver).'" does not implement ResolverInterface.'); 165 | } 166 | 167 | $this->resolver = $resolver; 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * Register a new layout 174 | * 175 | * @param array $arguments 176 | * @return $this 177 | */ 178 | public function addLayout(...$arguments) 179 | { 180 | $count = count($arguments); 181 | 182 | if ($count > 1) { 183 | $this->registerLayout(new Layout(...$arguments)); 184 | 185 | return $this; 186 | } 187 | 188 | $layout = $arguments[0]; 189 | 190 | if (is_string($layout) && is_a($layout, LayoutInterface::class, true)) { 191 | $layout = new $layout(); 192 | } 193 | 194 | if (! ($layout instanceof LayoutInterface)) { 195 | throw new \Exception('Layout Class "'.get_class($layout).'" does not implement LayoutInterface.'); 196 | } 197 | 198 | $this->registerLayout($layout); 199 | 200 | return $this; 201 | } 202 | 203 | /** 204 | * Apply a field configuration preset 205 | * 206 | * @param string|Preset $class 207 | * @param array $params 208 | * @return $this 209 | */ 210 | public function preset($class, $params = []) 211 | { 212 | if (is_string($class)) { 213 | $preset = resolve($class, $params); 214 | } elseif ($class instanceof Preset) { 215 | $preset = $class; 216 | } 217 | 218 | $preset->handle($this); 219 | 220 | return $this; 221 | } 222 | 223 | public function collapsed(bool $value = true) 224 | { 225 | $this->withMeta(['collapsed' => $value]); 226 | 227 | return $this; 228 | } 229 | 230 | /** 231 | * Push a layout instance into the layouts collection 232 | * 233 | * @param \Whitecube\NovaFlexibleContent\Layouts\LayoutInterface $layout 234 | * @return void 235 | */ 236 | protected function registerLayout(LayoutInterface $layout) 237 | { 238 | if (! $this->layouts) { 239 | $this->layouts = new LayoutsCollection(); 240 | $this->withMeta(['layouts' => $this->layouts]); 241 | } 242 | 243 | $this->layouts->push($layout); 244 | } 245 | 246 | /** 247 | * Resolve the field's value. 248 | * 249 | * @param mixed $resource 250 | * @param string|null $attribute 251 | * @return void 252 | */ 253 | public function resolve($resource, ?string $attribute = null): void 254 | { 255 | $attribute = $attribute ?? $this->attribute; 256 | 257 | $this->registerOriginModel($resource); 258 | 259 | $this->buildGroups($resource, $attribute); 260 | 261 | $this->value = $this->resolveGroups($this->groups); 262 | } 263 | 264 | /** 265 | * Resolve the field's value for display on index and detail views. 266 | * 267 | * @param mixed $resource 268 | * @param string|null $attribute 269 | * @return void 270 | */ 271 | public function resolveForDisplay($resource, ?string $attribute = null): void 272 | { 273 | $attribute = $attribute ?? $this->attribute; 274 | 275 | $this->registerOriginModel($resource); 276 | 277 | $this->buildGroups($resource, $attribute); 278 | 279 | $this->value = $this->resolveGroupsForDisplay($this->groups); 280 | } 281 | 282 | /** 283 | * Check showing on detail. 284 | * 285 | * @param NovaRequest $request 286 | * @param $resource 287 | * @return bool 288 | */ 289 | public function isShownOnDetail(NovaRequest $request, $resource): bool 290 | { 291 | $this->layouts = $this->layouts->each(function ($layout) use ($request, $resource) { 292 | $layout->filterForDetail($request, $resource); 293 | }); 294 | 295 | return parent::isShownOnDetail($request, $resource); 296 | } 297 | 298 | /** 299 | * Hydrate the given attribute on the model based on the incoming request. 300 | * 301 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 302 | * @param string $requestAttribute 303 | * @param object $model 304 | * @param string $attribute 305 | * @return void|\Closure 306 | */ 307 | protected function fillAttribute(NovaRequest $request, $requestAttribute, $model, $attribute) 308 | { 309 | if (! $request->exists($requestAttribute)) { 310 | return; 311 | } 312 | 313 | $attribute = $attribute ?? $this->attribute; 314 | 315 | $this->registerOriginModel($model); 316 | 317 | $this->buildGroups($model, $attribute); 318 | 319 | $callbacks = collect($this->syncAndFillGroups($request, $requestAttribute)); 320 | 321 | $this->value = $this->resolver->set($model, $attribute, $this->groups); 322 | 323 | if ($callbacks->isEmpty()) { 324 | return; 325 | } 326 | 327 | return function () use ($callbacks) { 328 | $callbacks->each->__invoke(); 329 | }; 330 | } 331 | 332 | /** 333 | * Process an incoming POST Request 334 | * 335 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 336 | * @param string $requestAttribute 337 | * @return array 338 | */ 339 | protected function syncAndFillGroups(NovaRequest $request, $requestAttribute): array 340 | { 341 | if (! ($raw = $this->extractValue($request, $requestAttribute))) { 342 | $this->fireRemoveCallbacks(collect()); 343 | $this->groups = collect(); 344 | 345 | return []; 346 | } 347 | 348 | $callbacks = []; 349 | 350 | $new_groups = collect($raw)->map(function ($item) use ($request, &$callbacks) { 351 | $layout = $item['layout']; 352 | $key = $item['key']; 353 | $attributes = $item['attributes']; 354 | 355 | $group = $this->findGroup($key) ?? $this->newGroup($layout, $key); 356 | 357 | if (! $group instanceof Layout) { 358 | return []; 359 | } 360 | 361 | $scope = ScopedRequest::scopeFrom($request, $attributes, $key); 362 | $callbacks = array_merge($callbacks, $group->fill($scope)); 363 | 364 | return $group; 365 | })->filter(); 366 | 367 | $this->fireRemoveCallbacks($new_groups); 368 | 369 | $this->groups = $new_groups; 370 | 371 | return $callbacks; 372 | } 373 | 374 | /** 375 | * Fire's the remove callbacks on the layouts 376 | * 377 | * @param Collection $new_groups This should be (all) the new groups to bne compared against to find the removed groups 378 | */ 379 | protected function fireRemoveCallbacks(Collection $new_groups) 380 | { 381 | $new_group_keys = $new_groups->map(function ($item) { 382 | return $item->inUseKey(); 383 | }); 384 | $removed_groups = $this->groups->filter(function ($item) use ($new_group_keys) { 385 | return ! $new_group_keys->contains($item->inUseKey()); 386 | })->each(function ($group) { 387 | if (method_exists($group, 'fireRemoveCallback')) { 388 | $group->fireRemoveCallback($this); 389 | } 390 | }); 391 | } 392 | 393 | /** 394 | * Find the flexible's value in given request 395 | * 396 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 397 | * @param string $attribute 398 | * @return null|array 399 | */ 400 | protected function extractValue(NovaRequest $request, $attribute) 401 | { 402 | $value = $request[$attribute]; 403 | 404 | if (! $value) { 405 | return; 406 | } 407 | 408 | if (! is_array($value)) { 409 | throw new \Exception('Unable to parse incoming Flexible content, data should be an array.'); 410 | } 411 | 412 | return $value; 413 | } 414 | 415 | /** 416 | * Resolve all contained groups and their fields 417 | * 418 | * @param \Illuminate\Support\Collection $groups 419 | * @return \Illuminate\Support\Collection 420 | */ 421 | protected function resolveGroups($groups) 422 | { 423 | return $groups->map(function ($group) { 424 | return $group->getResolved(); 425 | }); 426 | } 427 | 428 | /** 429 | * Resolve all contained groups and their fields for display on index and 430 | * detail views. 431 | * 432 | * @param \Illuminate\Support\Collection $groups 433 | * @return \Illuminate\Support\Collection 434 | */ 435 | protected function resolveGroupsForDisplay($groups) 436 | { 437 | return $groups->map(function ($group) { 438 | return $group->getResolvedForDisplay(); 439 | }); 440 | } 441 | 442 | /** 443 | * Define the field's actual layout groups (as "base models") based 444 | * on the field's current model & attribute 445 | * 446 | * @param mixed $resource 447 | * @param string $attribute 448 | * @return \Illuminate\Support\Collection 449 | */ 450 | protected function buildGroups($resource, $attribute) 451 | { 452 | if (! $this->resolver) { 453 | $this->resolver(Resolver::class); 454 | } 455 | 456 | return $this->groups = $this->resolver->get($resource, $attribute, $this->layouts); 457 | } 458 | 459 | /** 460 | * Find an existing group based on its key 461 | * 462 | * @param string $key 463 | * @return \Whitecube\NovaFlexibleContent\Layouts\Layout 464 | */ 465 | protected function findGroup($key) 466 | { 467 | return $this->groups->first(function ($group) use ($key) { 468 | return $group->matches($key); 469 | }); 470 | } 471 | 472 | /** 473 | * Create a new group based on its key and layout 474 | * 475 | * @param string $layout 476 | * @param string $key 477 | * @return null|\Whitecube\NovaFlexibleContent\Layouts\Layout 478 | */ 479 | protected function newGroup($layout, $key) 480 | { 481 | $layout = $this->layouts->find($layout); 482 | 483 | if (! $layout instanceof Layout) { 484 | return null; 485 | } 486 | 487 | return $layout->duplicate($key); 488 | } 489 | 490 | /** 491 | * Get the validation rules for this field & its contained fields. 492 | * 493 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 494 | * @return array 495 | */ 496 | public function getRules(NovaRequest $request): array 497 | { 498 | return parent::getRules($request); 499 | } 500 | 501 | /** 502 | * Get the creation rules for this field & its contained fields. 503 | * 504 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 505 | * @return array|string 506 | */ 507 | public function getCreationRules(NovaRequest $request): array 508 | { 509 | return array_merge_recursive( 510 | parent::getCreationRules($request), 511 | $this->getFlexibleRules($request, 'creation') 512 | ); 513 | } 514 | 515 | /** 516 | * Get the update rules for this field & its contained fields. 517 | * 518 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 519 | * @return array 520 | */ 521 | public function getUpdateRules(NovaRequest $request): array 522 | { 523 | return array_merge_recursive( 524 | parent::getUpdateRules($request), 525 | $this->getFlexibleRules($request, 'update') 526 | ); 527 | } 528 | 529 | /** 530 | * Retrieve contained fields rules and assign them to nested array attributes 531 | * 532 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 533 | * @param string $specificty 534 | * @return array 535 | */ 536 | protected function getFlexibleRules(NovaRequest $request, $specificty) 537 | { 538 | if (! ($value = $this->extractValue($request, $this->attribute))) { 539 | return []; 540 | } 541 | 542 | $rules = $this->generateRules($request, $value, $specificty); 543 | 544 | if (! is_a($request, ScopedRequest::class)) { 545 | // We're not in a nested flexible, meaning we're 546 | // assuming the field is located at the root of 547 | // the model's attributes. Therefore, we should now 548 | // register all the collected validation rules for later 549 | // reference (see Http\TransformsFlexibleErrors). 550 | static::registerValidationKeys($rules); 551 | 552 | // Then, transform the rules into an array that's actually 553 | // usable by Laravel's Validator. 554 | $rules = $this->getCleanedRules($rules); 555 | } 556 | 557 | return $rules; 558 | } 559 | 560 | /** 561 | * Format all contained fields rules and return them. 562 | * 563 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 564 | * @param array $value 565 | * @param string $specificty 566 | * @return array 567 | */ 568 | protected function generateRules(NovaRequest $request, $value, $specificty) 569 | { 570 | return collect($value)->map(function ($item, $key) use ($request, $specificty) { 571 | $group = $this->newGroup($item['layout'], $item['key']); 572 | 573 | if (! $group) { 574 | return []; 575 | } 576 | 577 | $scope = ScopedRequest::scopeFrom($request, $item['attributes'], $item['key']); 578 | 579 | return $group->generateRules($scope, $specificty, $this->attribute.'.'.$key); 580 | }) 581 | ->collapse() 582 | ->all(); 583 | } 584 | 585 | /** 586 | * Transform Flexible rules array into an actual validator rules array 587 | * 588 | * @param array $rules 589 | * @return array 590 | */ 591 | protected function getCleanedRules(array $rules) 592 | { 593 | return array_map(function ($field) { 594 | return $field['rules']; 595 | }, $rules); 596 | } 597 | 598 | /** 599 | * Add validation keys to the valdiatedKeys register, which will be 600 | * used for transforming validation errors later in the request cycle. 601 | * 602 | * @param array $rules 603 | * @return void 604 | */ 605 | protected static function registerValidationKeys(array $rules) 606 | { 607 | $validatedKeys = array_map(function ($field) { 608 | return $field['attribute']; 609 | }, $rules); 610 | 611 | static::$validatedKeys = array_merge( 612 | static::$validatedKeys, $validatedKeys 613 | ); 614 | } 615 | 616 | /** 617 | * Return a previously registered validation key 618 | * 619 | * @param string $key 620 | * @return null|\Whitecube\NovaFlexibleContent\Http\FlexibleAttribute 621 | */ 622 | public static function getValidationKey($key) 623 | { 624 | return static::$validatedKeys[$key] ?? null; 625 | } 626 | 627 | /** 628 | * Registers a reference to the origin model for nested & contained fields 629 | * 630 | * @param mixed $model 631 | * @return void 632 | */ 633 | protected function registerOriginModel($model) 634 | { 635 | if (is_a($model, \Laravel\Nova\Resource::class)) { 636 | $model = $model->model(); 637 | } elseif (is_a($model, \Whitecube\NovaPage\Pages\Template::class)) { 638 | $model = $model->getOriginal(); 639 | } 640 | 641 | if (! is_a($model, \Illuminate\Database\Eloquent\Model::class)) { 642 | return; 643 | } 644 | 645 | static::$model = $model; 646 | } 647 | 648 | /** 649 | * Return the previously registered origin model 650 | * 651 | * @return null|\Illuminate\Database\Eloquent\Model 652 | */ 653 | public static function getOriginModel() 654 | { 655 | return static::$model; 656 | } 657 | } 658 | -------------------------------------------------------------------------------- /src/Layouts/Layout.php: -------------------------------------------------------------------------------- 1 | 26 | * @implements \Illuminate\Contracts\Support\Arrayable 27 | */ 28 | class Layout implements LayoutInterface, JsonSerializable, ArrayAccess, Arrayable 29 | { 30 | use HasAttributes; 31 | use HidesAttributes; 32 | use HasFlexible; 33 | 34 | /** 35 | * The layout's name 36 | * 37 | * @var string 38 | */ 39 | protected $name; 40 | 41 | /** 42 | * The layout's unique identifier 43 | * 44 | * @var string 45 | */ 46 | protected $key; 47 | 48 | /** 49 | * The layout's temporary identifier 50 | * 51 | * @var string 52 | */ 53 | protected $_key; 54 | 55 | /** 56 | * The layout's title 57 | * 58 | * @var string 59 | */ 60 | protected $title; 61 | 62 | /** 63 | * The layout's registered fields 64 | * 65 | * @var \Laravel\Nova\Fields\FieldCollection 66 | */ 67 | protected $fields; 68 | 69 | /** 70 | * The attributes that should be cast to native types. 71 | * 72 | * @var array 73 | */ 74 | protected $casts = []; 75 | 76 | /** 77 | * The attributes that should be mutated to dates. 78 | * 79 | * @var array 80 | */ 81 | protected $dates = []; 82 | 83 | /** 84 | * The callback to be called when this layout is removed 85 | */ 86 | protected $removeCallbackMethod; 87 | 88 | /** 89 | * The maximum amount of this layout type that can be added 90 | * Can be set in custom layouts 91 | */ 92 | protected $limit; 93 | 94 | /** 95 | * The parent model instance 96 | * 97 | * @var \Illuminate\Database\Eloquent\Model 98 | */ 99 | protected $model; 100 | 101 | /** 102 | * Define that Layout is a model, when in fact it is not. 103 | * 104 | * @var bool 105 | */ 106 | protected $exists = false; 107 | 108 | /** 109 | * Define that Layout is a model, when in fact it is not. 110 | * 111 | * @var bool 112 | */ 113 | protected $wasRecentlyCreated = false; 114 | 115 | /** 116 | * The relation resolver callbacks for the Layout. 117 | * 118 | * @var array 119 | */ 120 | protected $relationResolvers = []; 121 | 122 | /** 123 | * The loaded relationships for the Layout. 124 | * 125 | * @var array 126 | */ 127 | protected $relations = []; 128 | 129 | /** 130 | * Create a new base Layout instance 131 | * 132 | * @param string $title 133 | * @param string $name 134 | * @param array $fields 135 | * @param string $key 136 | * @param array $attributes 137 | * @param callable|null $removeCallbackMethod 138 | * @param int|null $limit 139 | * @return void 140 | */ 141 | public function __construct($title = null, $name = null, $fields = null, $key = null, $attributes = [], ?callable $removeCallbackMethod = null) 142 | { 143 | $this->title = $title ?? $this->title(); 144 | $this->name = $name ?? $this->name(); 145 | $this->fields = new FieldCollection($fields ?? $this->fields()); 146 | $this->key = is_null($key) ? null : $this->getProcessedKey($key); 147 | $this->removeCallbackMethod = $removeCallbackMethod; 148 | $this->setRawAttributes($this->cleanAttributes($attributes)); 149 | } 150 | 151 | /** 152 | * Determine if accessing missing attributes is disabled. 153 | * 154 | * @return bool 155 | */ 156 | public static function preventsAccessingMissingAttributes() 157 | { 158 | return false; 159 | } 160 | 161 | /** 162 | * Set the parent model instance 163 | * 164 | * @param Model $model 165 | * @return $this 166 | */ 167 | public function setModel($model) 168 | { 169 | $this->model = $model; 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Retrieve the layout's name (identifier) 176 | * 177 | * @return string 178 | */ 179 | public function name() 180 | { 181 | return $this->name; 182 | } 183 | 184 | /** 185 | * Retrieve the layout's title 186 | * 187 | * @return string 188 | */ 189 | public function title() 190 | { 191 | return $this->title; 192 | } 193 | 194 | /** 195 | * Retrieve the layout's fields 196 | * 197 | * @return array 198 | */ 199 | public function fields() 200 | { 201 | return $this->fields ? $this->fields->all() : []; 202 | } 203 | 204 | /** 205 | * Retrieve the layout's unique key 206 | * 207 | * @return string 208 | */ 209 | public function key() 210 | { 211 | return $this->key; 212 | } 213 | 214 | /** 215 | * Retrieve the key currently in use in the views 216 | * 217 | * @return string 218 | */ 219 | public function inUseKey() 220 | { 221 | return $this->_key ?? $this->key(); 222 | } 223 | 224 | /** 225 | * Check if this group matches the given key 226 | * 227 | * @param string $key 228 | * @return bool 229 | */ 230 | public function matches($key) 231 | { 232 | return $this->key === $key || $this->_key === $key; 233 | } 234 | 235 | /** 236 | * Resolve and return the result 237 | * 238 | * @return array 239 | */ 240 | public function getResolved() 241 | { 242 | $this->resolve(); 243 | 244 | return $this->getResolvedValue(); 245 | } 246 | 247 | /** 248 | * Resolve the field for display and return the result. 249 | * 250 | * @return array 251 | */ 252 | public function getResolvedForDisplay() 253 | { 254 | return $this->resolveForDisplay($this->getAttributes()); 255 | } 256 | 257 | /** 258 | * Get an empty cloned instance 259 | * 260 | * @param string $key 261 | * @return Layout 262 | */ 263 | public function duplicate($key) 264 | { 265 | return $this->duplicateAndHydrate($key); 266 | } 267 | 268 | /** 269 | * Get a cloned instance with set values 270 | * 271 | * @param string $key 272 | * @param array $attributes 273 | * @return Layout 274 | */ 275 | public function duplicateAndHydrate($key, array $attributes = []) 276 | { 277 | $fields = $this->fields->map(function ($field) { 278 | return $this->cloneField($field); 279 | }); 280 | 281 | $clone = new static( 282 | $this->title, 283 | $this->name, 284 | $fields, 285 | $key, 286 | $attributes, 287 | $this->removeCallbackMethod, 288 | $this->limit 289 | ); 290 | if (! is_null($this->model)) { 291 | $clone->setModel($this->model); 292 | } 293 | 294 | return $clone; 295 | } 296 | 297 | /** 298 | * Create a working field clone instance 299 | * 300 | * @param \Laravel\Nova\Fields\Field $original 301 | * @return \Laravel\Nova\Fields\Field 302 | */ 303 | protected function cloneField(Field $original) 304 | { 305 | $field = clone $original; 306 | 307 | $callables = ['displayCallback', 'resolveCallback', 'fillCallback', 'requiredCallback']; 308 | 309 | foreach ($callables as $callable) { 310 | if (! is_a($field->$callable ?? null, \Closure::class)) { 311 | continue; 312 | } 313 | 314 | try { 315 | $field->$callable = $field->$callable->bindTo($field); 316 | } catch (\Throwable $th) { 317 | // Binding an instance to a static closure will fail. Assuming 318 | // that's the cause of the error here, we leave the original 319 | // closure as-is. 320 | } 321 | } 322 | 323 | return $field; 324 | } 325 | 326 | /** 327 | * Resolve fields using given attributes. 328 | * 329 | * @param bool $empty 330 | * @return void 331 | */ 332 | public function resolve($empty = false) 333 | { 334 | $this->fields->each(function ($field) use ($empty) { 335 | $field->resolve($empty ? $this->duplicate($this->inUseKey()) : $this); 336 | }); 337 | } 338 | 339 | /** 340 | * Resolve fields for display using given attributes. 341 | * 342 | * @param array $attributes 343 | * @return array 344 | */ 345 | public function resolveForDisplay(array $attributes = []) 346 | { 347 | $this->fields->each(function ($field) use ($attributes) { 348 | $field->resolveForDisplay($attributes); 349 | }); 350 | 351 | return $this->getResolvedValue(); 352 | } 353 | 354 | /** 355 | * Filter the layout's fields for detail view 356 | * 357 | * @param NovaRequest $request 358 | * @param $resource 359 | */ 360 | public function filterForDetail(NovaRequest $request, $resource) 361 | { 362 | $this->fields = $this->fields->filterForDetail($request, $resource); 363 | } 364 | 365 | /** 366 | * Get the layout's resolved representation. Best used 367 | * after a resolve() call 368 | * 369 | * @return array 370 | */ 371 | public function getResolvedValue() 372 | { 373 | return [ 374 | 'layout' => $this->name, 375 | 376 | // The (old) temporary key is preferred to the new one during 377 | // field resolving because we need to keep track of the current 378 | // attributes during the next fill request that will override 379 | // the key with a new, stronger & definitive one. 380 | 'key' => $this->inUseKey(), 381 | 382 | // The layout's fields now temporarily contain the resolved 383 | // values from the current group's attributes. If multiple 384 | // groups use the same layout, the current values will be lost 385 | // since each group uses the same fields by reference. That's 386 | // why we need to serialize the field's current state. 387 | 'attributes' => $this->fields->jsonSerialize(), 388 | ]; 389 | } 390 | 391 | /** 392 | * Fill attributes using underlaying fields and incoming request 393 | * 394 | * @param ScopedRequest $request 395 | * @return array 396 | */ 397 | public function fill(ScopedRequest $request) 398 | { 399 | return $this->fields->map(function ($field) use ($request) { 400 | return $field->fill($request, $this); 401 | }) 402 | ->filter(function ($callback) { 403 | return is_callable($callback); 404 | }) 405 | ->values() 406 | ->all(); 407 | } 408 | 409 | /** 410 | * Force Fill the layout with an array of attributes. 411 | * 412 | * @param array $attributes 413 | * @return $this 414 | */ 415 | public function forceFill(array $attributes) 416 | { 417 | foreach ($attributes as $key => $value) { 418 | $attribute = Str::replace('->', '.', $key); 419 | Arr::set($this->attributes, $attribute, $value); 420 | } 421 | 422 | return $this; 423 | } 424 | 425 | /** 426 | * Get validation rules for fields concerned by given request 427 | * 428 | * @param ScopedRequest $request 429 | * @param string $specificty 430 | * @param string $key 431 | * @return array 432 | */ 433 | public function generateRules(ScopedRequest $request, $specificty, $key) 434 | { 435 | return $this->fields->map(function ($field) use ($request, $specificty, $key) { 436 | return $this->getScopedFieldRules($field, $request, $specificty, $key); 437 | }) 438 | ->collapse() 439 | ->all(); 440 | } 441 | 442 | /** 443 | * Get validation rules for fields concerned by given request 444 | * 445 | * @param \Laravel\Nova\Fields\Field $field 446 | * @param ScopedRequest $request 447 | * @param null|string $specificty 448 | * @param string $key 449 | * @return array 450 | */ 451 | protected function getScopedFieldRules($field, ScopedRequest $request, $specificty, $key) 452 | { 453 | $method = 'get'.ucfirst($specificty).'Rules'; 454 | 455 | $rules = call_user_func([$field, $method], $request); 456 | 457 | return collect($rules)->mapWithKeys(function($validatorRules, $attribute) use ($key, $field, $request) { 458 | $key = $request->isFileAttribute($attribute) 459 | ? $request->getFileAttribute($attribute) 460 | : $key.'.attributes.'.$attribute; 461 | 462 | return [$key => $this->wrapScopedFieldRules($field, $validatorRules)]; 463 | })->filter()->all(); 464 | } 465 | 466 | /** 467 | * The method to call when this layout is removed 468 | * 469 | * @param Flexible $flexible 470 | * @return mixed 471 | */ 472 | public function fireRemoveCallback(Flexible $flexible) 473 | { 474 | if (is_callable($this->removeCallbackMethod)) { 475 | return $this->removeCallbackMethod($flexible, $this); 476 | } 477 | 478 | return $this->removeCallback($flexible, $this); 479 | } 480 | 481 | /** 482 | * The default behaviour when removed 483 | * 484 | * @param Flexible $flexible 485 | * @param \Whitecube\NovaFlexibleContent\Layout $layout 486 | * @return mixed 487 | */ 488 | protected function removeCallback(Flexible $flexible, $layout) 489 | { 490 | } 491 | 492 | /** 493 | * Wrap the rules in an array containing field information for later use 494 | * 495 | * @param \Laravel\Nova\Fields\Field $field 496 | * @param array $rules 497 | * @return null|array 498 | */ 499 | protected function wrapScopedFieldRules($field, array $rules) 500 | { 501 | if (! $rules) { 502 | return; 503 | } 504 | 505 | if (is_a($rules['attribute'] ?? null, FlexibleAttribute::class)) { 506 | return $rules; 507 | } 508 | 509 | return [ 510 | 'attribute' => FlexibleAttribute::make($field->attribute, $this->inUseKey()), 511 | 'rules' => $rules, 512 | ]; 513 | } 514 | 515 | /** 516 | * Dynamically retrieve attributes on the layout. 517 | * 518 | * @param string $key 519 | * @return mixed 520 | */ 521 | public function __get($key) 522 | { 523 | return $this->getAttribute($key); 524 | } 525 | 526 | /** 527 | * Dynamically set attributes on the layout. 528 | * 529 | * @param string $key 530 | * @param mixed $value 531 | * @return void 532 | */ 533 | public function __set($key, $value) 534 | { 535 | $this->setAttribute($key, $value); 536 | } 537 | 538 | /** 539 | * Determine if the given attribute exists. 540 | * 541 | * @param mixed $offset 542 | * @return bool 543 | */ 544 | #[\ReturnTypeWillChange] 545 | public function offsetExists($offset) 546 | { 547 | return ! is_null($this->getAttribute($offset)); 548 | } 549 | 550 | /** 551 | * Get the value for a given offset. 552 | * 553 | * @param mixed $offset 554 | * @return mixed 555 | */ 556 | #[\ReturnTypeWillChange] 557 | public function offsetGet($offset) 558 | { 559 | return $this->getAttribute($offset); 560 | } 561 | 562 | /** 563 | * Set the value for a given offset. 564 | * 565 | * @param mixed $offset 566 | * @param mixed $value 567 | * @return void 568 | */ 569 | #[\ReturnTypeWillChange] 570 | public function offsetSet($offset, $value) 571 | { 572 | $this->setAttribute($offset, $value); 573 | } 574 | 575 | /** 576 | * Unset the value for a given offset. 577 | * 578 | * @param mixed $offset 579 | * @return void 580 | */ 581 | #[\ReturnTypeWillChange] 582 | public function offsetUnset($offset) 583 | { 584 | unset($this->attributes[$offset]); 585 | } 586 | 587 | /** 588 | * Determine if an attribute or relation exists on the model. 589 | * 590 | * @param string $key 591 | * @return bool 592 | */ 593 | public function __isset($key) 594 | { 595 | return $this->offsetExists($key); 596 | } 597 | 598 | /** 599 | * Unset an attribute on the model. 600 | * 601 | * @param string $key 602 | * @return void 603 | */ 604 | public function __unset($key) 605 | { 606 | $this->offsetUnset($key); 607 | } 608 | 609 | /** 610 | * Transform empty attribute values to null 611 | * 612 | * @param array $attributes 613 | * @return array 614 | */ 615 | protected function cleanAttributes($attributes) 616 | { 617 | foreach ($attributes as $key => $value) { 618 | if (! is_string($value) || strlen($value)) { 619 | continue; 620 | } 621 | $attributes[$key] = null; 622 | } 623 | 624 | return $attributes; 625 | } 626 | 627 | /** 628 | * Get the attributes that should be converted to dates. 629 | * 630 | * @return array 631 | */ 632 | protected function getDates() 633 | { 634 | return $this->dates ?? []; 635 | } 636 | 637 | /** 638 | * Get the format for database stored dates. 639 | * 640 | * @return string 641 | */ 642 | public function getDateFormat() 643 | { 644 | return $this->dateFormat ?: 'Y-m-d H:i:s'; 645 | } 646 | 647 | /** 648 | * Get the casts array. 649 | * 650 | * @return array 651 | */ 652 | public function getCasts() 653 | { 654 | return $this->casts ?? []; 655 | } 656 | 657 | /** 658 | * Check if relation exists. Layouts do not have relations. 659 | * 660 | * @return bool 661 | */ 662 | protected function relationLoaded() 663 | { 664 | return false; 665 | } 666 | 667 | /** 668 | * Get the dynamic relation resolver if defined or inherited, or return null. 669 | * Since it is not possible to define a relation on a layout, this method 670 | * returns null 671 | * 672 | * @param string $class 673 | * @param string $key 674 | * @return mixed 675 | */ 676 | public function relationResolver($class, $key) 677 | { 678 | return null; 679 | } 680 | 681 | /** 682 | * Transform layout for serialization 683 | * 684 | * @return array 685 | */ 686 | #[\ReturnTypeWillChange] 687 | public function jsonSerialize() 688 | { 689 | // Calling an empty "resolve" first in order to empty all fields 690 | $this->resolve(true); 691 | 692 | return [ 693 | 'name' => $this->name, 694 | 'title' => $this->title, 695 | 'fields' => $this->fields->jsonSerialize(), 696 | 'limit' => $this->limit, 697 | ]; 698 | } 699 | 700 | /** 701 | * Returns an unique key for this group if it's not already the case 702 | * 703 | * @param string $key 704 | * @return string 705 | * 706 | * @throws \Exception 707 | */ 708 | protected function getProcessedKey($key) 709 | { 710 | if (strpos($key, '-') === false && strlen($key) === 16) { 711 | return $key; 712 | } 713 | 714 | // The key is either generated by Javascript or not strong enough. 715 | // Before assigning a new valid key we'll keep track of this one 716 | // in order to keep it usable in a Flexible::findGroup($key) search. 717 | $this->_key = $key; 718 | 719 | if (function_exists('random_bytes')) { 720 | $bytes = random_bytes(ceil(16 / 2)); 721 | } elseif (function_exists('openssl_random_pseudo_bytes')) { 722 | $bytes = openssl_random_pseudo_bytes(ceil(16 / 2)); 723 | } else { 724 | throw new \Exception('No cryptographically secure random function available'); 725 | } 726 | 727 | return 'c'.substr(bin2hex($bytes), 0, 15); 728 | } 729 | 730 | /** 731 | * Convert the model instance to an array. 732 | * 733 | * @return array 734 | */ 735 | public function toArray() 736 | { 737 | return $this->attributesToArray(); 738 | } 739 | } 740 | --------------------------------------------------------------------------------