├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── dist ├── js │ ├── field.js │ └── field.js.LICENSE.txt └── mix-manifest.json ├── nova.mix.js ├── package.json ├── resources └── js │ ├── components │ └── CustomRelationshipField.vue │ └── field.js ├── screenshots ├── dark.png └── light.png ├── src ├── CustomRelationshipField.php ├── CustomRelationshipFieldServiceProvider.php └── CustomRelationshipFieldTrait.php ├── webpack.mix.js └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: milewski 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /node_modules 4 | package-lock.json 5 | composer.phar 6 | composer.lock 7 | phpunit.xml 8 | .phpunit.result.cache 9 | .DS_Store 10 | Thumbs.db 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Digital Creative 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom Relationship Field 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/digital-creative/custom-relationship-field)](https://packagist.org/packages/digital-creative/custom-relationship-field) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/digital-creative/custom-relationship-field)](https://packagist.org/packages/digital-creative/custom-relationship-field) 5 | [![License](https://img.shields.io/packagist/l/digital-creative/custom-relationship-field)](https://github.com/dcasia/custom-relationship-field/blob/main/LICENSE) 6 | 7 | 8 | 9 | Custom Relationship Field in action 10 | 11 | 12 | This field works just like as the default HasMany relationship field from nova but **without requiring a real relation** with the resource. 13 | 14 | That means you are free to show resource `A` into the details page of resource `B` without having to create a real relation between them. 15 | 16 | # Installation 17 | 18 | You can install the package via composer: 19 | 20 | ```shell 21 | composer require digital-creative/custom-relationship-field 22 | ``` 23 | 24 | ```php 25 | use DigitalCreative\CustomRelationshipField\CustomRelationshipField; 26 | use DigitalCreative\CustomRelationshipField\CustomRelationshipFieldTrait; 27 | 28 | trait UserWithSimilarNameTrait 29 | { 30 | public static function similarNameQuery(NovaRequest $request, Builder $query, User $model): Builder 31 | { 32 | return $query->where('last_name', $model->last_name)->whereKeyNot($model->getKey()); 33 | } 34 | 35 | public function similarNameFields(NovaRequest $request): array 36 | { 37 | return [ 38 | ID::make(), 39 | Text::make('First Name'), 40 | Text::make('Last Name'), 41 | ]; 42 | } 43 | 44 | public function similarNameActions(NovaRequest $request): array 45 | { 46 | return []; 47 | } 48 | 49 | public function similarNameFilters(NovaRequest $request): array 50 | { 51 | return []; 52 | } 53 | } 54 | 55 | class User extends Resource 56 | { 57 | use CustomRelationshipFieldTrait; 58 | use UserWithSimilarNameTrait; 59 | 60 | public function fields(NovaRequest $request): array 61 | { 62 | return [ 63 | ... 64 | CustomRelationshipField::make('Users with similar name', 'similarName', User::class), 65 | ... 66 | ]; 67 | } 68 | } 69 | ``` 70 | 71 | ## ⭐️ Show Your Support 72 | 73 | Please give a ⭐️ if this project helped you! 74 | 75 | ### Other Packages You Might Like 76 | 77 | - [Nova Dashboard](https://github.com/dcasia/nova-dashboard) - The missing dashboard for Laravel Nova! 78 | - [Nova Welcome Card](https://github.com/dcasia/nova-welcome-card) - A configurable version of the `Help card` that comes with Nova. 79 | - [Icon Action Toolbar](https://github.com/dcasia/icon-action-toolbar) - Replaces the default boring action menu with an inline row of icon-based actions. 80 | - [Expandable Table Row](https://github.com/dcasia/expandable-table-row) - Provides an easy way to append extra data to each row of your resource tables. 81 | - [Collapsible Resource Manager](https://github.com/dcasia/collapsible-resource-manager) - Provides an easy way to order and group your resources on the sidebar. 82 | - [Resource Navigation Tab](https://github.com/dcasia/resource-navigation-tab) - Organize your resource fields into tabs. 83 | - [Resource Navigation Link](https://github.com/dcasia/resource-navigation-link) - Create links to internal or external resources. 84 | - [Nova Mega Filter](https://github.com/dcasia/nova-mega-filter) - Display all your filters in a card instead of a tiny dropdown! 85 | - [Nova Pill Filter](https://github.com/dcasia/nova-pill-filter) - A Laravel Nova filter that renders into clickable pills. 86 | - [Nova Slider Filter](https://github.com/dcasia/nova-slider-filter) - A Laravel Nova filter for picking range between a min/max value. 87 | - [Nova Range Input Filter](https://github.com/dcasia/nova-range-input-filter) - A Laravel Nova range input filter. 88 | - [Nova FilePond](https://github.com/dcasia/nova-filepond) - A Nova field for uploading File, Image and Video using Filepond. 89 | - [Custom Relationship Field](https://github.com/dcasia/custom-relationship-field) - Emulate HasMany relationship without having a real relationship set between resources. 90 | - [Column Toggler](https://github.com/dcasia/column-toggler) - A Laravel Nova package that allows you to hide/show columns in the index view. 91 | - [Batch Edit Toolbar](https://github.com/dcasia/batch-edit-toolbar) - Allows you to update a single column of a resource all at once directly from the index page. 92 | 93 | ## License 94 | 95 | The MIT License (MIT). Please see [License File](https://raw.githubusercontent.com/dcasia/custom-relationship-field/master/LICENSE) for more information. 96 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "digital-creative/custom-relationship-field", 3 | "description": "Emulate HasMany relationship without having a real relationship set between resources", 4 | "keywords": [ 5 | "laravel", 6 | "nova", 7 | "nova4", 8 | "relationship", 9 | "has-many", 10 | "custom" 11 | ], 12 | "license": "MIT", 13 | "homepage": "https://github.com/dcasia/custom-relationship-field", 14 | "authors": [ 15 | { 16 | "name": "Rafael Milewski" 17 | } 18 | ], 19 | "require": { 20 | "php": ">=8.0", 21 | "laravel/nova": "^4.0" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "DigitalCreative\\CustomRelationshipField\\": "src/" 26 | } 27 | }, 28 | "extra": { 29 | "laravel": { 30 | "providers": [ 31 | "DigitalCreative\\CustomRelationshipField\\CustomRelationshipFieldServiceProvider" 32 | ] 33 | } 34 | }, 35 | "config": { 36 | "sort-packages": true 37 | }, 38 | "minimum-stability": "dev", 39 | "prefer-stable": true 40 | } 41 | -------------------------------------------------------------------------------- /dist/js/field.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e={744:(e,t)=>{t.Z=(e,t)=>{const i=e.__vccOpts||e;for(const[e,o]of t)i[e]=o;return i}}},t={};function i(o){var r=t[o];if(void 0!==r)return r.exports;var n=t[o]={exports:{}};return e[o](n,n.exports,i),n.exports}(()=>{const e=Vue;const t={emits:["actionExecuted"],props:["resourceName","resourceId","resource","field"],methods:{actionExecuted:function(){this.$emit("actionExecuted")}},data:function(){return{eventCallback:null}},mounted:function(){var e=this;Nova.$on("custom-relationship-field:request-extra-params",this.eventCallback=function(){Nova.$emit("custom-relationship-field:extra-params",{relationshipType:e.relationshipType})})},unmounted:function(){Nova.$off("custom-relationship-field:request-extra-params",this.eventCallback)},computed:{encodedAttribute:function(){return btoa("".concat(this.field.attribute,"|_::_|").concat(this.field.name))},relationshipType:function(){return"CustomRelationshipField:".concat(this.encodedAttribute)}}};const o=(0,i(744).Z)(t,[["render",function(t,i,o,r,n,a){var c=(0,e.resolveComponent)("ResourceIndex");return(0,e.openBlock)(),(0,e.createBlock)(c,{field:o.field,"resource-name":o.field.resourceName,"via-resource":o.resourceName,"via-resource-id":o.resourceId,"via-relationship":null,"relationship-type":a.relationshipType,onActionExecuted:a.actionExecuted,"load-cards":!1,"initial-per-page":o.field.perPage||5,"should-override-meta":!1,"custom-relationship-field-attribute":o.field.attribute,"custom-relationship-field-label":o.field.name},null,8,["field","resource-name","via-resource","via-resource-id","relationship-type","onActionExecuted","initial-per-page","custom-relationship-field-attribute","custom-relationship-field-label"])}]]);Nova.booting((function(e,t){e.component("detail-custom-relationship-field",o)}))})()})(); -------------------------------------------------------------------------------- /dist/js/field.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * vuex v4.1.0 3 | * (c) 2022 Evan You 4 | * @license MIT 5 | */ 6 | -------------------------------------------------------------------------------- /dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/field.js": "/js/field.js" 3 | } 4 | -------------------------------------------------------------------------------- /nova.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix') 2 | const webpack = require('webpack') 3 | const path = require('path') 4 | 5 | class NovaExtension { 6 | name() { 7 | return 'nova-extension' 8 | } 9 | 10 | register(name) { 11 | this.name = name 12 | } 13 | 14 | webpackConfig(webpackConfig) { 15 | webpackConfig.externals = { 16 | vue: 'Vue', 17 | } 18 | 19 | webpackConfig.resolve.alias = { 20 | ...(webpackConfig.resolve.alias || {}), 21 | '@': path.resolve(__dirname, '../../vendor/laravel/nova/resources/js/'), 22 | } 23 | 24 | webpackConfig.output = { 25 | uniqueName: this.name, 26 | } 27 | } 28 | } 29 | 30 | mix.extend('nova', new NovaExtension()) 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "watch": "mix watch", 5 | "production": "mix --production", 6 | "nova:install": "npm --prefix='../../vendor/laravel/nova' ci" 7 | }, 8 | "devDependencies": { 9 | "@vue/compiler-sfc": "^3.3.4", 10 | "laravel-mix": "^6.0.49", 11 | "vue-loader": "^17.2.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /resources/js/components/CustomRelationshipField.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 54 | -------------------------------------------------------------------------------- /resources/js/field.js: -------------------------------------------------------------------------------- 1 | import CustomRelationshipField from './components/CustomRelationshipField' 2 | 3 | Nova.booting((app, store) => { 4 | app.component('detail-custom-relationship-field', CustomRelationshipField) 5 | }) 6 | -------------------------------------------------------------------------------- /screenshots/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcasia/custom-relationship-field/a4759e28540f1915b27b2e529a5567edb332112e/screenshots/dark.png -------------------------------------------------------------------------------- /screenshots/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcasia/custom-relationship-field/a4759e28540f1915b27b2e529a5567edb332112e/screenshots/light.png -------------------------------------------------------------------------------- /src/CustomRelationshipField.php: -------------------------------------------------------------------------------- 1 | app->afterResolving(NovaRequest::class, function (NovaRequest $request): void { 22 | 23 | if ($relationshipType = $request->relationshipType) { 24 | 25 | if (Str::startsWith($relationshipType, 'CustomRelationshipField')) { 26 | 27 | $encoded = Str::after($relationshipType, ':'); 28 | 29 | [ $attribute, $label ] = Str::of(base64_decode($encoded))->explode('|_::_|'); 30 | 31 | $request->merge([ 32 | 'relationshipType' => 'hasMany', 33 | 'customRelationshipFieldAttribute' => $attribute, 34 | 'customRelationshipFieldLabel' => $label, 35 | ]); 36 | 37 | } 38 | 39 | } 40 | 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/CustomRelationshipFieldTrait.php: -------------------------------------------------------------------------------- 1 | input('customRelationshipFieldLabel') ?? parent::label(); 25 | } 26 | 27 | protected function fieldsMethod(NovaRequest $request): string 28 | { 29 | $prefix = 'fields'; 30 | 31 | if ($attribute = $this->extractAttributeFromRequest($request)) { 32 | $prefix = "{$attribute}Fields"; 33 | } 34 | 35 | if ($request->isInlineCreateRequest() && method_exists($this, "{$prefix}ForInlineCreate")) { 36 | return "{$prefix}ForInlineCreate"; 37 | } 38 | 39 | if ($request->isResourceIndexRequest() && method_exists($this, "{$prefix}ForIndex")) { 40 | return "{$prefix}ForIndex"; 41 | } 42 | 43 | if ($request->isResourceDetailRequest() && method_exists($this, "{$prefix}ForDetail")) { 44 | return "{$prefix}ForDetail"; 45 | } 46 | 47 | if ($request->isCreateOrAttachRequest() && method_exists($this, "{$prefix}ForCreate")) { 48 | return "{$prefix}ForCreate"; 49 | } 50 | 51 | if ($request->isUpdateOrUpdateAttachedRequest() && method_exists($this, "{$prefix}ForUpdate")) { 52 | return "{$prefix}ForUpdate"; 53 | } 54 | 55 | return $prefix; 56 | } 57 | 58 | public function buildAvailableFields(NovaRequest $request, array $methods): FieldCollection 59 | { 60 | if ($attribute = $this->extractAttributeFromRequest($request)) { 61 | 62 | $method = "{$attribute}Fields"; 63 | 64 | $fields = collect([ 65 | method_exists($this, $method) ? $this->{$method}($request) : [], 66 | ]); 67 | 68 | collect($methods) 69 | ->map(fn (string $method) => $attribute . ucfirst($method)) 70 | ->filter(fn (string $method) => $method !== "{$attribute}Fields" && method_exists($this, $method)) 71 | ->each(function (string $method) use ($request, $fields): void { 72 | $fields->push([ $this->{$method}($request) ]); 73 | }); 74 | 75 | return FieldCollection::make(array_values($this->filter($fields->flatten()->all()))); 76 | 77 | } 78 | 79 | return parent::buildAvailableFields($request, $methods); 80 | } 81 | 82 | public function resolveActions(NovaRequest $request): Collection 83 | { 84 | if ($method = $this->extractAttributeFromRequest($request)) { 85 | 86 | $method = "{$method}Actions"; 87 | 88 | if (method_exists($this, $method)) { 89 | 90 | return ActionCollection::make( 91 | $this->filter($this->{$method}($request)), 92 | ); 93 | 94 | } 95 | 96 | } 97 | 98 | return parent::resolveActions($request); 99 | } 100 | 101 | public function resolveCards(NovaRequest $request): Collection 102 | { 103 | if ($method = $this->extractAttributeFromRequest($request)) { 104 | 105 | $method = "{$method}Cards"; 106 | 107 | if (method_exists($this, $method)) { 108 | return collect(array_values($this->filter($this->{$method}($request)))); 109 | } 110 | 111 | } 112 | 113 | return parent::resolveCards($request); 114 | } 115 | 116 | public function resolveFilters(NovaRequest $request): Collection 117 | { 118 | if ($method = $this->extractAttributeFromRequest($request)) { 119 | 120 | $method = "{$method}Filters"; 121 | 122 | if (method_exists($this, $method)) { 123 | return collect(array_values($this->filter($this->{$method}($request)))); 124 | } 125 | 126 | } 127 | 128 | return parent::resolveFilters($request); 129 | } 130 | 131 | public static function buildIndexQuery( 132 | NovaRequest $request, 133 | $query, 134 | $search = null, 135 | array $filters = [], 136 | array $orderings = [], 137 | $withTrashed = TrashedStatus::DEFAULT, 138 | ): Builder|BelongsToMany 139 | { 140 | if ($method = static::extractAttributeFromRequest($request)) { 141 | 142 | $method = "{$method}Query"; 143 | 144 | if (method_exists(static::class, $method)) { 145 | 146 | return static::applyOrderings(static::applyFilters( 147 | $request, static::initializeQuery($request, $query, $search, $withTrashed), $filters, 148 | ), $orderings)->tap(function ($query) use ($method, $request): void { 149 | 150 | $resource = Nova::modelInstanceForKey($request->viaResource) 151 | ->newQueryWithoutScopes() 152 | ->find($request->viaResourceId); 153 | 154 | static::$method($request, $query->with(static::$with), $resource); 155 | 156 | }); 157 | 158 | } 159 | 160 | } 161 | 162 | return parent::buildIndexQuery(...func_get_args()); 163 | } 164 | 165 | private static function extractAttributeFromRequest(NovaRequest $request): ?string 166 | { 167 | return $request->input('customRelationshipFieldAttribute'); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix') 2 | 3 | require('./nova.mix') 4 | 5 | mix 6 | .setPublicPath('dist') 7 | .js('resources/js/field.js', 'js') 8 | .vue({ version: 3 }) 9 | .nova('digital-creative/custom-relationship-field') 10 | --------------------------------------------------------------------------------